diff --git a/.dockerignore b/.dockerignore
index 85355537d..33be4b6ee 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -26,8 +26,9 @@ version_builds/
node_modules/
**/node_modules/
frontend/node_modules/
-frontend/dist/
-frontend/playwright-report/
+frontend/editor/dist/
+frontend/dist-portal/
+frontend/editor/playwright-report/
.npm/
.yarn/
diff --git a/.gitignore b/.gitignore
index 1b7e69c49..9e221c7eb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -212,7 +212,7 @@ out/
*.asc
# Allow test fixture certificates (synthetic, no real credentials)
-!frontend/src/core/tests/test-fixtures/certs/**
+!frontend/editor/src/core/tests/test-fixtures/certs/**
# SSH Keys
*.pub
@@ -254,7 +254,7 @@ node_modules/
*compact*.json
test_batch.json
*.backup.*.json
-frontend/public/locales/*/translation.backup*.json
+frontend/editor/public/locales/*/translation.backup*.json
# Development/build artifacts
.gradle-cache/
diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml
index 9e6f87889..b2fc9d45d 100644
--- a/.taskfiles/frontend.yml
+++ b/.taskfiles/frontend.yml
@@ -112,6 +112,12 @@ tasks:
- task: dev:_run
vars: { MODE: prototypes, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
+ dev:portal:
+ desc: "Start developer portal dev server"
+ deps: [install]
+ cmds:
+ - npx vite portal --port {{.PORT | default "5173"}}{{if .OPEN}} --open{{end}}
+
# ============================================================
# Build
# ============================================================
@@ -156,6 +162,24 @@ tasks:
cmds:
- npx vite build editor --mode prototypes
+ build:portal:
+ desc: "Build developer portal"
+ deps: [install]
+ cmds:
+ - npx vite build portal
+
+ storybook:
+ desc: "Start Storybook dev server"
+ deps: [install]
+ cmds:
+ - npx storybook dev -p 6006 {{.CLI_ARGS}}
+
+ storybook:build:
+ desc: "Build static Storybook"
+ deps: [install]
+ cmds:
+ - npx storybook build {{.CLI_ARGS}}
+
# ============================================================
# Code quality
# ============================================================
@@ -165,7 +189,10 @@ tasks:
deps: [install]
cmds:
- npx eslint --max-warnings=0
- - npx dpdm editor/src --circular --no-warning --no-tree --exit-code circular:1
+ # Globs (not a bare dir) so dpdm walks the whole tree — `editor/src`
+ # alone matched only 2 files. dpdm expands the braces itself, so this is
+ # shell-agnostic. Covers editor, portal, and the shared design system.
+ - 'npx dpdm "editor/src/**/*.{ts,tsx}" "portal/src/**/*.{ts,tsx}" "shared/**/*.{ts,tsx}" --circular --no-warning --no-tree --exit-code circular:1'
lint:fix:
desc: "Auto-fix lint issues"
@@ -236,6 +263,18 @@ tasks:
cmds:
- npx tsc --noEmit --project editor/src/prototypes/tsconfig.json
+ typecheck:portal:
+ desc: "Typecheck developer portal build variant"
+ deps: [install]
+ cmds:
+ - npx tsc --noEmit --project portal/tsconfig.json
+
+ typecheck:shared:
+ desc: "Typecheck the shared design system"
+ deps: [install]
+ cmds:
+ - npx tsc --noEmit --project shared/tsconfig.json
+
typecheck:all:
desc: "Typecheck all build variants"
cmds:
@@ -245,6 +284,8 @@ tasks:
- task: typecheck:desktop
- task: typecheck:scripts
- task: typecheck:prototypes
+ - task: typecheck:portal
+ - task: typecheck:shared
# ============================================================
# Quality Gate
@@ -265,7 +306,9 @@ tasks:
- task: lint
- task: format:check
- task: build
+ - task: build:portal
- task: test
+ - task: storybook:build
# ============================================================
# Test
diff --git a/LICENSE b/LICENSE
index 2cf4a972a..efc31d3a4 100644
--- a/LICENSE
+++ b/LICENSE
@@ -10,14 +10,16 @@ if that directory exists, is licensed under the license defined in "app/propriet
if that directory exists, is licensed under the license defined in "app/saas/LICENSE".
* All content that resides under the "engine/" directory of this repository,
if that directory exists, is licensed under the license defined in "engine/LICENSE".
-* All content that resides under the "frontend/src/proprietary/" directory of this repository,
-if that directory exists, is licensed under the license defined in "frontend/src/proprietary/LICENSE".
-* All content that resides under the "frontend/src/desktop/" directory of this repository,
-if that directory exists, is licensed under the license defined in "frontend/src/desktop/LICENSE".
-* All content that resides under the "frontend/src/saas/" directory of this repository,
-if that directory exists, is licensed under the license defined in "frontend/src/saas/LICENSE".
-* All content that resides under the "frontend/src/prototypes/" directory of this repository,
-if that directory exists, is licensed under the license defined in "frontend/src/prototypes/LICENSE".
+* All content that resides under the "frontend/editor/src/proprietary/" directory of this repository,
+if that directory exists, is licensed under the license defined in "frontend/editor/src/proprietary/LICENSE".
+* All content that resides under the "frontend/editor/src/desktop/" directory of this repository,
+if that directory exists, is licensed under the license defined in "frontend/editor/src/desktop/LICENSE".
+* All content that resides under the "frontend/editor/src/saas/" directory of this repository,
+if that directory exists, is licensed under the license defined in "frontend/editor/src/saas/LICENSE".
+* All content that resides under the "frontend/editor/src/prototypes/" directory of this repository,
+if that directory exists, is licensed under the license defined in "frontend/editor/src/prototypes/LICENSE".
+* All content that resides under the "frontend/portal/" directory of this repository,
+if that directory exists, is licensed under the license defined in "frontend/portal/LICENSE".
* Content outside of the above mentioned directories or restrictions above is
available under the MIT License as defined below.
diff --git a/docker/frontend/Dockerfile b/docker/frontend/Dockerfile
index 9227f516a..e92f19ddc 100644
--- a/docker/frontend/Dockerfile
+++ b/docker/frontend/Dockerfile
@@ -13,7 +13,7 @@ RUN npm ci
COPY frontend .
# Build the application (vite root is editor/, output lands in editor/dist/)
-RUN npx vite --root editor build
+RUN npx vite build editor
# Production stage
FROM nginx:alpine@sha256:b0f7830b6bfaa1258f45d94c240ab668ced1b3651c8a222aefe6683447c7bf55
diff --git a/frontend/.gitignore b/frontend/.gitignore
index 159c76ff5..f881e6fb0 100644
--- a/frontend/.gitignore
+++ b/frontend/.gitignore
@@ -11,6 +11,8 @@
# production
/build
/dist
+/dist-portal
+/storybook-static
/editor/build
/editor/dist
diff --git a/frontend/.prettierignore b/frontend/.prettierignore
index c8240371d..0dec02ff5 100644
--- a/frontend/.prettierignore
+++ b/frontend/.prettierignore
@@ -8,6 +8,8 @@ editor/src-tauri/**/target/
editor/src-tauri/gen/
node_modules/
editor/public/vendor/
+# Auto-generated by MSW (`msw init`); regenerated verbatim, not hand-formatted.
+portal/public/mockServiceWorker.js
editor/public/pdfjs*/
editor/public/js/thirdParty/
editor/public/css/cookieconsent.css
diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts
new file mode 100644
index 000000000..7a53d8e9c
--- /dev/null
+++ b/frontend/.storybook/main.ts
@@ -0,0 +1,42 @@
+import { resolve } from "node:path";
+import type { StorybookConfig } from "@storybook/react-vite";
+
+/**
+ * Storybook 9 ships essentials, interactions, and docs as built-ins, so the
+ * addon list is just the extras we want: theme switching + a11y auditing.
+ *
+ * Story files live next to their components in shared/ and portal/src/.
+ * MDX docs pages live in portal/src/docs/.
+ */
+const config: StorybookConfig = {
+ stories: [
+ "../portal/src/**/*.mdx",
+ "../portal/src/**/*.stories.@(ts|tsx)",
+ "../shared/**/*.mdx",
+ "../shared/**/*.stories.@(ts|tsx)",
+ ],
+ addons: ["@storybook/addon-themes", "@storybook/addon-a11y"],
+ framework: {
+ name: "@storybook/react-vite",
+ options: {},
+ },
+ typescript: {
+ reactDocgen: "react-docgen-typescript",
+ },
+ // Serve the MSW worker file from portal/public so Storybook can intercept
+ // network calls the same way the dev portal does.
+ staticDirs: ["../portal/public"],
+ viteFinal: async (config) => {
+ // Wire @portal/* and @shared/* aliases directly on the Storybook bundler so
+ // story imports resolve without needing the portal's vite config.
+ config.resolve = config.resolve ?? {};
+ config.resolve.alias = {
+ ...(config.resolve.alias ?? {}),
+ "@portal": resolve(__dirname, "../portal/src"),
+ "@shared": resolve(__dirname, "../shared"),
+ };
+ return config;
+ },
+};
+
+export default config;
diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx
new file mode 100644
index 000000000..c94b16973
--- /dev/null
+++ b/frontend/.storybook/preview.tsx
@@ -0,0 +1,142 @@
+// Storybook compiles .storybook/* with the classic JSX runtime, so the JSX in
+// the decorators below transpiles to React.createElement and needs React in
+// scope. (The app + story files use the automatic runtime via the portal vite
+// config; this import is specifically for the preview config file.)
+import React, { useEffect } from "react";
+import type { Decorator, Preview } from "@storybook/react-vite";
+import { initialize, mswLoader } from "msw-storybook-addon";
+import { MemoryRouter } from "react-router-dom";
+import { withThemeByDataAttribute } from "@storybook/addon-themes";
+import { MantineProvider } from "@mantine/core";
+
+// Reference React so the import isn't dropped as unused by the bundler — the
+// classic runtime needs it present even though it's not named in the JSX.
+void React;
+
+import { TierProvider, type Tier } from "@portal/contexts/TierContext";
+import { ThemeProvider } from "@portal/contexts/ThemeContext";
+import { UIProvider } from "@portal/contexts/UIContext";
+import { mantineTheme } from "@portal/theme/mantineTheme";
+import { handlers } from "@portal/mocks/handlers";
+
+import "@mantine/core/styles.css";
+import "@shared/tokens/tokens.css";
+import "@shared/tokens/base.css";
+
+// Start MSW once. Storybook runs in a browser so this uses the service worker.
+initialize({ onUnhandledRequest: "bypass" }, handlers);
+
+/**
+ * Bridge between Storybook's `tier` global toolbar and the actual TierProvider.
+ * Without this the toolbar would just change a label; with it, every story
+ * that calls useTier() reflects the active toolbar value.
+ */
+function TierBridge({
+ tier,
+ children,
+}: {
+ tier: Tier;
+ children: React.ReactNode;
+}) {
+ return {children} ;
+}
+
+/** Forces the TierProvider to re-mount whenever the toolbar tier changes. */
+function TierKey({
+ tier,
+ children,
+}: {
+ tier: Tier;
+ children: React.ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+/** Keeps useTheme() and the data-theme attribute in sync. */
+function ThemeWatcher() {
+ useEffect(() => {
+ // The addon-themes decorator already sets data-theme on .
+ // We just read it on mount so ThemeProvider picks it up.
+ }, []);
+ return null;
+}
+
+const withProviders: Decorator = (Story, context) => {
+ const tier = (context.globals.tier as Tier) ?? "pro";
+ // withThemeByDataAttribute exposes the toolbar theme as the `theme` global.
+ // Bind Mantine's color scheme to it so Mantine chrome (inputs, focus rings,
+ // default surfaces) follows the dark toggle alongside the SUI CSS variables.
+ // The global initialises to "" (before any toolbar interaction), so treat
+ // anything that isn't "dark" as light — matching the addon's own
+ // `selected || defaultTheme` fallback where defaultTheme is light.
+ const colorScheme = context.globals.theme === "dark" ? "dark" : "light";
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const preview: Preview = {
+ loaders: [mswLoader],
+ parameters: {
+ layout: "padded",
+ controls: {
+ matchers: { color: /(background|color)$/i, date: /Date$/i },
+ },
+ backgrounds: {
+ default: "app",
+ values: [
+ { name: "app", value: "var(--color-bg)" },
+ { name: "surface", value: "var(--color-surface)" },
+ ],
+ },
+ a11y: {
+ // Run axe automatically against the story root; violations show in the
+ // Accessibility panel. `context` replaced `element` in addon-a11y 9.x.
+ context: "#storybook-root",
+ config: {},
+ options: {},
+ test: "todo",
+ },
+ },
+ globalTypes: {
+ tier: {
+ name: "Tier",
+ description: "Subscription tier — drives useTier() everywhere",
+ defaultValue: "pro",
+ toolbar: {
+ icon: "star",
+ items: [
+ { value: "free", title: "Free" },
+ { value: "pro", title: "Pay-as-you-go" },
+ { value: "enterprise", title: "Enterprise" },
+ ],
+ dynamicTitle: true,
+ },
+ },
+ },
+ decorators: [
+ withProviders,
+ withThemeByDataAttribute({
+ themes: { light: "light", dark: "dark" },
+ defaultTheme: "light",
+ attributeName: "data-theme",
+ }),
+ ],
+};
+
+export default preview;
diff --git a/frontend/editor/scripts/tsconfig.json b/frontend/editor/scripts/tsconfig.json
index f6f5b9911..5f95926cb 100644
--- a/frontend/editor/scripts/tsconfig.json
+++ b/frontend/editor/scripts/tsconfig.json
@@ -5,6 +5,5 @@
"moduleResolution": "node16",
"noEmit": true
},
- "include": ["./**/*.ts", "./**/*.mts"],
- "exclude": []
+ "include": ["./**/*.ts", "./**/*.mts"]
}
diff --git a/frontend/editor/src/core/tsconfig.json b/frontend/editor/src/core/tsconfig.json
index 51c970d00..655b1900f 100644
--- a/frontend/editor/src/core/tsconfig.json
+++ b/frontend/editor/src/core/tsconfig.json
@@ -3,7 +3,8 @@
"compilerOptions": {
"baseUrl": "../../",
"paths": {
- "@app/*": ["src/core/*"]
+ "@app/*": ["src/core/*"],
+ "@shared/*": ["../shared/*"]
}
},
"include": ["../global.d.ts", "../*.js", "../*.ts", "../*.tsx", "."]
diff --git a/frontend/editor/src/desktop/tsconfig.json b/frontend/editor/src/desktop/tsconfig.json
index 7fa180a62..f50dc35a6 100644
--- a/frontend/editor/src/desktop/tsconfig.json
+++ b/frontend/editor/src/desktop/tsconfig.json
@@ -5,7 +5,8 @@
"paths": {
"@app/*": ["src/desktop/*", "src/proprietary/*", "src/core/*"],
"@proprietary/*": ["src/proprietary/*"],
- "@core/*": ["src/core/*"]
+ "@core/*": ["src/core/*"],
+ "@shared/*": ["../shared/*"]
}
},
"include": [
diff --git a/frontend/editor/src/proprietary/tsconfig.json b/frontend/editor/src/proprietary/tsconfig.json
index 14644446d..16a21a4c9 100644
--- a/frontend/editor/src/proprietary/tsconfig.json
+++ b/frontend/editor/src/proprietary/tsconfig.json
@@ -4,7 +4,8 @@
"baseUrl": "../../",
"paths": {
"@app/*": ["src/proprietary/*", "src/core/*"],
- "@core/*": ["src/core/*"]
+ "@core/*": ["src/core/*"],
+ "@shared/*": ["../shared/*"]
}
},
"include": [
diff --git a/frontend/editor/src/prototypes/tsconfig.json b/frontend/editor/src/prototypes/tsconfig.json
index 44e477e98..e632856a4 100644
--- a/frontend/editor/src/prototypes/tsconfig.json
+++ b/frontend/editor/src/prototypes/tsconfig.json
@@ -5,7 +5,8 @@
"paths": {
"@app/*": ["src/prototypes/*", "src/proprietary/*", "src/core/*"],
"@proprietary/*": ["src/proprietary/*"],
- "@core/*": ["src/core/*"]
+ "@core/*": ["src/core/*"],
+ "@shared/*": ["../shared/*"]
}
},
"include": [
diff --git a/frontend/editor/src/saas/tsconfig.json b/frontend/editor/src/saas/tsconfig.json
index e64e8b099..1e59f4590 100644
--- a/frontend/editor/src/saas/tsconfig.json
+++ b/frontend/editor/src/saas/tsconfig.json
@@ -5,7 +5,8 @@
"paths": {
"@app/*": ["src/saas/*", "src/proprietary/*", "src/core/*"],
"@proprietary/*": ["src/proprietary/*"],
- "@core/*": ["src/core/*"]
+ "@core/*": ["src/core/*"],
+ "@shared/*": ["../shared/*"]
}
},
"include": ["../global.d.ts", "../*.js", "../*.ts", "../*.tsx", "."]
diff --git a/frontend/editor/tsconfig.core.vite.json b/frontend/editor/tsconfig.core.vite.json
index daa02a7f0..9d298ab5a 100644
--- a/frontend/editor/tsconfig.core.vite.json
+++ b/frontend/editor/tsconfig.core.vite.json
@@ -2,8 +2,9 @@
"extends": "./tsconfig.json",
"compilerOptions": {
"paths": {
- "@app/*": ["src/core/*"]
+ "@app/*": ["src/core/*"],
+ "@shared/*": ["../shared/*"]
}
},
- "exclude": ["src/proprietary", "src/desktop", "node_modules"]
+ "exclude": ["src/proprietary", "src/desktop"]
}
diff --git a/frontend/editor/tsconfig.desktop.vite.json b/frontend/editor/tsconfig.desktop.vite.json
index abdd28a85..ee4556ade 100644
--- a/frontend/editor/tsconfig.desktop.vite.json
+++ b/frontend/editor/tsconfig.desktop.vite.json
@@ -4,14 +4,14 @@
"paths": {
"@app/*": ["src/desktop/*", "src/proprietary/*", "src/core/*"],
"@proprietary/*": ["src/proprietary/*"],
- "@core/*": ["src/core/*"]
+ "@core/*": ["src/core/*"],
+ "@shared/*": ["../shared/*"]
}
},
"exclude": [
"src/core/**/*.test.ts*",
"src/core/**/*.spec.ts*",
"src/proprietary/**/*.test.ts*",
- "src/proprietary/**/*.spec.ts*",
- "node_modules"
+ "src/proprietary/**/*.spec.ts*"
]
}
diff --git a/frontend/editor/tsconfig.json b/frontend/editor/tsconfig.json
index 37fc14efd..ad5870a5f 100644
--- a/frontend/editor/tsconfig.json
+++ b/frontend/editor/tsconfig.json
@@ -34,7 +34,8 @@
/* Specify a set of entries that re-map imports to additional lookup locations. */
"@app/*": ["src/desktop/*", "src/proprietary/*", "src/core/*"],
"@proprietary/*": ["src/proprietary/*"],
- "@core/*": ["src/core/*"]
+ "@core/*": ["src/core/*"],
+ "@shared/*": ["../shared/*"]
},
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
diff --git a/frontend/editor/tsconfig.proprietary.vite.json b/frontend/editor/tsconfig.proprietary.vite.json
index 2cde30d2b..78c638ddb 100644
--- a/frontend/editor/tsconfig.proprietary.vite.json
+++ b/frontend/editor/tsconfig.proprietary.vite.json
@@ -4,13 +4,9 @@
"paths": {
"@app/*": ["src/proprietary/*", "src/core/*"],
"@proprietary/*": ["src/proprietary/*"],
- "@core/*": ["src/core/*"]
+ "@core/*": ["src/core/*"],
+ "@shared/*": ["../shared/*"]
}
},
- "exclude": [
- "src/core/**/*.test.ts*",
- "src/core/**/*.spec.ts*",
- "src/desktop",
- "node_modules"
- ]
+ "exclude": ["src/core/**/*.test.ts*", "src/core/**/*.spec.ts*", "src/desktop"]
}
diff --git a/frontend/editor/tsconfig.prototypes.vite.json b/frontend/editor/tsconfig.prototypes.vite.json
index eae017ee6..2e14c541b 100644
--- a/frontend/editor/tsconfig.prototypes.vite.json
+++ b/frontend/editor/tsconfig.prototypes.vite.json
@@ -4,7 +4,8 @@
"paths": {
"@app/*": ["src/prototypes/*", "src/proprietary/*", "src/core/*"],
"@proprietary/*": ["src/proprietary/*"],
- "@core/*": ["src/core/*"]
+ "@core/*": ["src/core/*"],
+ "@shared/*": ["../shared/*"]
}
},
"exclude": [
@@ -13,7 +14,6 @@
"src/proprietary/**/*.test.ts*",
"src/proprietary/**/*.spec.ts*",
"src/desktop",
- "src/saas",
- "node_modules"
+ "src/saas"
]
}
diff --git a/frontend/editor/tsconfig.saas.vite.json b/frontend/editor/tsconfig.saas.vite.json
index c0cced8c0..0ab501bf9 100644
--- a/frontend/editor/tsconfig.saas.vite.json
+++ b/frontend/editor/tsconfig.saas.vite.json
@@ -4,7 +4,8 @@
"paths": {
"@app/*": ["src/saas/*", "src/proprietary/*", "src/core/*"],
"@proprietary/*": ["src/proprietary/*"],
- "@core/*": ["src/core/*"]
+ "@core/*": ["src/core/*"],
+ "@shared/*": ["../shared/*"]
}
},
"exclude": [
@@ -12,7 +13,6 @@
"src/core/**/*.spec.ts*",
"src/proprietary/**/*.test.ts*",
"src/proprietary/**/*.spec.ts*",
- "src/desktop",
- "node_modules"
+ "src/desktop"
]
}
diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs
index 8641e4120..372ad3685 100644
--- a/frontend/eslint.config.mjs
+++ b/frontend/eslint.config.mjs
@@ -5,19 +5,30 @@ import globals from "globals";
import { defineConfig } from "eslint/config";
import tseslint from "typescript-eslint";
-const srcGlobs = ["editor/src/**/*.{js,mjs,jsx,ts,tsx}"];
+const srcGlobs = [
+ "editor/src/**/*.{js,mjs,jsx,ts,tsx}",
+ "portal/src/**/*.{js,mjs,jsx,ts,tsx}",
+ "portal/main.tsx",
+ "shared/**/*.{js,mjs,jsx,ts,tsx}",
+];
const nodeGlobs = [
"scripts/**/*.{js,ts,mjs,mts}",
"editor/scripts/**/*.{js,ts,mjs,mts}",
"editor/*.config.{js,ts,mjs}",
+ "portal/*.config.{js,ts,mjs}",
"*.config.{js,ts,mjs}",
+ ".storybook/*.{js,ts,mjs,mts,tsx}",
];
const baseRestrictedImportPatterns = [
- { regex: "^\\.", message: "Use @app/* imports instead of relative imports." },
+ {
+ regex: "^\\.",
+ message:
+ "Use a workspace alias (@app/* for editor, @portal/* for portal, @shared/*) instead of relative imports.",
+ },
{
regex: "^src/",
- message: "Use @app/* imports instead of absolute src/ imports.",
+ message: "Use a workspace alias instead of absolute src/ imports.",
},
];
@@ -36,6 +47,7 @@ export default defineConfig(
"editor/src-tauri",
"editor/playwright-report",
"editor/test-results",
+ "portal/public",
],
},
eslint.configs.recommended,
@@ -92,6 +104,45 @@ export default defineConfig(
],
},
},
+ // The shared/ layer is the seed of a future packages/shared-ui — it must
+ // only depend on third-party packages and on itself. If it ever imports
+ // from editor or portal layers, extraction to a standalone package later
+ // becomes a rewrite instead of a `git mv`.
+ {
+ files: ["shared/**/*.{js,mjs,jsx,ts,tsx}"],
+ rules: {
+ "no-restricted-imports": [
+ "error",
+ {
+ patterns: [
+ ...baseRestrictedImportPatterns,
+ {
+ regex: "^@app/",
+ message:
+ "shared/ must not depend on the editor layer (@app/* resolves into editor/src/).",
+ },
+ {
+ regex: "^@portal/",
+ message:
+ "shared/ must not depend on the portal layer. Use @shared/* or third-party imports only.",
+ },
+ {
+ regex: "^@core/",
+ message: "shared/ must not depend on editor/src/core/.",
+ },
+ {
+ regex: "^@proprietary/",
+ message: "shared/ must not depend on editor/src/proprietary/.",
+ },
+ {
+ regex: "^@tauri-apps/",
+ message: "shared/ must remain web-compatible (no Tauri APIs).",
+ },
+ ],
+ },
+ ],
+ },
+ },
// Folders that have been cleaned up and are now conformant - stricter rules enforced here
{
files: [
@@ -99,6 +150,8 @@ export default defineConfig(
"editor/src/proprietary/**/*.{js,mjs,jsx,ts,tsx}",
"editor/src/saas/**/*.{js,mjs,jsx,ts,tsx}",
"editor/src/prototypes/**/*.{js,mjs,jsx,ts,tsx}",
+ "portal/src/**/*.{js,mjs,jsx,ts,tsx}",
+ "shared/**/*.{js,mjs,jsx,ts,tsx}",
],
languageOptions: {
parserOptions: {
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 1543a1496..5c67488af 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -90,6 +90,10 @@
"@iconify-json/material-symbols": "^1.2.53",
"@iconify/utils": "^3.1.0",
"@playwright/test": "^1.55.0",
+ "@storybook/addon-a11y": "^9.1.20",
+ "@storybook/addon-docs": "^9.1.20",
+ "@storybook/addon-themes": "^9.1.20",
+ "@storybook/react-vite": "^9.1.20",
"@tauri-apps/cli": "^2.9.6",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.8.0",
@@ -114,6 +118,8 @@
"jsdom": "^27.0.0",
"license-checker": "^25.0.1",
"madge": "^8.0.0",
+ "msw": "^2.14.6",
+ "msw-storybook-addon": "^2.0.7",
"postcss": "^8.5.12",
"postcss-cli": "^11.0.1",
"postcss-preset-mantine": "^1.18.0",
@@ -121,6 +127,7 @@
"prettier": "^3.8.1",
"puppeteer": "^24.25.0",
"rollup-plugin-visualizer": "^7.0.1",
+ "storybook": "^9.1.20",
"tsx": "^4.21.0",
"typescript": "^5.9.2",
"typescript-eslint": "^8.44.1",
@@ -244,6 +251,64 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.3",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz",
+ "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
+ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helpers": "^7.28.6",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/core/node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@babel/core/node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
"node_modules/@babel/generator": {
"version": "7.29.1",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
@@ -260,6 +325,43 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.28.6",
+ "@babel/helper-validator-option": "^7.27.1",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets/node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
"node_modules/@babel/helper-globals": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
@@ -282,6 +384,24 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/traverse": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
@@ -300,6 +420,30 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
+ "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/parser": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
@@ -1925,6 +2069,93 @@
"mlly": "^1.8.0"
}
},
+ "node_modules/@inquirer/ansi": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.5.tgz",
+ "integrity": "sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0"
+ }
+ },
+ "node_modules/@inquirer/confirm": {
+ "version": "6.0.13",
+ "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.0.13.tgz",
+ "integrity": "sha512-wkGPC7yJ5WJk1DJ5SX7fzk+gfj4BM8cf5dDDi71B/551xHrdsZVRJOC0WyikXd0pEsb/9cLniuE4atbsMqmFkw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/core": "^11.1.10",
+ "@inquirer/type": "^4.0.5"
+ },
+ "engines": {
+ "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/core": {
+ "version": "11.1.10",
+ "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.1.10.tgz",
+ "integrity": "sha512-a4Q5BXHQAHa9eO202sTaFCHFYVB3x5fauDuThEAdZ9gfn76pSxiKU7wWcEH0N1O0XmQvNfQNU6QXpiRxmYQx+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/ansi": "^2.0.5",
+ "@inquirer/figures": "^2.0.5",
+ "@inquirer/type": "^4.0.5",
+ "cli-width": "^4.1.0",
+ "fast-wrap-ansi": "^0.2.0",
+ "mute-stream": "^3.0.0",
+ "signal-exit": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/figures": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.5.tgz",
+ "integrity": "sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0"
+ }
+ },
+ "node_modules/@inquirer/type": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.5.tgz",
+ "integrity": "sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -1953,6 +2184,27 @@
"node": ">=8"
}
},
+ "node_modules/@joshwooding/vite-plugin-react-docgen-typescript": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/@joshwooding/vite-plugin-react-docgen-typescript/-/vite-plugin-react-docgen-typescript-0.6.1.tgz",
+ "integrity": "sha512-J4BaTocTOYFkMHIra1JDWrMWpNmBl4EkplIwHEsV8aeUOtdWjwSnln9U7twjMFTAEB7mptNtSKyVi1Y2W9sDJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "glob": "^10.0.0",
+ "magic-string": "^0.30.0",
+ "react-docgen-typescript": "^2.2.2"
+ },
+ "peerDependencies": {
+ "typescript": ">= 4.3.x",
+ "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -2091,6 +2343,24 @@
"@types/gapi.client.discovery-v1": "*"
}
},
+ "node_modules/@mdx-js/react": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz",
+ "integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdx": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ },
+ "peerDependencies": {
+ "@types/react": ">=16",
+ "react": ">=16"
+ }
+ },
"node_modules/@msgpack/msgpack": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-2.8.0.tgz",
@@ -2100,6 +2370,31 @@
"node": ">= 10"
}
},
+ "node_modules/@mswjs/interceptors": {
+ "version": "0.41.9",
+ "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.9.tgz",
+ "integrity": "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@open-draft/deferred-promise": "^2.2.0",
+ "@open-draft/logger": "^0.3.0",
+ "@open-draft/until": "^2.0.0",
+ "is-node-process": "^1.2.0",
+ "outvariant": "^1.4.3",
+ "strict-event-emitter": "^0.5.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@mswjs/interceptors/node_modules/@open-draft/deferred-promise": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz",
+ "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@mui/core-downloads-tracker": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-9.0.0.tgz",
@@ -2583,6 +2878,31 @@
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
+ "node_modules/@open-draft/deferred-promise": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz",
+ "integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@open-draft/logger": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz",
+ "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-node-process": "^1.2.0",
+ "outvariant": "^1.4.0"
+ }
+ },
+ "node_modules/@open-draft/until": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz",
+ "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@opentelemetry/api": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
@@ -3094,6 +3414,49 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@rollup/pluginutils": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
+ "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0",
+ "estree-walker": "^2.0.2",
+ "picomatch": "^4.0.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/pluginutils/node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/pluginutils/node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz",
@@ -3483,6 +3846,286 @@
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
+ "node_modules/@storybook/addon-a11y": {
+ "version": "9.1.20",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-9.1.20.tgz",
+ "integrity": "sha512-VFZ34y4ApmFwIzPRs2OJrG6jtYhM5y91eCZLTlR/HMGQciKF4TdOJHjj+5vf91SOER5UDcLizXetpiUowiZSgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@storybook/global": "^5.0.0",
+ "axe-core": "^4.2.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "storybook": "^9.1.20"
+ }
+ },
+ "node_modules/@storybook/addon-docs": {
+ "version": "9.1.20",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-9.1.20.tgz",
+ "integrity": "sha512-eUIOd4u/p9994Nkv8Avn6r/xmS7D+RNmhmu6KGROefN3myLe3JfhSdimal2wDFe/h/OUNZ/LVVKMZrya9oEfKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@mdx-js/react": "^3.0.0",
+ "@storybook/csf-plugin": "9.1.20",
+ "@storybook/icons": "^1.4.0",
+ "@storybook/react-dom-shim": "9.1.20",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "ts-dedent": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "storybook": "^9.1.20"
+ }
+ },
+ "node_modules/@storybook/addon-themes": {
+ "version": "9.1.20",
+ "resolved": "https://registry.npmjs.org/@storybook/addon-themes/-/addon-themes-9.1.20.tgz",
+ "integrity": "sha512-GY9WKJMxbsI24CTj1PToWbjZGuXnwLasahv51wpIQC94Sn/8NxRta8K2BO2Pqb7U3sdtjH6htUasnQnaL0/ZBw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ts-dedent": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "storybook": "^9.1.20"
+ }
+ },
+ "node_modules/@storybook/builder-vite": {
+ "version": "9.1.20",
+ "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-9.1.20.tgz",
+ "integrity": "sha512-cdU3Q2/wEaT8h+mApFToRiF/0hYKH1eAkD0scQn67aODgp7xnkr0YHcdA+8w0Uxd2V7U8crV/cmT/HD0ELVOGw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@storybook/csf-plugin": "9.1.20",
+ "ts-dedent": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "storybook": "^9.1.20",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/@storybook/csf-plugin": {
+ "version": "9.1.20",
+ "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-9.1.20.tgz",
+ "integrity": "sha512-HHgk50YQhML7mT01Mzf9N7lNMFHWN4HwwRP90kPT9Ct+Jhx7h3LBDbdmWjI96HwujcpY7eoYdTfpB1Sw8Z7nBQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "unplugin": "^1.3.1"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "storybook": "^9.1.20"
+ }
+ },
+ "node_modules/@storybook/global": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/@storybook/global/-/global-5.0.0.tgz",
+ "integrity": "sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@storybook/icons": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@storybook/icons/-/icons-1.6.0.tgz",
+ "integrity": "sha512-hcFZIjW8yQz8O8//2WTIXylm5Xsgc+lW9ISLgUk1xGmptIJQRdlhVIXCpSyLrQaaRiyhQRaVg7l3BD9S216BHw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta"
+ }
+ },
+ "node_modules/@storybook/react": {
+ "version": "9.1.20",
+ "resolved": "https://registry.npmjs.org/@storybook/react/-/react-9.1.20.tgz",
+ "integrity": "sha512-TJhqzggs7HCvLhTXKfx8HodnVq9YizsB2J31s9v6olU0UCxbCY+FYaCF+XdE8qUCyefGRZgHKzGBIczJ/q9e2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@storybook/global": "^5.0.0",
+ "@storybook/react-dom-shim": "9.1.20"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta",
+ "storybook": "^9.1.20",
+ "typescript": ">= 4.9.x"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@storybook/react-dom-shim": {
+ "version": "9.1.20",
+ "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-9.1.20.tgz",
+ "integrity": "sha512-UYdZavfPwHEqCKMqPssUOlyFVZiJExLxnSHwkICSZBmw3gxXJcp1aXWs7PvoZdWz2K4ztl3IcKErXXHeiY6w+A==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta",
+ "storybook": "^9.1.20"
+ }
+ },
+ "node_modules/@storybook/react-vite": {
+ "version": "9.1.20",
+ "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-9.1.20.tgz",
+ "integrity": "sha512-buXeNvEJ9kp4FKbGYV7zW4sh/KS01EAjeq8Z6AVxaXOh4W2CIRTKM9maWGz+Rr+YyqQIq/Gl+RqNwxctpxeuHA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@joshwooding/vite-plugin-react-docgen-typescript": "0.6.1",
+ "@rollup/pluginutils": "^5.0.2",
+ "@storybook/builder-vite": "9.1.20",
+ "@storybook/react": "9.1.20",
+ "find-up": "^7.0.0",
+ "magic-string": "^0.30.0",
+ "react-docgen": "^8.0.0",
+ "resolve": "^1.22.8",
+ "tsconfig-paths": "^4.2.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta",
+ "storybook": "^9.1.20",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/@storybook/react-vite/node_modules/find-up": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-7.0.0.tgz",
+ "integrity": "sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^7.2.0",
+ "path-exists": "^5.0.0",
+ "unicorn-magic": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@storybook/react-vite/node_modules/locate-path": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz",
+ "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^6.0.0"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@storybook/react-vite/node_modules/p-limit": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz",
+ "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^1.0.0"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@storybook/react-vite/node_modules/p-locate": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz",
+ "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^4.0.0"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@storybook/react-vite/node_modules/path-exists": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz",
+ "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ }
+ },
+ "node_modules/@storybook/react-vite/node_modules/yocto-queue": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
+ "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/@stripe/react-stripe-js": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-4.0.2.tgz",
@@ -4670,6 +5313,51 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@@ -4972,6 +5660,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/doctrine": {
+ "version": "0.0.9",
+ "resolved": "https://registry.npmjs.org/@types/doctrine/-/doctrine-0.0.9.tgz",
+ "integrity": "sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/esrecurse": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
@@ -5080,6 +5775,13 @@
"@types/unist": "*"
}
},
+ "node_modules/@types/mdx": {
+ "version": "2.0.13",
+ "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz",
+ "integrity": "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
@@ -5135,6 +5837,30 @@
"@types/react": "*"
}
},
+ "node_modules/@types/resolve": {
+ "version": "1.20.6",
+ "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.6.tgz",
+ "integrity": "sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/set-cookie-parser": {
+ "version": "2.4.10",
+ "resolved": "https://registry.npmjs.org/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz",
+ "integrity": "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/statuses": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz",
+ "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -5959,6 +6685,16 @@
"postcss": "^8.1.0"
}
},
+ "node_modules/axe-core": {
+ "version": "4.11.4",
+ "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz",
+ "integrity": "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/axios": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
@@ -6175,6 +6911,76 @@
"node": ">=10.0.0"
}
},
+ "node_modules/better-opn": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/better-opn/-/better-opn-3.0.2.tgz",
+ "integrity": "sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "open": "^8.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/better-opn/node_modules/define-lazy-prop": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz",
+ "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/better-opn/node_modules/is-docker": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
+ "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "is-docker": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/better-opn/node_modules/is-wsl": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
+ "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-docker": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/better-opn/node_modules/open": {
+ "version": "8.4.2",
+ "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz",
+ "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-lazy-prop": "^2.0.0",
+ "is-docker": "^2.1.1",
+ "is-wsl": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
@@ -6629,6 +7435,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/cli-width": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz",
+ "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@@ -7814,6 +8630,19 @@
"wrappy": "1"
}
},
+ "node_modules/doctrine": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
+ "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "esutils": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
@@ -8074,6 +8903,19 @@
"@esbuild/win32-x64": "0.27.4"
}
},
+ "node_modules/esbuild-register": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz",
+ "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.3.4"
+ },
+ "peerDependencies": {
+ "esbuild": ">=0.12 <1"
+ }
+ },
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -8482,6 +9324,33 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/fast-string-truncated-width": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz",
+ "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-string-width": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz",
+ "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-string-truncated-width": "^3.0.2"
+ }
+ },
+ "node_modules/fast-wrap-ansi": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.0.tgz",
+ "integrity": "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-string-width": "^3.0.2"
+ }
+ },
"node_modules/fd-slicer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
@@ -8749,6 +9618,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/get-amd-module-type": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/get-amd-module-type/-/get-amd-module-type-6.0.1.tgz",
@@ -9038,6 +9917,16 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
+ "node_modules/graphql": {
+ "version": "16.14.0",
+ "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.0.tgz",
+ "integrity": "sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
+ }
+ },
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -9127,6 +10016,24 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/headers-polyfill": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-5.0.1.tgz",
+ "integrity": "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/set-cookie-parser": "^2.4.10",
+ "set-cookie-parser": "^3.0.1"
+ }
+ },
+ "node_modules/headers-polyfill/node_modules/set-cookie-parser": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz",
+ "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
@@ -9616,6 +10523,13 @@
"node": ">=8"
}
},
+ "node_modules/is-node-process": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz",
+ "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@@ -11635,6 +12549,90 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
+ "node_modules/msw": {
+ "version": "2.14.6",
+ "resolved": "https://registry.npmjs.org/msw/-/msw-2.14.6.tgz",
+ "integrity": "sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/confirm": "^6.0.11",
+ "@mswjs/interceptors": "^0.41.3",
+ "@open-draft/deferred-promise": "^3.0.0",
+ "@types/statuses": "^2.0.6",
+ "cookie": "^1.1.1",
+ "graphql": "^16.13.2",
+ "headers-polyfill": "^5.0.1",
+ "is-node-process": "^1.2.0",
+ "outvariant": "^1.4.3",
+ "path-to-regexp": "^6.3.0",
+ "picocolors": "^1.1.1",
+ "rettime": "^0.11.11",
+ "statuses": "^2.0.2",
+ "strict-event-emitter": "^0.5.1",
+ "tough-cookie": "^6.0.1",
+ "type-fest": "^5.5.0",
+ "until-async": "^3.0.2",
+ "yargs": "^17.7.2"
+ },
+ "bin": {
+ "msw": "cli/index.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/mswjs"
+ },
+ "peerDependencies": {
+ "typescript": ">= 4.8.x"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/msw-storybook-addon": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/msw-storybook-addon/-/msw-storybook-addon-2.0.7.tgz",
+ "integrity": "sha512-TGmlxXy2TsaB6QcClVKRxqvay5f93xoLguHOihRFQ+gIEIyiyvcoQjkEeuOe7Y9qvddzGB1LyFomzPo9/EpnuQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-node-process": "^1.0.1"
+ },
+ "peerDependencies": {
+ "msw": "^2.0.0"
+ }
+ },
+ "node_modules/msw/node_modules/type-fest": {
+ "version": "5.6.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz",
+ "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "dependencies": {
+ "tagged-tag": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/mute-stream": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz",
+ "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -11920,6 +12918,13 @@
"os-tmpdir": "^1.0.0"
}
},
+ "node_modules/outvariant": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz",
+ "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/p-cancelable": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-4.0.1.tgz",
@@ -12165,6 +13170,13 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/path-to-regexp": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
+ "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/path-type": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
@@ -13065,6 +14077,51 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-docgen": {
+ "version": "8.0.3",
+ "resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-8.0.3.tgz",
+ "integrity": "sha512-aEZ9qP+/M+58x2qgfSFEWH1BxLyHe5+qkLNJOZQb5iGS017jpbRnoKhNRrXPeA6RfBrZO5wZrT9DMC1UqE1f1w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.28.0",
+ "@babel/traverse": "^7.28.0",
+ "@babel/types": "^7.28.2",
+ "@types/babel__core": "^7.20.5",
+ "@types/babel__traverse": "^7.20.7",
+ "@types/doctrine": "^0.0.9",
+ "@types/resolve": "^1.20.2",
+ "doctrine": "^3.0.0",
+ "resolve": "^1.22.1",
+ "strip-indent": "^4.0.0"
+ },
+ "engines": {
+ "node": "^20.9.0 || >=22"
+ }
+ },
+ "node_modules/react-docgen-typescript": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/react-docgen-typescript/-/react-docgen-typescript-2.4.0.tgz",
+ "integrity": "sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "typescript": ">= 4.3.x"
+ }
+ },
+ "node_modules/react-docgen/node_modules/strip-indent": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.1.1.tgz",
+ "integrity": "sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/react-dom": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
@@ -13523,6 +14580,46 @@
"node": ">=8.10.0"
}
},
+ "node_modules/recast": {
+ "version": "0.23.11",
+ "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz",
+ "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ast-types": "^0.16.1",
+ "esprima": "~4.0.0",
+ "source-map": "~0.6.1",
+ "tiny-invariant": "^1.3.3",
+ "tslib": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/recast/node_modules/ast-types": {
+ "version": "0.16.1",
+ "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz",
+ "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/recast/node_modules/source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/recharts": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz",
@@ -13805,6 +14902,13 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/rettime": {
+ "version": "0.11.11",
+ "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.11.tgz",
+ "integrity": "sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/robust-predicates": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
@@ -14356,6 +15460,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
@@ -14363,6 +15477,526 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/storybook": {
+ "version": "9.1.20",
+ "resolved": "https://registry.npmjs.org/storybook/-/storybook-9.1.20.tgz",
+ "integrity": "sha512-6rME2tww6PFhm96iG2Xx44yzwLDWBiDWy+kJ2ub6x90werSTOiuo+tZJ94BgCfFutR0tEfLRIq59s+Zg6YyChA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@storybook/global": "^5.0.0",
+ "@testing-library/jest-dom": "^6.6.3",
+ "@testing-library/user-event": "^14.6.1",
+ "@vitest/expect": "3.2.4",
+ "@vitest/mocker": "3.2.4",
+ "@vitest/spy": "3.2.4",
+ "better-opn": "^3.0.2",
+ "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0",
+ "esbuild-register": "^3.5.0",
+ "recast": "^0.23.5",
+ "semver": "^7.6.2",
+ "ws": "^8.18.0"
+ },
+ "bin": {
+ "storybook": "bin/index.cjs"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/storybook"
+ },
+ "peerDependencies": {
+ "prettier": "^2 || ^3"
+ },
+ "peerDependenciesMeta": {
+ "prettier": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/storybook/node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/storybook/node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/storybook/node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/storybook/node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/storybook/node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/storybook/node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/storybook/node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/storybook/node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/storybook/node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/storybook/node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/storybook/node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/storybook/node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/storybook/node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/storybook/node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/storybook/node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/storybook/node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/storybook/node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/storybook/node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/storybook/node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/storybook/node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/storybook/node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/storybook/node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/storybook/node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/storybook/node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/storybook/node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/storybook/node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/storybook/node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
"node_modules/stream-to-array": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/stream-to-array/-/stream-to-array-2.3.0.tgz",
@@ -14385,6 +16019,13 @@
"text-decoder": "^1.1.0"
}
},
+ "node_modules/strict-event-emitter": {
+ "version": "0.5.1",
+ "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz",
+ "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
@@ -14815,6 +16456,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/tagged-tag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
+ "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/tailwindcss": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
@@ -15113,6 +16767,16 @@
"typescript": ">=4.8.4"
}
},
+ "node_modules/ts-dedent": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz",
+ "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.10"
+ }
+ },
"node_modules/ts-graphviz": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/ts-graphviz/-/ts-graphviz-2.1.6.tgz",
@@ -15299,6 +16963,19 @@
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"license": "MIT"
},
+ "node_modules/unicorn-magic": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz",
+ "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/unified": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
@@ -15396,6 +17073,30 @@
"node": ">= 10.0.0"
}
},
+ "node_modules/unplugin": {
+ "version": "1.16.1",
+ "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-1.16.1.tgz",
+ "integrity": "sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "acorn": "^8.14.0",
+ "webpack-virtual-modules": "^0.6.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/until-async": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz",
+ "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/kettanaito"
+ }
+ },
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -15980,6 +17681,13 @@
"node": ">=20"
}
},
+ "node_modules/webpack-virtual-modules": {
+ "version": "0.6.2",
+ "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
+ "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/webrtc-adapter": {
"version": "9.0.4",
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.4.tgz",
@@ -16225,6 +17933,13 @@
"node": ">=10"
}
},
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/yaml": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 4c9dfe8d5..68695be91 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -104,6 +104,10 @@
"@iconify-json/material-symbols": "^1.2.53",
"@iconify/utils": "^3.1.0",
"@playwright/test": "^1.55.0",
+ "@storybook/addon-a11y": "^9.1.20",
+ "@storybook/addon-docs": "^9.1.20",
+ "@storybook/addon-themes": "^9.1.20",
+ "@storybook/react-vite": "^9.1.20",
"@tauri-apps/cli": "^2.9.6",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.8.0",
@@ -128,6 +132,8 @@
"jsdom": "^27.0.0",
"license-checker": "^25.0.1",
"madge": "^8.0.0",
+ "msw": "^2.14.6",
+ "msw-storybook-addon": "^2.0.7",
"postcss": "^8.5.12",
"postcss-cli": "^11.0.1",
"postcss-preset-mantine": "^1.18.0",
@@ -135,6 +141,7 @@
"prettier": "^3.8.1",
"puppeteer": "^24.25.0",
"rollup-plugin-visualizer": "^7.0.1",
+ "storybook": "^9.1.20",
"tsx": "^4.21.0",
"typescript": "^5.9.2",
"typescript-eslint": "^8.44.1",
@@ -153,5 +160,10 @@
},
"overrides": {
"devalue": "^5.8.1"
+ },
+ "msw": {
+ "workerDirectory": [
+ "portal/public"
+ ]
}
}
diff --git a/frontend/portal/LICENSE b/frontend/portal/LICENSE
new file mode 100644
index 000000000..d26855680
--- /dev/null
+++ b/frontend/portal/LICENSE
@@ -0,0 +1,51 @@
+Stirling PDF User License
+
+Copyright (c) 2025 Stirling PDF Inc.
+
+License Scope & Usage Rights
+
+Production use of the Stirling PDF Software is only permitted with a valid Stirling PDF User License.
+
+For purposes of this license, “the Software” refers to the Stirling PDF application and any associated documentation files
+provided by Stirling PDF Inc. You or your organization may not use the Software in production, at scale, or for business-critical
+processes unless you have agreed to, and remain in compliance with, the Stirling PDF Subscription Terms of Service
+(https://www.stirlingpdf.com/terms) or another valid agreement with Stirling PDF, and hold an active User License subscription
+covering the appropriate number of licensed users.
+
+Trial and Minimal Use
+
+You may use the Software without a paid subscription for the sole purposes of internal trial, evaluation, or minimal use, provided that:
+* Use is limited to the capabilities and restrictions defined by the Software itself;
+* You do not copy, distribute, sublicense, reverse-engineer, or use the Software in client-facing or commercial contexts.
+
+Continued use beyond this scope requires a valid Stirling PDF User License.
+
+Modifications and Derivative Works
+
+You may modify the Software only for development or internal testing purposes. Any such modifications or derivative works:
+
+* May not be deployed in production environments without a valid User License;
+* May not be distributed or sublicensed;
+* Remain the intellectual property of Stirling PDF and/or its licensors;
+* May only be used, copied, or exploited in accordance with the terms of a valid Stirling PDF User License subscription.
+
+Prohibited Actions
+
+Unless explicitly permitted by a paid license or separate agreement, you may not:
+
+* Use the Software in production environments;
+* Copy, merge, distribute, sublicense, or sell the Software;
+* Remove or alter any licensing or copyright notices;
+* Circumvent access restrictions or licensing requirements.
+
+Third-Party Components
+
+The Stirling PDF Software may include components subject to separate open source licenses. Such components remain governed by
+their original license terms as provided by their respective owners.
+
+Disclaimer
+
+THE SOFTWARE IS PROVIDED “AS IS,” WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/frontend/portal/index.html b/frontend/portal/index.html
new file mode 100644
index 000000000..69328bad8
--- /dev/null
+++ b/frontend/portal/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+ Stirling — Developer Portal
+
+
+ You need to enable JavaScript to run this app.
+
+
+
+
diff --git a/frontend/portal/main.tsx b/frontend/portal/main.tsx
new file mode 100644
index 000000000..76e802312
--- /dev/null
+++ b/frontend/portal/main.tsx
@@ -0,0 +1,34 @@
+///
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { App } from "@portal/App";
+import { readMocksPreference } from "@portal/mocks/preference";
+
+// Mantine's prebuilt styles load first so SUI tokens/base can override on
+// conflicts — SUI is the primary design language, Mantine the escape hatch.
+import "@mantine/core/styles.css";
+import "@shared/tokens/tokens.css";
+import "@shared/tokens/base.css";
+
+const root = document.getElementById("root");
+if (!root) throw new Error("No #root element");
+
+// Start MSW before React mounts when the user has mocks enabled. The
+// preference defaults to ON in dev / OFF in production. The toggle in the
+// header flips it at runtime.
+async function bootstrap(): Promise {
+ if (readMocksPreference()) {
+ // Dynamic import keeps MSW + every handler + every fixture out of any
+ // chunk that doesn't need to actually run the worker.
+ const { startMockWorker } = await import("@portal/mocks/browser");
+ await startMockWorker();
+ }
+
+ createRoot(root!).render(
+
+
+ ,
+ );
+}
+
+void bootstrap();
diff --git a/frontend/portal/public/mockServiceWorker.js b/frontend/portal/public/mockServiceWorker.js
new file mode 100644
index 000000000..71a94b1b4
--- /dev/null
+++ b/frontend/portal/public/mockServiceWorker.js
@@ -0,0 +1,349 @@
+/* eslint-disable */
+/* tslint:disable */
+
+/**
+ * Mock Service Worker.
+ * @see https://github.com/mswjs/msw
+ * - Please do NOT modify this file.
+ */
+
+const PACKAGE_VERSION = "2.14.6";
+const INTEGRITY_CHECKSUM = "4db4a41e972cec1b64cc569c66952d82";
+const IS_MOCKED_RESPONSE = Symbol("isMockedResponse");
+const activeClientIds = new Set();
+
+addEventListener("install", function () {
+ self.skipWaiting();
+});
+
+addEventListener("activate", function (event) {
+ event.waitUntil(self.clients.claim());
+});
+
+addEventListener("message", async function (event) {
+ const clientId = Reflect.get(event.source || {}, "id");
+
+ if (!clientId || !self.clients) {
+ return;
+ }
+
+ const client = await self.clients.get(clientId);
+
+ if (!client) {
+ return;
+ }
+
+ const allClients = await self.clients.matchAll({
+ type: "window",
+ });
+
+ switch (event.data) {
+ case "KEEPALIVE_REQUEST": {
+ sendToClient(client, {
+ type: "KEEPALIVE_RESPONSE",
+ });
+ break;
+ }
+
+ case "INTEGRITY_CHECK_REQUEST": {
+ sendToClient(client, {
+ type: "INTEGRITY_CHECK_RESPONSE",
+ payload: {
+ packageVersion: PACKAGE_VERSION,
+ checksum: INTEGRITY_CHECKSUM,
+ },
+ });
+ break;
+ }
+
+ case "MOCK_ACTIVATE": {
+ activeClientIds.add(clientId);
+
+ sendToClient(client, {
+ type: "MOCKING_ENABLED",
+ payload: {
+ client: {
+ id: client.id,
+ frameType: client.frameType,
+ },
+ },
+ });
+ break;
+ }
+
+ case "CLIENT_CLOSED": {
+ activeClientIds.delete(clientId);
+
+ const remainingClients = allClients.filter((client) => {
+ return client.id !== clientId;
+ });
+
+ // Unregister itself when there are no more clients
+ if (remainingClients.length === 0) {
+ self.registration.unregister();
+ }
+
+ break;
+ }
+ }
+});
+
+addEventListener("fetch", function (event) {
+ const requestInterceptedAt = Date.now();
+
+ // Bypass navigation requests.
+ if (event.request.mode === "navigate") {
+ return;
+ }
+
+ // Opening the DevTools triggers the "only-if-cached" request
+ // that cannot be handled by the worker. Bypass such requests.
+ if (
+ event.request.cache === "only-if-cached" &&
+ event.request.mode !== "same-origin"
+ ) {
+ return;
+ }
+
+ // Bypass all requests when there are no active clients.
+ // Prevents the self-unregistered worked from handling requests
+ // after it's been terminated (still remains active until the next reload).
+ if (activeClientIds.size === 0) {
+ return;
+ }
+
+ const requestId = crypto.randomUUID();
+ event.respondWith(handleRequest(event, requestId, requestInterceptedAt));
+});
+
+/**
+ * @param {FetchEvent} event
+ * @param {string} requestId
+ * @param {number} requestInterceptedAt
+ */
+async function handleRequest(event, requestId, requestInterceptedAt) {
+ const client = await resolveMainClient(event);
+ const requestCloneForEvents = event.request.clone();
+ const response = await getResponse(
+ event,
+ client,
+ requestId,
+ requestInterceptedAt,
+ );
+
+ // Send back the response clone for the "response:*" life-cycle events.
+ // Ensure MSW is active and ready to handle the message, otherwise
+ // this message will pend indefinitely.
+ if (client && activeClientIds.has(client.id)) {
+ const serializedRequest = await serializeRequest(requestCloneForEvents);
+
+ // Clone the response so both the client and the library could consume it.
+ const responseClone = response.clone();
+
+ sendToClient(
+ client,
+ {
+ type: "RESPONSE",
+ payload: {
+ isMockedResponse: IS_MOCKED_RESPONSE in response,
+ request: {
+ id: requestId,
+ ...serializedRequest,
+ },
+ response: {
+ type: responseClone.type,
+ status: responseClone.status,
+ statusText: responseClone.statusText,
+ headers: Object.fromEntries(responseClone.headers.entries()),
+ body: responseClone.body,
+ },
+ },
+ },
+ responseClone.body ? [serializedRequest.body, responseClone.body] : [],
+ );
+ }
+
+ return response;
+}
+
+/**
+ * Resolve the main client for the given event.
+ * Client that issues a request doesn't necessarily equal the client
+ * that registered the worker. It's with the latter the worker should
+ * communicate with during the response resolving phase.
+ * @param {FetchEvent} event
+ * @returns {Promise}
+ */
+async function resolveMainClient(event) {
+ const client = await self.clients.get(event.clientId);
+
+ if (activeClientIds.has(event.clientId)) {
+ return client;
+ }
+
+ if (client?.frameType === "top-level") {
+ return client;
+ }
+
+ const allClients = await self.clients.matchAll({
+ type: "window",
+ });
+
+ return allClients
+ .filter((client) => {
+ // Get only those clients that are currently visible.
+ return client.visibilityState === "visible";
+ })
+ .find((client) => {
+ // Find the client ID that's recorded in the
+ // set of clients that have registered the worker.
+ return activeClientIds.has(client.id);
+ });
+}
+
+/**
+ * @param {FetchEvent} event
+ * @param {Client | undefined} client
+ * @param {string} requestId
+ * @param {number} requestInterceptedAt
+ * @returns {Promise}
+ */
+async function getResponse(event, client, requestId, requestInterceptedAt) {
+ // Clone the request because it might've been already used
+ // (i.e. its body has been read and sent to the client).
+ const requestClone = event.request.clone();
+
+ function passthrough() {
+ // Cast the request headers to a new Headers instance
+ // so the headers can be manipulated with.
+ const headers = new Headers(requestClone.headers);
+
+ // Remove the "accept" header value that marked this request as passthrough.
+ // This prevents request alteration and also keeps it compliant with the
+ // user-defined CORS policies.
+ const acceptHeader = headers.get("accept");
+ if (acceptHeader) {
+ const values = acceptHeader.split(",").map((value) => value.trim());
+ const filteredValues = values.filter(
+ (value) => value !== "msw/passthrough",
+ );
+
+ if (filteredValues.length > 0) {
+ headers.set("accept", filteredValues.join(", "));
+ } else {
+ headers.delete("accept");
+ }
+ }
+
+ return fetch(requestClone, { headers });
+ }
+
+ // Bypass mocking when the client is not active.
+ if (!client) {
+ return passthrough();
+ }
+
+ // Bypass initial page load requests (i.e. static assets).
+ // The absence of the immediate/parent client in the map of the active clients
+ // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
+ // and is not ready to handle requests.
+ if (!activeClientIds.has(client.id)) {
+ return passthrough();
+ }
+
+ // Notify the client that a request has been intercepted.
+ const serializedRequest = await serializeRequest(event.request);
+ const clientMessage = await sendToClient(
+ client,
+ {
+ type: "REQUEST",
+ payload: {
+ id: requestId,
+ interceptedAt: requestInterceptedAt,
+ ...serializedRequest,
+ },
+ },
+ [serializedRequest.body],
+ );
+
+ switch (clientMessage.type) {
+ case "MOCK_RESPONSE": {
+ return respondWithMock(clientMessage.data);
+ }
+
+ case "PASSTHROUGH": {
+ return passthrough();
+ }
+ }
+
+ return passthrough();
+}
+
+/**
+ * @param {Client} client
+ * @param {any} message
+ * @param {Array} transferrables
+ * @returns {Promise}
+ */
+function sendToClient(client, message, transferrables = []) {
+ return new Promise((resolve, reject) => {
+ const channel = new MessageChannel();
+
+ channel.port1.onmessage = (event) => {
+ if (event.data && event.data.error) {
+ return reject(event.data.error);
+ }
+
+ resolve(event.data);
+ };
+
+ client.postMessage(message, [
+ channel.port2,
+ ...transferrables.filter(Boolean),
+ ]);
+ });
+}
+
+/**
+ * @param {Response} response
+ * @returns {Response}
+ */
+function respondWithMock(response) {
+ // Setting response status code to 0 is a no-op.
+ // However, when responding with a "Response.error()", the produced Response
+ // instance will have status code set to 0. Since it's not possible to create
+ // a Response instance with status code 0, handle that use-case separately.
+ if (response.status === 0) {
+ return Response.error();
+ }
+
+ const mockedResponse = new Response(response.body, response);
+
+ Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
+ value: true,
+ enumerable: true,
+ });
+
+ return mockedResponse;
+}
+
+/**
+ * @param {Request} request
+ */
+async function serializeRequest(request) {
+ return {
+ url: request.url,
+ mode: request.mode,
+ method: request.method,
+ headers: Object.fromEntries(request.headers.entries()),
+ cache: request.cache,
+ credentials: request.credentials,
+ destination: request.destination,
+ integrity: request.integrity,
+ redirect: request.redirect,
+ referrer: request.referrer,
+ referrerPolicy: request.referrerPolicy,
+ body: await request.arrayBuffer(),
+ keepalive: request.keepalive,
+ };
+}
diff --git a/frontend/portal/src/App.tsx b/frontend/portal/src/App.tsx
new file mode 100644
index 000000000..f51868e64
--- /dev/null
+++ b/frontend/portal/src/App.tsx
@@ -0,0 +1,75 @@
+import { useEffect, type ReactNode } from "react";
+import { BrowserRouter } from "react-router-dom";
+import { MantineProvider } from "@mantine/core";
+import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext";
+import { TierProvider } from "@portal/contexts/TierContext";
+import { UIProvider, useUI } from "@portal/contexts/UIContext";
+import { mantineTheme } from "@portal/theme/mantineTheme";
+import { AppShell } from "@portal/components/AppShell";
+import { AssistantButton } from "@portal/components/AssistantButton";
+import { AssistantPanel } from "@portal/components/AssistantPanel";
+import { SearchModal } from "@portal/components/SearchModal";
+import { ViewRouter } from "@portal/ViewRouter";
+
+/**
+ * Binds Mantine's colour scheme to the portal's own ThemeProvider so Mantine
+ * components follow the same light/dark switch as the SUI primitives. Must sit
+ * inside to read useTheme().
+ */
+function PortalMantineProvider({ children }: { children: ReactNode }) {
+ const { theme } = useTheme();
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * Global keyboard shortcuts. Lives below the UIProvider so it can dispatch
+ * into the overlay state. Currently just ⌘K / Ctrl+K to toggle the search
+ * palette.
+ */
+function GlobalShortcuts() {
+ const { toggleSearch, closeSearch } = useUI();
+
+ useEffect(() => {
+ function onKey(e: KeyboardEvent) {
+ const isCmdK = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k";
+ if (isCmdK) {
+ e.preventDefault();
+ toggleSearch();
+ return;
+ }
+ if (e.key === "Escape") {
+ closeSearch();
+ }
+ }
+ document.addEventListener("keydown", onKey);
+ return () => document.removeEventListener("keydown", onKey);
+ }, [toggleSearch, closeSearch]);
+
+ return null;
+}
+
+export function App() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/portal/src/ViewRouter.tsx b/frontend/portal/src/ViewRouter.tsx
new file mode 100644
index 000000000..f8e0005e5
--- /dev/null
+++ b/frontend/portal/src/ViewRouter.tsx
@@ -0,0 +1,67 @@
+import { Navigate, Route, Routes } from "react-router-dom";
+import { Home } from "@portal/views/Home";
+import { Documents } from "@portal/views/Documents";
+import { Placeholder } from "@portal/views/Placeholder";
+import { VIEW_PATHS, type ViewId } from "@portal/contexts/ViewContext";
+
+const PLACEHOLDER_PHASES: Partial> = {
+ editor: "Phase 8 — Editor",
+ sources: "Phase 5 — Sources & Agents",
+ pipelines: "Phase 4 — Pipelines",
+ infrastructure: "Phase 7 — Infrastructure",
+ usage: "Phase 8 — Usage & Billing",
+ docs: "Phase 8 — Developer Docs",
+ settings: "Settings — modal overlay",
+};
+
+export function ViewRouter() {
+ return (
+
+ } />
+
+ }
+ />
+
+ }
+ />
+
+ }
+ />
+ } />
+
+ }
+ />
+ }
+ />
+ }
+ />
+
+ }
+ />
+ {/* Unknown paths land on Home. */}
+ } />
+
+ );
+}
diff --git a/frontend/portal/src/api/assistant.ts b/frontend/portal/src/api/assistant.ts
new file mode 100644
index 000000000..a6f10597f
--- /dev/null
+++ b/frontend/portal/src/api/assistant.ts
@@ -0,0 +1,15 @@
+import { httpJson } from "@portal/api/http";
+
+/** GET /v1/assistant/suggestions */
+export async function fetchAssistantSuggestions(): Promise {
+ return httpJson("/v1/assistant/suggestions");
+}
+
+/** POST /v1/assistant/messages */
+export async function getAssistantReply(input: string): Promise {
+ const res = await httpJson<{ reply: string }>("/v1/assistant/messages", {
+ method: "POST",
+ body: { input },
+ });
+ return res.reply;
+}
diff --git a/frontend/portal/src/api/endpoints.ts b/frontend/portal/src/api/endpoints.ts
new file mode 100644
index 000000000..95bab777b
--- /dev/null
+++ b/frontend/portal/src/api/endpoints.ts
@@ -0,0 +1,15 @@
+import { httpJson } from "@portal/api/http";
+import type { Vertical } from "@shared/data/endpoints";
+
+export type {
+ Endpoint,
+ EndpointSchema,
+ EndpointTierGate,
+ Vertical,
+ VerticalKey,
+} from "@shared/data/endpoints";
+
+/** GET /v1/endpoints — verticals plus their endpoints. */
+export async function fetchVerticals(): Promise {
+ return httpJson("/v1/endpoints");
+}
diff --git a/frontend/portal/src/api/home.ts b/frontend/portal/src/api/home.ts
new file mode 100644
index 000000000..b7b8fee64
--- /dev/null
+++ b/frontend/portal/src/api/home.ts
@@ -0,0 +1,44 @@
+import { httpJson } from "@portal/api/http";
+import type {
+ ActivityEvent,
+ KpiEntry,
+ OnboardingStep,
+ RegionHealth,
+ UsageSeriesResponse,
+} from "@portal/mocks/home";
+import type { Tier } from "@portal/contexts/TierContext";
+
+export type {
+ ActivityEvent,
+ ActivityKind,
+ KpiEntry,
+ OnboardingStep,
+ RegionHealth,
+ UsagePoint,
+ UsageSeriesResponse,
+} from "@portal/mocks/home";
+
+/** GET /v1/analytics/usage?window=30d */
+export async function fetchUsageSeries(): Promise {
+ return httpJson("/v1/analytics/usage?window=30d");
+}
+
+/** GET /v1/activity?limit=8 */
+export async function fetchRecentActivity(): Promise {
+ return httpJson("/v1/activity?limit=8");
+}
+
+/** GET /v1/home/kpis?tier=… */
+export async function fetchHomeKpis(tier: Tier): Promise {
+ return httpJson(`/v1/home/kpis?tier=${encodeURIComponent(tier)}`);
+}
+
+/** GET /v1/regions/health (Enterprise) */
+export async function fetchRegionHealth(): Promise {
+ return httpJson("/v1/regions/health");
+}
+
+/** GET /v1/onboarding (Free) */
+export async function fetchOnboarding(): Promise {
+ return httpJson("/v1/onboarding");
+}
diff --git a/frontend/portal/src/api/http.ts b/frontend/portal/src/api/http.ts
new file mode 100644
index 000000000..7b4f35170
--- /dev/null
+++ b/frontend/portal/src/api/http.ts
@@ -0,0 +1,59 @@
+/**
+ * Shared HTTP plumbing for the portal's service layer.
+ *
+ * Every `api/*.ts` module calls {@link httpJson}, which issues a real `fetch`.
+ * In dev and Storybook those requests are intercepted by the MSW handlers in
+ * `mocks/` and answered with fixture data; pointing at a real backend is just
+ * a matter of not registering MSW. Consumers don't change either way.
+ */
+
+export interface HttpRequestOptions {
+ method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
+ body?: unknown;
+ /** Extra headers; Content-Type and Accept are set automatically. */
+ headers?: Record;
+ signal?: AbortSignal;
+}
+
+export class HttpError extends Error {
+ constructor(
+ public readonly status: number,
+ public readonly statusText: string,
+ public readonly body: unknown,
+ ) {
+ super(`${status} ${statusText}`);
+ this.name = "HttpError";
+ }
+}
+
+/**
+ * Thin JSON fetch wrapper used by every api module. In dev/Storybook the
+ * request is served by MSW; against a real backend it hits the network.
+ */
+export async function httpJson(
+ path: string,
+ options: HttpRequestOptions = {},
+): Promise {
+ const res = await fetch(path, {
+ method: options.method ?? "GET",
+ headers: {
+ Accept: "application/json",
+ ...(options.body !== undefined
+ ? { "Content-Type": "application/json" }
+ : {}),
+ ...options.headers,
+ },
+ body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
+ signal: options.signal,
+ });
+ if (!res.ok) {
+ let body: unknown = null;
+ try {
+ body = await res.json();
+ } catch {
+ // ignore — non-JSON error response
+ }
+ throw new HttpError(res.status, res.statusText, body);
+ }
+ return (await res.json()) as T;
+}
diff --git a/frontend/portal/src/api/notifications.ts b/frontend/portal/src/api/notifications.ts
new file mode 100644
index 000000000..21b6e33f6
--- /dev/null
+++ b/frontend/portal/src/api/notifications.ts
@@ -0,0 +1,19 @@
+import { httpJson } from "@portal/api/http";
+import type {
+ Notification,
+ NotificationCategory,
+} from "@portal/mocks/notifications";
+
+export type { Notification, NotificationCategory };
+
+/** GET /v1/notifications */
+export async function fetchNotifications(): Promise {
+ return httpJson("/v1/notifications");
+}
+
+/** POST /v1/notifications/mark-all-read */
+export async function markAllNotificationsRead(): Promise {
+ await httpJson<{ ok: true }>("/v1/notifications/mark-all-read", {
+ method: "POST",
+ });
+}
diff --git a/frontend/portal/src/api/ops.ts b/frontend/portal/src/api/ops.ts
new file mode 100644
index 000000000..e741793bf
--- /dev/null
+++ b/frontend/portal/src/api/ops.ts
@@ -0,0 +1,37 @@
+import { HttpError, httpJson } from "@portal/api/http";
+import type { FeaturedOp, OpResultMap } from "@portal/mocks/ops";
+
+export type { FeaturedOp, OpResultMap };
+
+/** GET /v1/ops/featured */
+export async function fetchFeaturedOps(): Promise {
+ return httpJson("/v1/ops/featured");
+}
+
+export class UnknownOpError extends Error {
+ constructor(public readonly opId: string) {
+ super(`Unknown op: ${opId}`);
+ this.name = "UnknownOpError";
+ }
+}
+
+/** POST /v1/ops/{opId}/run */
+export async function runSingleOp(
+ opId: string,
+ sample: string,
+): Promise<{ result: OpResultMap; durationMs: number }> {
+ try {
+ return await httpJson<{ result: OpResultMap; durationMs: number }>(
+ `/v1/ops/${encodeURIComponent(opId)}/run`,
+ {
+ method: "POST",
+ body: { sample },
+ },
+ );
+ } catch (err) {
+ if (err instanceof HttpError && err.status === 404) {
+ throw new UnknownOpError(opId);
+ }
+ throw err;
+ }
+}
diff --git a/frontend/portal/src/api/search.ts b/frontend/portal/src/api/search.ts
new file mode 100644
index 000000000..6a7b600df
--- /dev/null
+++ b/frontend/portal/src/api/search.ts
@@ -0,0 +1,9 @@
+import { httpJson } from "@portal/api/http";
+import type { QuickAction } from "@portal/mocks/search";
+
+export type { QuickAction };
+
+/** GET /v1/search/quick-actions */
+export async function fetchQuickActions(): Promise {
+ return httpJson("/v1/search/quick-actions");
+}
diff --git a/frontend/portal/src/components/AppShell.css b/frontend/portal/src/components/AppShell.css
new file mode 100644
index 000000000..1c7491d9a
--- /dev/null
+++ b/frontend/portal/src/components/AppShell.css
@@ -0,0 +1,19 @@
+.portal-shell {
+ display: flex;
+ min-height: 100vh;
+ background: var(--color-bg);
+ color: var(--color-text-2);
+}
+
+.portal-shell__main {
+ flex: 1 1 auto;
+ display: flex;
+ flex-direction: column;
+ min-width: 0; /* prevent grid blowout on narrow content */
+}
+
+.portal-shell__view {
+ flex: 1 1 auto;
+ overflow-y: auto;
+ animation: fadeInUp var(--motion-enter) both;
+}
diff --git a/frontend/portal/src/components/AppShell.tsx b/frontend/portal/src/components/AppShell.tsx
new file mode 100644
index 000000000..606f021c0
--- /dev/null
+++ b/frontend/portal/src/components/AppShell.tsx
@@ -0,0 +1,21 @@
+import type { ReactNode } from "react";
+import { Sidebar } from "@portal/components/Sidebar";
+import { Header } from "@portal/components/Header";
+import "@portal/components/AppShell.css";
+
+/**
+ * Two-column layout: fixed-width sidebar on the left, sticky header + scrolling
+ * main column on the right. The Sidebar and Header read their state from
+ * context, so this shell stays prop-free.
+ */
+export function AppShell({ children }: { children: ReactNode }) {
+ return (
+
+ );
+}
diff --git a/frontend/portal/src/components/AssistantButton.css b/frontend/portal/src/components/AssistantButton.css
new file mode 100644
index 000000000..107ff7c1a
--- /dev/null
+++ b/frontend/portal/src/components/AssistantButton.css
@@ -0,0 +1,35 @@
+.portal-assistant-btn {
+ position: fixed;
+ bottom: 1.5rem;
+ right: 1.5rem;
+ width: 3rem;
+ height: 3rem;
+ border-radius: 50%;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: #fff;
+ background: linear-gradient(
+ 135deg,
+ var(--color-blue) 0%,
+ var(--color-purple) 100%
+ );
+ box-shadow:
+ 0 0.5rem 1.25rem rgba(59, 130, 246, 0.35),
+ inset 0 0.0625rem 0 rgba(255, 255, 255, 0.2);
+ transition:
+ transform var(--motion-base),
+ box-shadow var(--motion-base);
+ z-index: 40;
+}
+
+.portal-assistant-btn:hover {
+ transform: scale(1.08);
+ box-shadow:
+ 0 0.75rem 1.75rem rgba(59, 130, 246, 0.45),
+ inset 0 0.0625rem 0 rgba(255, 255, 255, 0.25);
+}
+
+.portal-assistant-btn:active {
+ transform: scale(1.02);
+}
diff --git a/frontend/portal/src/components/AssistantButton.tsx b/frontend/portal/src/components/AssistantButton.tsx
new file mode 100644
index 000000000..126d84ce6
--- /dev/null
+++ b/frontend/portal/src/components/AssistantButton.tsx
@@ -0,0 +1,36 @@
+import { useUI } from "@portal/contexts/UIContext";
+import { SparklesIcon } from "@portal/components/icons";
+import "@portal/components/AssistantButton.css";
+
+export function AssistantButton() {
+ const { assistantOpen, toggleAssistant } = useUI();
+ return (
+
+ {assistantOpen ? (
+
+
+
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/frontend/portal/src/components/AssistantPanel.css b/frontend/portal/src/components/AssistantPanel.css
new file mode 100644
index 000000000..e16f84f6c
--- /dev/null
+++ b/frontend/portal/src/components/AssistantPanel.css
@@ -0,0 +1,189 @@
+.portal-assistant {
+ position: fixed;
+ bottom: 5.5rem;
+ right: 1.5rem;
+ width: 23.75rem;
+ height: 32.5rem;
+ max-height: calc(100vh - 7rem);
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-xl);
+ box-shadow: var(--shadow-lg);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ z-index: 35;
+ animation: fadeInUp var(--motion-enter) both;
+}
+
+.portal-assistant__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ padding: 0.625rem 0.875rem;
+ color: var(--color-text-on-accent);
+ background: linear-gradient(
+ 135deg,
+ var(--color-blue) 0%,
+ var(--color-purple) 100%
+ );
+}
+
+.portal-assistant__header-left {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.portal-assistant__title {
+ font-size: 0.875rem;
+ font-weight: 600;
+}
+
+.portal-assistant__close {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.5rem;
+ height: 1.5rem;
+ border-radius: var(--radius-sm);
+ color: rgba(255, 255, 255, 0.85);
+ transition:
+ background var(--motion-fast),
+ color var(--motion-fast);
+}
+.portal-assistant__close:hover {
+ background: rgba(255, 255, 255, 0.15);
+ color: var(--color-text-on-accent);
+}
+
+.portal-assistant__messages {
+ flex: 1 1 auto;
+ overflow-y: auto;
+ padding: 0.875rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ background: var(--color-bg-subtle);
+}
+
+.portal-assistant__suggestions {
+ margin-bottom: 0.25rem;
+}
+
+.portal-assistant__suggestions-eyebrow {
+ font-size: 0.6875rem;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--color-section-label);
+ margin-bottom: 0.5rem;
+}
+
+.portal-assistant__suggestions-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.375rem;
+}
+
+.portal-assistant__suggestion {
+ padding: 0.4375rem 0.625rem;
+ font-size: 0.8125rem;
+ text-align: left;
+ color: var(--color-text-2);
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ transition:
+ border-color var(--motion-fast),
+ background var(--motion-fast);
+}
+.portal-assistant__suggestion:hover {
+ background: var(--color-bg-hover);
+ border-color: var(--color-blue-border);
+}
+
+.portal-assistant__bubble {
+ max-width: 80%;
+ padding: 0.5rem 0.75rem;
+ font-size: 0.8125rem;
+ line-height: 1.5;
+ border-radius: var(--radius-md);
+ animation: fadeInUp var(--motion-enter) both;
+ word-wrap: break-word;
+}
+
+.portal-assistant__bubble--user {
+ align-self: flex-end;
+ background: var(--color-blue);
+ color: var(--color-text-on-accent);
+ border-bottom-right-radius: 0.25rem;
+}
+
+.portal-assistant__bubble--assistant {
+ align-self: flex-start;
+ background: var(--color-surface);
+ color: var(--color-text-2);
+ border: 1px solid var(--color-border);
+ border-bottom-left-radius: 0.25rem;
+}
+
+.portal-assistant__typing {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+}
+.portal-assistant__typing span {
+ width: 0.375rem;
+ height: 0.375rem;
+ border-radius: 50%;
+ background: var(--color-text-5);
+ animation: pulse 1.2s ease-in-out infinite;
+}
+.portal-assistant__typing span:nth-child(2) {
+ animation-delay: 0.15s;
+}
+.portal-assistant__typing span:nth-child(3) {
+ animation-delay: 0.3s;
+}
+
+.portal-assistant__input-row {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.625rem 0.75rem;
+ border-top: 1px solid var(--color-border);
+ background: var(--color-surface);
+}
+
+.portal-assistant__input {
+ flex: 1 1 auto;
+ font: inherit;
+ font-size: 0.8125rem;
+ background: transparent;
+ border: 1px solid var(--color-border-input);
+ border-radius: var(--radius-md);
+ padding: 0.4375rem 0.625rem;
+ color: var(--color-text-1);
+ outline: none;
+ transition: border-color var(--motion-fast);
+}
+.portal-assistant__input:focus {
+ border-color: var(--color-blue);
+}
+
+.portal-assistant__send {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 2rem;
+ height: 2rem;
+ border-radius: var(--radius-md);
+ background: var(--grad-blue-btn);
+ color: var(--color-text-on-accent);
+ transition: opacity var(--motion-fast);
+}
+.portal-assistant__send:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
diff --git a/frontend/portal/src/components/AssistantPanel.stories.tsx b/frontend/portal/src/components/AssistantPanel.stories.tsx
new file mode 100644
index 000000000..92fe3e0bd
--- /dev/null
+++ b/frontend/portal/src/components/AssistantPanel.stories.tsx
@@ -0,0 +1,59 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { useEffect } from "react";
+import { http, HttpResponse, delay } from "msw";
+import { AssistantPanel } from "@portal/components/AssistantPanel";
+import { useUI } from "@portal/contexts/UIContext";
+
+function ForceOpen() {
+ const { openAssistant } = useUI();
+ useEffect(() => {
+ openAssistant();
+ }, [openAssistant]);
+ return null;
+}
+
+const meta: Meta = {
+ title: "Portal/Assistant/AssistantPanel",
+ component: AssistantPanel,
+ parameters: { layout: "fullscreen" },
+ decorators: [
+ (S) => (
+
+
+
+
+ ),
+ ],
+};
+export default meta;
+type Story = StoryObj;
+
+export const SuggestionsOnly: Story = {};
+
+export const SlowReply: Story = {
+ parameters: {
+ msw: {
+ handlers: [
+ http.post("/v1/assistant/messages", async () => {
+ await delay(3000);
+ return HttpResponse.json({
+ reply:
+ "After a long pause: here's a deliberately slow reply to demo the typing indicator under real network latency.",
+ });
+ }),
+ ],
+ },
+ },
+};
+
+export const ReplyFails: Story = {
+ parameters: {
+ msw: {
+ handlers: [
+ http.post("/v1/assistant/messages", () =>
+ HttpResponse.json({ error: "rate limit exceeded" }, { status: 429 }),
+ ),
+ ],
+ },
+ },
+};
diff --git a/frontend/portal/src/components/AssistantPanel.tsx b/frontend/portal/src/components/AssistantPanel.tsx
new file mode 100644
index 000000000..412131461
--- /dev/null
+++ b/frontend/portal/src/components/AssistantPanel.tsx
@@ -0,0 +1,169 @@
+import { useEffect, useRef, useState } from "react";
+import { useUI } from "@portal/contexts/UIContext";
+import { useAsync } from "@portal/hooks/useAsync";
+import {
+ fetchAssistantSuggestions,
+ getAssistantReply,
+} from "@portal/api/assistant";
+import { CloseIcon, SendIcon, SparklesIcon } from "@portal/components/icons";
+import "@portal/components/AssistantPanel.css";
+
+interface Message {
+ id: number;
+ role: "user" | "assistant";
+ text: string;
+}
+
+export function AssistantPanel() {
+ const { assistantOpen, closeAssistant } = useUI();
+ const { data: suggestions } = useAsync(
+ () => fetchAssistantSuggestions(),
+ [],
+ );
+
+ const [messages, setMessages] = useState([]);
+ const [input, setInput] = useState("");
+ const [typing, setTyping] = useState(false);
+ const messagesRef = useRef(null);
+ const inputRef = useRef(null);
+ const nextIdRef = useRef(1);
+
+ useEffect(() => {
+ if (assistantOpen) {
+ const t = setTimeout(() => inputRef.current?.focus(), 60);
+ return () => clearTimeout(t);
+ }
+ return undefined;
+ }, [assistantOpen]);
+
+ useEffect(() => {
+ messagesRef.current?.scrollTo({
+ top: messagesRef.current.scrollHeight,
+ behavior: "smooth",
+ });
+ }, [messages, typing]);
+
+ async function send(text: string) {
+ const trimmed = text.trim();
+ if (!trimmed || typing) return;
+ const userMsg: Message = {
+ id: nextIdRef.current++,
+ role: "user",
+ text: trimmed,
+ };
+ setMessages((prev) => [...prev, userMsg]);
+ setInput("");
+ setTyping(true);
+
+ try {
+ const reply = await getAssistantReply(trimmed);
+ const assistantMsg: Message = {
+ id: nextIdRef.current++,
+ role: "assistant",
+ text: reply,
+ };
+ setMessages((prev) => [...prev, assistantMsg]);
+ } catch (err) {
+ const failMsg: Message = {
+ id: nextIdRef.current++,
+ role: "assistant",
+ text:
+ err instanceof Error
+ ? `Couldn't reach the assistant: ${err.message}`
+ : "Couldn't reach the assistant.",
+ };
+ setMessages((prev) => [...prev, failMsg]);
+ } finally {
+ setTyping(false);
+ }
+ }
+
+ if (!assistantOpen) return null;
+
+ return (
+
+ );
+}
diff --git a/frontend/portal/src/components/DocumentTypeGrid.css b/frontend/portal/src/components/DocumentTypeGrid.css
new file mode 100644
index 000000000..9d97efd0c
--- /dev/null
+++ b/frontend/portal/src/components/DocumentTypeGrid.css
@@ -0,0 +1,227 @@
+.portal-doctype {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.portal-doctype__head {
+ display: flex;
+ flex-direction: column;
+ gap: 0.125rem;
+}
+
+.portal-doctype__title {
+ margin: 0;
+ font-size: 1.125rem;
+ font-weight: 600;
+ color: var(--color-text-1);
+}
+
+.portal-doctype__sub {
+ margin: 0;
+ font-size: 0.8125rem;
+ color: var(--color-text-4);
+}
+
+/* Tabs */
+.portal-doctype__tabs {
+ display: flex;
+ gap: 0.375rem;
+ flex-wrap: wrap;
+}
+
+.portal-doctype__tab {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.375rem;
+ padding: 0.3125rem 0.625rem;
+ font-size: 0.75rem;
+ color: var(--color-text-3);
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-pill);
+ transition:
+ border-color var(--motion-fast),
+ background var(--motion-fast),
+ color var(--motion-fast);
+}
+
+.portal-doctype__tab:hover {
+ background: var(--color-bg-hover);
+ color: var(--color-text-1);
+}
+
+.portal-doctype__tab.is-active {
+ color: var(--portal-tab-accent, var(--color-blue));
+ border-color: var(--portal-tab-accent, var(--color-blue));
+ background: var(--color-surface);
+ font-weight: 500;
+}
+
+.portal-doctype__tab-dot {
+ width: 0.4375rem;
+ height: 0.4375rem;
+ border-radius: 50%;
+}
+
+.portal-doctype__tab-count {
+ font-size: 0.6875rem;
+ color: var(--color-text-5);
+}
+
+/* Vertical groups */
+.portal-doctype__groups {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+}
+
+.portal-doctype__group-head {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-bottom: 0.625rem;
+}
+
+.portal-doctype__group-title {
+ margin: 0;
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: var(--color-text-2);
+}
+
+.portal-doctype__group-count {
+ font-size: 0.75rem;
+ color: var(--color-text-5);
+}
+
+.portal-doctype__scroller {
+ display: flex;
+ gap: 0.75rem;
+ overflow-x: auto;
+ padding-bottom: 0.5rem;
+ scroll-snap-type: x mandatory;
+ scrollbar-width: thin;
+}
+
+.portal-doctype__scroller::-webkit-scrollbar {
+ height: 0.375rem;
+}
+.portal-doctype__scroller::-webkit-scrollbar-thumb {
+ background: var(--color-border-hover);
+ border-radius: var(--radius-pill);
+}
+
+/* Cards */
+.portal-doctype__card {
+ flex: 0 0 17rem;
+ display: flex;
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ overflow: hidden;
+ scroll-snap-align: start;
+ transition:
+ border-color var(--motion-fast),
+ box-shadow var(--motion-fast),
+ transform var(--motion-fast);
+}
+
+.portal-doctype__card:hover {
+ border-color: var(--color-border-hover);
+ box-shadow: var(--shadow-md);
+ transform: translateY(-0.0625rem);
+}
+
+.portal-doctype__card-accent {
+ width: 0.25rem;
+ flex-shrink: 0;
+}
+
+.portal-doctype__card-body {
+ flex: 1 1 auto;
+ padding: 0.875rem 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ min-width: 0;
+}
+
+.portal-doctype__card-eyebrow {
+ font-size: 0.625rem;
+ font-weight: 600;
+ letter-spacing: 0.08em;
+ color: var(--color-text-5);
+}
+
+.portal-doctype__card-title {
+ margin: 0.125rem 0 0;
+ font-size: 0.9375rem;
+ font-weight: 600;
+ color: var(--color-text-1);
+}
+
+.portal-doctype__card-desc {
+ margin: 0;
+ font-size: 0.75rem;
+ line-height: 1.45;
+ color: var(--color-text-4);
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.portal-doctype__card-meta {
+ margin-top: 0.5rem;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ font-size: 0.6875rem;
+ color: var(--color-text-4);
+}
+
+.portal-doctype__regions {
+ font-family: var(--font-mono);
+}
+
+.portal-doctype__regions-more {
+ color: var(--color-text-5);
+}
+
+.portal-doctype__card-cta {
+ margin-top: 0.5rem;
+ font-size: 0.75rem;
+ font-weight: 500;
+ text-decoration: none;
+ transition: opacity var(--motion-fast);
+}
+
+.portal-doctype__card-cta:hover {
+ opacity: 0.8;
+}
+
+.portal-doctype__skel,
+.portal-doctype__card-skel {
+ border-radius: var(--radius-md);
+ background: linear-gradient(
+ 90deg,
+ var(--color-bg-muted) 0%,
+ var(--color-bg-hover) 50%,
+ var(--color-bg-muted) 100%
+ );
+ background-size: 200% 100%;
+ animation: shimmer 1.4s linear infinite;
+}
+
+.portal-doctype__skel {
+ display: inline-block;
+ height: 0.75rem;
+}
+
+.portal-doctype__card-skel {
+ flex: 0 0 17rem;
+ height: 8rem;
+ border: 1px solid var(--color-border-light);
+}
diff --git a/frontend/portal/src/components/DocumentTypeGrid.stories.tsx b/frontend/portal/src/components/DocumentTypeGrid.stories.tsx
new file mode 100644
index 000000000..56d0f33f3
--- /dev/null
+++ b/frontend/portal/src/components/DocumentTypeGrid.stories.tsx
@@ -0,0 +1,41 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { http, HttpResponse, delay } from "msw";
+import { DocumentTypeGrid } from "@portal/components/DocumentTypeGrid";
+
+const meta: Meta = {
+ title: "Portal/Home/DocumentTypeGrid",
+ component: DocumentTypeGrid,
+ parameters: { layout: "padded" },
+ decorators: [
+ (S) => (
+
+
+
+ ),
+ ],
+};
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const Loading: Story = {
+ parameters: {
+ msw: {
+ handlers: [
+ http.get("/v1/endpoints", async () => {
+ await delay("infinite");
+ return HttpResponse.json([]);
+ }),
+ ],
+ },
+ },
+};
+
+export const Empty: Story = {
+ parameters: {
+ msw: {
+ handlers: [http.get("/v1/endpoints", () => HttpResponse.json([]))],
+ },
+ },
+};
diff --git a/frontend/portal/src/components/DocumentTypeGrid.tsx b/frontend/portal/src/components/DocumentTypeGrid.tsx
new file mode 100644
index 000000000..8aec2784a
--- /dev/null
+++ b/frontend/portal/src/components/DocumentTypeGrid.tsx
@@ -0,0 +1,181 @@
+import { useState } from "react";
+import {
+ EmptyState,
+ Skeleton,
+ StatusBadge,
+ Tabs,
+ type TabItem,
+} from "@shared/components";
+import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
+import {
+ fetchVerticals,
+ type Endpoint,
+ type Vertical,
+ type VerticalKey,
+} from "@portal/api/endpoints";
+import "@portal/components/DocumentTypeGrid.css";
+
+type ActiveTab = VerticalKey | "all";
+
+const TIER_LABEL = ["Free", "Paid", "Enterprise"] as const;
+const TIER_TONE = ["success", "info", "purple"] as const;
+
+function tierBadge(tier: 0 | 1 | 2) {
+ return (
+
+ {TIER_LABEL[tier]}
+
+ );
+}
+
+function EndpointCard({
+ endpoint,
+ vertical,
+}: {
+ endpoint: Endpoint;
+ vertical: Vertical;
+}) {
+ const visibleRegions = endpoint.regions.slice(0, 2);
+ const extraRegions = endpoint.regions.length - visibleRegions.length;
+ return (
+
+
+
+
+ {vertical.label.toUpperCase()}
+
+
{endpoint.name}
+
{endpoint.desc}
+
+
+ {visibleRegions.join(" · ")}
+ {extraRegions > 0 && (
+
+ {" "}
+ +{extraRegions} more
+
+ )}
+
+ {tierBadge(endpoint.tier)}
+
+
e.preventDefault()}
+ >
+ Explore →
+
+
+
+ );
+}
+
+function GridSkeleton() {
+ return (
+
+ {Array.from({ length: 2 }).map((_, gi) => (
+
+
+
+
+
+
+ {Array.from({ length: 4 }).map((_, ci) => (
+
+ ))}
+
+
+ ))}
+
+ );
+}
+
+export function DocumentTypeGrid() {
+ const [tab, setTab] = useState("all");
+ const state = useAsync(() => fetchVerticals(), []);
+ const { data: verticals } = state;
+ const { isLoading, isEmpty } = useSectionFlags(state);
+ const hasVerticals = verticals !== null && verticals.length > 0;
+
+ const tabItems: TabItem[] = hasVerticals
+ ? [
+ { key: "all", label: "All" },
+ ...verticals.map>((v) => ({
+ key: v.key,
+ label: v.label,
+ count: v.endpoints.length,
+ accentColor: v.color,
+ dotColor: v.color,
+ })),
+ ]
+ : [];
+
+ return (
+
+
+
+ {hasVerticals && (
+
+ items={tabItems}
+ activeKey={tab}
+ onChange={setTab}
+ ariaLabel="Document type verticals"
+ />
+ )}
+
+ {isLoading && }
+
+ {isEmpty && (
+
+ )}
+
+ {hasVerticals && (
+
+ {(tab === "all"
+ ? verticals
+ : verticals.filter((v) => v.key === tab)
+ ).map((v) => (
+
+ {tab === "all" && (
+
+
+
{v.label}
+
+ {v.endpoints.length} endpoints
+
+
+ )}
+
+ {v.endpoints.map((endpoint) => (
+
+ ))}
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/frontend/portal/src/components/Header.css b/frontend/portal/src/components/Header.css
new file mode 100644
index 000000000..047009d2f
--- /dev/null
+++ b/frontend/portal/src/components/Header.css
@@ -0,0 +1,182 @@
+.portal-header {
+ height: 3.25rem;
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0 1rem;
+ background: var(--color-header-bg);
+ border-bottom: 1px solid var(--color-header-border);
+ position: sticky;
+ top: 0;
+ z-index: 10;
+}
+
+.portal-header__left {
+ flex: 0 0 auto;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ min-width: 12.5rem;
+}
+
+.portal-header__breadcrumb {
+ font-size: 0.8125rem;
+ font-weight: 500;
+ color: var(--color-header-text);
+}
+
+/* Search trigger */
+.portal-header__search {
+ flex: 0 1 20rem;
+ margin: 0 auto;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.375rem 0.625rem;
+ background: var(--color-search-bg);
+ border: 1px solid var(--color-search-border);
+ border-radius: var(--radius-md);
+ color: var(--color-search-text);
+ font-size: 0.75rem;
+ transition:
+ border-color var(--motion-fast),
+ background var(--motion-fast);
+}
+.portal-header__search:hover {
+ border-color: var(--color-search-border-hover);
+}
+
+.portal-header__search-placeholder {
+ flex: 1 1 auto;
+ text-align: left;
+}
+
+.portal-header__search-kbd {
+ font-family: var(--font-mono);
+ font-size: 0.6875rem;
+ padding: 0.0625rem 0.25rem;
+ border-radius: var(--radius-xs);
+ background: var(--color-bg-muted);
+ color: var(--color-text-4);
+}
+
+/* Right cluster */
+.portal-header__right {
+ flex: 0 0 auto;
+ display: flex;
+ align-items: center;
+ gap: 0.375rem;
+}
+
+.portal-header__icon-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 2rem;
+ height: 2rem;
+ border-radius: var(--radius-md);
+ color: var(--color-bell-stroke, var(--color-text-3));
+ position: relative;
+ transition:
+ background var(--motion-fast),
+ color var(--motion-fast);
+}
+.portal-header__icon-btn:hover {
+ background: var(--color-bg-hover);
+ color: var(--color-text-1);
+}
+
+.portal-header__bell-dot {
+ position: absolute;
+ top: 0.375rem;
+ right: 0.4375rem;
+ width: 0.4375rem;
+ height: 0.4375rem;
+ border-radius: 50%;
+ background: var(--color-red);
+ border: 2px solid var(--color-header-bg);
+ box-sizing: content-box;
+}
+
+/* Tier switcher */
+.portal-header__tier {
+ position: relative;
+}
+
+.portal-header__tier-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.375rem;
+ padding: 0.3125rem 0.625rem;
+ font-size: 0.75rem;
+ color: var(--color-text-2);
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ transition:
+ border-color var(--motion-fast),
+ background var(--motion-fast);
+}
+.portal-header__tier-btn:hover {
+ background: var(--color-bg-hover);
+ border-color: var(--color-border-hover);
+}
+
+.portal-header__tier-dot {
+ width: 0.4375rem;
+ height: 0.4375rem;
+ border-radius: 50%;
+}
+
+.portal-header__tier-label {
+ font-weight: 500;
+}
+
+.portal-header__tier-menu {
+ position: absolute;
+ top: calc(100% + 0.25rem);
+ right: 0;
+ min-width: 12rem;
+ margin: 0;
+ padding: 0.25rem;
+ list-style: none;
+ background: var(--color-dropdown-bg);
+ border: 1px solid var(--color-dropdown-border);
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-lg);
+ z-index: 20;
+ animation: fadeInUp var(--motion-enter) both;
+}
+
+.portal-header__tier-option {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.4375rem 0.625rem;
+ font-size: 0.8125rem;
+ color: var(--color-text-2);
+ border-radius: var(--radius-sm);
+ text-align: left;
+}
+.portal-header__tier-option:hover {
+ background: var(--color-dropdown-hover);
+}
+.portal-header__tier-option.is-active {
+ background: var(--color-nav-active);
+ color: var(--color-nav-active-text);
+}
+
+/* Avatar */
+.portal-header__avatar {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 2rem;
+ height: 2rem;
+ border-radius: 50%;
+ background: var(--grad-blue-btn);
+ color: #fff;
+ font-size: 0.8125rem;
+ font-weight: 600;
+}
diff --git a/frontend/portal/src/components/Header.stories.tsx b/frontend/portal/src/components/Header.stories.tsx
new file mode 100644
index 000000000..25f2b9c2b
--- /dev/null
+++ b/frontend/portal/src/components/Header.stories.tsx
@@ -0,0 +1,26 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { Header } from "@portal/components/Header";
+
+const meta: Meta = {
+ title: "Portal/Shell/Header",
+ component: Header,
+ parameters: { layout: "fullscreen" },
+ decorators: [
+ (S) => (
+
+
+
+ Page body would render below the header.
+
+
+ ),
+ ],
+};
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const FreeTier: Story = { globals: { tier: "free" } };
+export const ProTier: Story = { globals: { tier: "pro" } };
+export const EnterpriseTier: Story = { globals: { tier: "enterprise" } };
diff --git a/frontend/portal/src/components/Header.tsx b/frontend/portal/src/components/Header.tsx
new file mode 100644
index 000000000..10c1607a0
--- /dev/null
+++ b/frontend/portal/src/components/Header.tsx
@@ -0,0 +1,104 @@
+import { Avatar, Dropdown } from "@shared/components";
+import { useTheme } from "@portal/contexts/ThemeContext";
+import { useTier, TIER_INFO, type Tier } from "@portal/contexts/TierContext";
+import { useView, VIEW_LABELS } from "@portal/contexts/ViewContext";
+import { useUI } from "@portal/contexts/UIContext";
+import {
+ SearchIcon,
+ SunIcon,
+ MoonIcon,
+ ChevronDownIcon,
+} from "@portal/components/icons";
+import { NotificationsDropdown } from "@portal/components/NotificationsDropdown";
+import { MocksToggle } from "@portal/components/MocksToggle";
+import "@portal/components/Header.css";
+
+function ThemeToggle() {
+ const { theme, toggle } = useTheme();
+ return (
+
+ {theme === "light" ? : }
+
+ );
+}
+
+function TierSwitcher() {
+ const { tier, setTier } = useTier();
+ const info = TIER_INFO[tier];
+ return (
+
+
+
+
+ {info.label}
+
+
+
+
+ {(Object.keys(TIER_INFO) as Tier[]).map((id) => (
+ setTier(id)}
+ leading={
+
+ }
+ >
+ {TIER_INFO[id].label}
+
+ ))}
+
+
+ );
+}
+
+export function Header() {
+ const { activeView } = useView();
+ const { openSearch } = useUI();
+ return (
+
+ );
+}
diff --git a/frontend/portal/src/components/MocksToggle.css b/frontend/portal/src/components/MocksToggle.css
new file mode 100644
index 000000000..68e5262ed
--- /dev/null
+++ b/frontend/portal/src/components/MocksToggle.css
@@ -0,0 +1,51 @@
+.portal-mocks-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1_5);
+ padding: var(--space-1) var(--space-2);
+ font-family: var(--font-mono);
+ font-size: 0.6875rem;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ border-radius: var(--radius-sm);
+ border: 1px dashed transparent;
+ transition:
+ background var(--motion-fast),
+ border-color var(--motion-fast),
+ color var(--motion-fast);
+}
+
+.portal-mocks-toggle.is-on {
+ color: var(--color-amber-dark);
+ background: var(--color-amber-light);
+ border-color: var(--color-amber-border);
+}
+
+.portal-mocks-toggle.is-off {
+ color: var(--color-text-4);
+ background: var(--color-bg-muted);
+ border-color: var(--color-border);
+}
+
+.portal-mocks-toggle:hover {
+ filter: brightness(1.04);
+}
+
+.portal-mocks-toggle.is-pending {
+ opacity: 0.6;
+ cursor: progress;
+}
+
+.portal-mocks-toggle__dot {
+ width: 0.4375rem;
+ height: 0.4375rem;
+ border-radius: 50%;
+ background: currentColor;
+ box-shadow: 0 0 0 2px color-mix(in srgb, currentColor 28%, transparent);
+}
+
+.portal-mocks-toggle.is-off .portal-mocks-toggle__dot {
+ background: var(--color-text-5);
+ box-shadow: none;
+}
diff --git a/frontend/portal/src/components/MocksToggle.tsx b/frontend/portal/src/components/MocksToggle.tsx
new file mode 100644
index 000000000..2293102fa
--- /dev/null
+++ b/frontend/portal/src/components/MocksToggle.tsx
@@ -0,0 +1,54 @@
+///
+import { useState } from "react";
+import {
+ readMocksPreference,
+ writeMocksPreference,
+} from "@portal/mocks/preference";
+import "@portal/components/MocksToggle.css";
+
+/**
+ * Dev-only header chip that flips MSW interception on and off. Persists the
+ * preference to localStorage so it survives reloads. Hidden entirely in
+ * production builds — there's no MSW worker to toggle there.
+ *
+ * Toggling reloads the page. Without a reload, components that already
+ * fetched data via useAsync keep showing the cached result, which makes the
+ * toggle feel like it does nothing. A reload gives a clean view of what the
+ * app looks like with/without mocks.
+ */
+export function MocksToggle() {
+ const [enabled] = useState(() => readMocksPreference());
+ const [pending, setPending] = useState(false);
+
+ if (!import.meta.env.DEV) return null;
+
+ function toggle() {
+ if (pending) return;
+ setPending(true);
+ writeMocksPreference(!enabled);
+ window.location.reload();
+ }
+
+ return (
+
+
+
+ Mocks {enabled ? "ON" : "OFF"}
+
+
+ );
+}
diff --git a/frontend/portal/src/components/NotificationsDropdown.css b/frontend/portal/src/components/NotificationsDropdown.css
new file mode 100644
index 000000000..325f8f4fb
--- /dev/null
+++ b/frontend/portal/src/components/NotificationsDropdown.css
@@ -0,0 +1,137 @@
+.portal-notif {
+ position: relative;
+}
+
+.portal-notif__panel {
+ position: absolute;
+ top: calc(100% + 0.25rem);
+ right: 0;
+ width: 22.5rem;
+ background: var(--color-notif-bg, var(--color-surface));
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-lg);
+ overflow: hidden;
+ z-index: 25;
+ animation: fadeInUp var(--motion-enter) both;
+}
+
+.portal-notif__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.625rem 0.875rem;
+ border-bottom: 1px solid var(--color-border-light);
+ font-size: 0.8125rem;
+}
+
+.portal-notif__title {
+ font-weight: 600;
+ color: var(--color-text-1);
+}
+
+.portal-notif__count {
+ font-size: 0.6875rem;
+ font-weight: 500;
+ color: var(--color-blue);
+ padding: 0.125rem 0.375rem;
+ background: var(--color-blue-light);
+ border-radius: var(--radius-pill);
+}
+
+.portal-notif__list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ max-height: 22rem;
+ overflow-y: auto;
+}
+
+.portal-notif__item {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.625rem;
+ padding: 0.625rem 0.875rem;
+ border-bottom: 1px solid var(--color-border-light);
+ cursor: pointer;
+ transition: background var(--motion-fast);
+}
+
+.portal-notif__item:last-child {
+ border-bottom: none;
+}
+
+.portal-notif__item:hover {
+ background: var(--color-bg-hover);
+}
+
+.portal-notif__dot {
+ flex: 0 0 auto;
+ width: 0.5rem;
+ height: 0.5rem;
+ border-radius: 50%;
+ margin-top: 0.4375rem;
+}
+
+.portal-notif__body {
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+.portal-notif__item-title {
+ font-size: 0.8125rem;
+ font-weight: 500;
+ color: var(--color-text-1);
+ margin-bottom: 0.125rem;
+}
+
+.portal-notif__desc {
+ font-size: 0.75rem;
+ color: var(--color-text-4);
+ line-height: 1.4;
+ margin-bottom: 0.25rem;
+}
+
+.portal-notif__time {
+ font-size: 0.6875rem;
+ color: var(--color-text-5);
+}
+
+.portal-notif__footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.5rem 0.625rem;
+ border-top: 1px solid var(--color-border-light);
+ background: var(--color-bg-subtle);
+}
+
+.portal-notif__action {
+ font-size: 0.75rem;
+ font-weight: 500;
+ color: var(--color-blue);
+ padding: 0.25rem 0.5rem;
+ border-radius: var(--radius-sm);
+ transition: background var(--motion-fast);
+}
+
+.portal-notif__action:hover:not(:disabled) {
+ background: var(--color-bg-hover);
+}
+
+.portal-notif__action:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.portal-notif__count--quiet {
+ background: var(--color-bg-muted);
+ color: var(--color-text-5);
+}
+
+.portal-notif__empty {
+ padding: 1.5rem 1rem;
+ text-align: center;
+ font-size: 0.8125rem;
+ color: var(--color-text-4);
+}
diff --git a/frontend/portal/src/components/NotificationsDropdown.stories.tsx b/frontend/portal/src/components/NotificationsDropdown.stories.tsx
new file mode 100644
index 000000000..1470f5e44
--- /dev/null
+++ b/frontend/portal/src/components/NotificationsDropdown.stories.tsx
@@ -0,0 +1,76 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { http, HttpResponse, delay } from "msw";
+import { NotificationsDropdown } from "@portal/components/NotificationsDropdown";
+import { NOTIFICATIONS } from "@portal/mocks/notifications";
+
+const meta: Meta = {
+ title: "Portal/Header/NotificationsDropdown",
+ component: NotificationsDropdown,
+ parameters: { layout: "centered" },
+ decorators: [
+ (S) => (
+
+
+
+ ),
+ ],
+};
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const Empty: Story = {
+ parameters: {
+ msw: {
+ handlers: [http.get("/v1/notifications", () => HttpResponse.json([]))],
+ },
+ },
+};
+
+export const Loading: Story = {
+ parameters: {
+ msw: {
+ handlers: [
+ http.get("/v1/notifications", async () => {
+ await delay("infinite");
+ return HttpResponse.json([]);
+ }),
+ ],
+ },
+ },
+};
+
+export const HighVolume: Story = {
+ parameters: {
+ msw: {
+ handlers: [
+ http.get("/v1/notifications", () => {
+ const items = Array.from({ length: 24 }, (_, i) => ({
+ ...NOTIFICATIONS[i % NOTIFICATIONS.length],
+ id: `n${i + 1}`,
+ }));
+ return HttpResponse.json(items);
+ }),
+ ],
+ },
+ },
+};
+
+export const NetworkError: Story = {
+ parameters: {
+ msw: {
+ handlers: [
+ http.get("/v1/notifications", () =>
+ HttpResponse.json({ error: "Service unavailable" }, { status: 503 }),
+ ),
+ ],
+ },
+ },
+};
diff --git a/frontend/portal/src/components/NotificationsDropdown.tsx b/frontend/portal/src/components/NotificationsDropdown.tsx
new file mode 100644
index 000000000..2445798cc
--- /dev/null
+++ b/frontend/portal/src/components/NotificationsDropdown.tsx
@@ -0,0 +1,125 @@
+import { useState } from "react";
+import { Dropdown, EmptyState, Skeleton } from "@shared/components";
+import { BellIcon } from "@portal/components/icons";
+import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
+import {
+ fetchNotifications,
+ markAllNotificationsRead,
+ type Notification,
+ type NotificationCategory,
+} from "@portal/api/notifications";
+import "@portal/components/NotificationsDropdown.css";
+
+const CATEGORY_COLOUR: Record = {
+ pipeline: "var(--color-blue)",
+ deploy: "var(--color-green)",
+ billing: "var(--color-amber)",
+ audit: "var(--color-purple)",
+ agent: "var(--color-cat-insurance)",
+ doc: "var(--color-cat-healthcare)",
+};
+
+export function NotificationsDropdown() {
+ const state = useAsync(() => fetchNotifications(), []);
+ const { data: items } = state;
+ const { isLoading } = useSectionFlags(state);
+
+ // Optimistically clear on "mark all read"; revert if the request fails.
+ const [cleared, setCleared] = useState(false);
+ const visible = cleared ? [] : (items ?? []);
+
+ async function onMarkAllRead() {
+ setCleared(true);
+ try {
+ await markAllNotificationsRead();
+ } catch {
+ setCleared(false);
+ }
+ }
+
+ const isEmpty = !isLoading && visible.length === 0;
+ const hasUnread = visible.length > 0;
+
+ return (
+
+
+
+
+ {hasUnread && (
+
+ )}
+
+
+
+
+ Notifications
+ {hasUnread ? (
+ {visible.length} new
+ ) : isLoading ? (
+
+ loading
+
+ ) : (
+
+ all read
+
+ )}
+
+ {isLoading && (
+
+
+
+
+
+
+ )}
+ {isEmpty && (
+
+ )}
+ {!isLoading && !isEmpty && (
+
+ {visible.map((item) => (
+
+
+
+
{item.title}
+
{item.description}
+
{item.time}
+
+
+ ))}
+
+ )}
+
+
+ Mark all read
+
+
+ View all
+
+
+
+
+ );
+}
diff --git a/frontend/portal/src/components/PopularUseCases.css b/frontend/portal/src/components/PopularUseCases.css
new file mode 100644
index 000000000..af79deb2a
--- /dev/null
+++ b/frontend/portal/src/components/PopularUseCases.css
@@ -0,0 +1,87 @@
+.portal-usecases {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.portal-usecases__head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 0.5rem;
+}
+
+.portal-usecases__title {
+ margin: 0;
+ font-size: 1.125rem;
+ font-weight: 600;
+ color: var(--color-text-1);
+}
+
+.portal-usecases__viewall,
+.portal-usecases__cta {
+ background: none;
+ border: none;
+ padding: 0;
+ cursor: pointer;
+ font-family: inherit;
+ font-weight: 500;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ transition: opacity var(--motion-fast);
+}
+
+.portal-usecases__viewall {
+ font-size: 0.8125rem;
+ color: var(--color-blue);
+}
+
+.portal-usecases__viewall:hover,
+.portal-usecases__cta:hover {
+ opacity: 0.8;
+}
+
+.portal-usecases__grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 0.875rem;
+}
+
+@media (max-width: 50rem) {
+ .portal-usecases__grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+.portal-usecases__card {
+ display: flex;
+ flex-direction: column;
+ gap: 0.375rem;
+}
+
+.portal-usecases__eyebrow {
+ font-size: 0.625rem;
+ font-weight: 600;
+ letter-spacing: 0.08em;
+}
+
+.portal-usecases__card-title {
+ margin: 0.125rem 0 0;
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--color-text-1);
+}
+
+.portal-usecases__blurb {
+ margin: 0;
+ font-size: 0.8125rem;
+ line-height: 1.5;
+ color: var(--color-text-3);
+}
+
+.portal-usecases__cta {
+ margin-top: 0.5rem;
+ align-self: flex-start;
+ font-size: 0.8125rem;
+}
diff --git a/frontend/portal/src/components/PopularUseCases.tsx b/frontend/portal/src/components/PopularUseCases.tsx
new file mode 100644
index 000000000..bdbeaff82
--- /dev/null
+++ b/frontend/portal/src/components/PopularUseCases.tsx
@@ -0,0 +1,107 @@
+import { Card, type CardProps } from "@shared/components";
+import { useView } from "@portal/contexts/ViewContext";
+import "@portal/components/PopularUseCases.css";
+
+type Accent = NonNullable;
+
+interface UseCase {
+ eyebrow: string;
+ title: string;
+ blurb: string;
+ cta: string;
+ accent: Accent;
+}
+
+const ACCENT_COLOR: Record = {
+ blue: "var(--color-blue)",
+ purple: "var(--color-purple)",
+ green: "var(--color-green)",
+ amber: "var(--color-amber)",
+ red: "var(--color-red)",
+};
+
+/**
+ * Curated landing-page use cases — a teaser, not the full catalogue. The
+ * exhaustive per-vertical endpoint list lives on the Documents view; here we
+ * surface the four cross-cutting pipelines people reach for first. Copy mirrors
+ * the prototype's "Popular use cases" block.
+ */
+const USE_CASES: UseCase[] = [
+ {
+ eyebrow: "AUTO-ROUTING",
+ title: "Auto-classify and route incoming documents",
+ blurb:
+ "One classifier reads what arrived — KYC form, invoice, contract, COI — and routes to the right downstream pipeline. No manual triage, no docs in the wrong workflow.",
+ cta: "Build a classifier pipeline",
+ accent: "blue",
+ },
+ {
+ eyebrow: "PII REDACTION",
+ title: "Redact PII before it leaves your stack",
+ blurb:
+ "Strip sensitive fields before storage, indexing, or LLM processing. Schema-aware, per-field audit, BYOK or HYOK keys. Compliance at the document boundary, not per pipeline.",
+ cta: "See redaction pipelines",
+ accent: "red",
+ },
+ {
+ eyebrow: "TRAINING DATA",
+ title: "Turn PDFs into training data",
+ blurb:
+ "Batch-import an archive, redact PII, classify, chunk, and emit ready-to-load JSON for fine-tuning, eval sets, or RAG. Self-completing and replayable.",
+ cta: "Build a training-data pipeline",
+ accent: "purple",
+ },
+ {
+ eyebrow: "AUTHENTICITY",
+ title: "Verify signatures and detect tampering",
+ blurb:
+ "Cryptographic checks at the document boundary — signature validation, tamper detection, signing flows for outbound documents. Trust decisions in the pipeline, not your app code.",
+ cta: "Try authenticity check",
+ accent: "green",
+ },
+];
+
+export function PopularUseCases() {
+ const { setActiveView } = useView();
+ return (
+
+
+ Popular use cases
+ setActiveView("pipelines")}
+ >
+ View all pipelines →
+
+
+
+ {USE_CASES.map((uc) => (
+
+
+ {uc.eyebrow}
+
+ {uc.title}
+ {uc.blurb}
+ setActiveView("pipelines")}
+ >
+ {uc.cta} →
+
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/portal/src/components/RecentActivity.css b/frontend/portal/src/components/RecentActivity.css
new file mode 100644
index 000000000..9fceadfe8
--- /dev/null
+++ b/frontend/portal/src/components/RecentActivity.css
@@ -0,0 +1,138 @@
+.portal-activity {
+ display: flex;
+ flex-direction: column;
+}
+
+.portal-activity__head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.875rem 1rem;
+ border-bottom: 1px solid var(--color-border-light);
+}
+
+.portal-activity__title {
+ margin: 0;
+ font-size: 0.9375rem;
+ font-weight: 600;
+ color: var(--color-text-1);
+}
+
+.portal-activity__more {
+ font-size: 0.75rem;
+ font-weight: 500;
+ color: var(--color-blue);
+ padding: 0.25rem 0.5rem;
+ border-radius: var(--radius-sm);
+ transition: background var(--motion-fast);
+}
+
+.portal-activity__more:hover {
+ background: var(--color-bg-hover);
+}
+
+.portal-activity__list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.portal-activity__item {
+ display: flex;
+ gap: 0.75rem;
+ padding: 0.75rem 1rem;
+ border-bottom: 1px solid var(--color-border-light);
+ transition: background var(--motion-fast);
+}
+
+.portal-activity__item:last-child {
+ border-bottom: none;
+}
+
+.portal-activity__item:hover {
+ background: var(--color-bg-hover);
+}
+
+.portal-activity__rail {
+ width: 0.1875rem;
+ border-radius: var(--radius-pill);
+ flex-shrink: 0;
+}
+
+.portal-activity__body {
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+.portal-activity__row {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 0.5rem;
+}
+
+.portal-activity__action {
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: var(--color-text-1);
+}
+
+.portal-activity__time {
+ font-size: 0.6875rem;
+ color: var(--color-text-5);
+}
+
+.portal-activity__subject {
+ font-size: 0.75rem;
+ color: var(--color-text-3);
+ margin-top: 0.0625rem;
+}
+
+.portal-activity__detail-row {
+ margin-top: 0.375rem;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+}
+
+.portal-activity__detail {
+ font-size: 0.75rem;
+ color: var(--color-text-4);
+ font-family: var(--font-mono);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ min-width: 0;
+}
+
+.portal-activity__item--skeleton {
+ cursor: default;
+}
+
+.portal-activity__item--skeleton .portal-activity__rail {
+ background: var(--color-border-light);
+}
+
+.portal-activity__skel {
+ height: 0.75rem;
+ border-radius: var(--radius-pill);
+ background: linear-gradient(
+ 90deg,
+ var(--color-bg-muted) 0%,
+ var(--color-bg-hover) 50%,
+ var(--color-bg-muted) 100%
+ );
+ background-size: 200% 100%;
+ animation: shimmer 1.4s linear infinite;
+ margin-bottom: 0.375rem;
+}
+
+.portal-activity__skel--md {
+ width: 70%;
+}
+
+.portal-activity__skel--sm {
+ width: 50%;
+ height: 0.625rem;
+}
diff --git a/frontend/portal/src/components/RecentActivity.stories.tsx b/frontend/portal/src/components/RecentActivity.stories.tsx
new file mode 100644
index 000000000..5b33617ea
--- /dev/null
+++ b/frontend/portal/src/components/RecentActivity.stories.tsx
@@ -0,0 +1,41 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { http, HttpResponse, delay } from "msw";
+import { RecentActivity } from "@portal/components/RecentActivity";
+
+const meta: Meta = {
+ title: "Portal/Home/RecentActivity",
+ component: RecentActivity,
+ parameters: { layout: "padded" },
+ decorators: [
+ (S) => (
+
+
+
+ ),
+ ],
+};
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const Loading: Story = {
+ parameters: {
+ msw: {
+ handlers: [
+ http.get("/v1/activity", async () => {
+ await delay("infinite");
+ return HttpResponse.json([]);
+ }),
+ ],
+ },
+ },
+};
+
+export const Empty: Story = {
+ parameters: {
+ msw: {
+ handlers: [http.get("/v1/activity", () => HttpResponse.json([]))],
+ },
+ },
+};
diff --git a/frontend/portal/src/components/RecentActivity.tsx b/frontend/portal/src/components/RecentActivity.tsx
new file mode 100644
index 000000000..74a11f042
--- /dev/null
+++ b/frontend/portal/src/components/RecentActivity.tsx
@@ -0,0 +1,94 @@
+import { Card, EmptyState, Skeleton, StatusBadge } from "@shared/components";
+import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
+import {
+ fetchRecentActivity,
+ type ActivityEvent,
+ type ActivityKind,
+} from "@portal/api/home";
+import "@portal/components/RecentActivity.css";
+
+const KIND_COLOUR: Record = {
+ "pipeline-run": "var(--color-blue)",
+ deploy: "var(--color-green)",
+ drift: "var(--color-amber)",
+ eval: "var(--color-purple)",
+ agent: "var(--color-cat-insurance)",
+ billing: "var(--color-red)",
+};
+
+export function RecentActivity() {
+ const state = useAsync(() => fetchRecentActivity(), []);
+ const { data: events } = state;
+ const { isLoading, isEmpty } = useSectionFlags(state);
+
+ return (
+
+
+ Recent activity
+
+ View all →
+
+
+
+ {isLoading && (
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ {isEmpty && (
+
+ )}
+
+ {events && events.length > 0 && (
+
+ {events.map((event) => (
+
+
+
+
+
+ {event.action}
+
+ {event.time}
+
+
{event.subject}
+
+
+ {event.detail}
+
+
+ {event.status}
+
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/frontend/portal/src/components/SearchModal.css b/frontend/portal/src/components/SearchModal.css
new file mode 100644
index 000000000..6b139c975
--- /dev/null
+++ b/frontend/portal/src/components/SearchModal.css
@@ -0,0 +1,70 @@
+.portal-search__input-row {
+ display: flex;
+ align-items: center;
+ gap: 0.625rem;
+ padding: 0.25rem 0.25rem 0.75rem;
+ border-bottom: 1px solid var(--color-border-light);
+ color: var(--color-text-4);
+}
+
+.portal-search__input {
+ flex: 1 1 auto;
+ font: inherit;
+ font-size: 0.9375rem;
+ background: transparent;
+ border: none;
+ outline: none;
+ color: var(--color-text-1);
+}
+
+.portal-search__input::placeholder {
+ color: var(--color-text-placeholder);
+}
+
+.portal-search__esc {
+ font-family: var(--font-mono);
+ font-size: 0.6875rem;
+ padding: 0.0625rem 0.375rem;
+ border-radius: var(--radius-xs);
+ background: var(--color-bg-muted);
+ color: var(--color-text-4);
+}
+
+.portal-search__results {
+ padding-top: 0.75rem;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ max-height: 24rem;
+ overflow-y: auto;
+}
+
+.portal-search__group-label {
+ font-size: 0.6875rem;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--color-section-label);
+ padding: 0 0.5rem 0.375rem;
+}
+
+.portal-search__item {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.5rem 0.625rem;
+ border-radius: var(--radius-sm);
+ color: var(--color-text-2);
+ font-size: 0.8125rem;
+ text-align: left;
+}
+
+.portal-search__item:hover {
+ background: var(--color-bg-hover);
+}
+
+.portal-search__item-hint {
+ font-family: var(--font-mono);
+ font-size: 0.6875rem;
+ color: var(--color-text-5);
+}
diff --git a/frontend/portal/src/components/SearchModal.stories.tsx b/frontend/portal/src/components/SearchModal.stories.tsx
new file mode 100644
index 000000000..16de3449e
--- /dev/null
+++ b/frontend/portal/src/components/SearchModal.stories.tsx
@@ -0,0 +1,41 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { useEffect } from "react";
+import { http, HttpResponse } from "msw";
+import { SearchModal } from "@portal/components/SearchModal";
+import { useUI } from "@portal/contexts/UIContext";
+
+function ForceOpen() {
+ const { openSearch } = useUI();
+ useEffect(() => {
+ openSearch();
+ }, [openSearch]);
+ return null;
+}
+
+const meta: Meta = {
+ title: "Portal/Header/SearchModal",
+ component: SearchModal,
+ parameters: { layout: "fullscreen" },
+ decorators: [
+ (S) => (
+
+
+
+
+ ),
+ ],
+};
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const EmptyCatalogue: Story = {
+ parameters: {
+ msw: {
+ handlers: [
+ http.get("/v1/search/quick-actions", () => HttpResponse.json([])),
+ ],
+ },
+ },
+};
diff --git a/frontend/portal/src/components/SearchModal.tsx b/frontend/portal/src/components/SearchModal.tsx
new file mode 100644
index 000000000..f0da1abef
--- /dev/null
+++ b/frontend/portal/src/components/SearchModal.tsx
@@ -0,0 +1,121 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+import { EmptyState, Modal, Skeleton } from "@shared/components";
+import { useUI } from "@portal/contexts/UIContext";
+import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
+import { fetchQuickActions, type QuickAction } from "@portal/api/search";
+import { SearchIcon } from "@portal/components/icons";
+import "@portal/components/SearchModal.css";
+
+export function SearchModal() {
+ const { searchOpen, closeSearch } = useUI();
+ const inputRef = useRef(null);
+ const [query, setQuery] = useState("");
+ const state = useAsync(() => fetchQuickActions(), []);
+ const { data: actions } = state;
+ const { isLoading } = useSectionFlags(state);
+
+ useEffect(() => {
+ if (searchOpen) {
+ setQuery("");
+ const t = setTimeout(() => inputRef.current?.focus(), 0);
+ return () => clearTimeout(t);
+ }
+ return undefined;
+ }, [searchOpen]);
+
+ const filtered = useMemo(() => {
+ if (!actions) return [] as QuickAction[];
+ if (!query.trim()) return actions;
+ const needle = query.trim().toLowerCase();
+ return actions.filter((item) => item.label.toLowerCase().includes(needle));
+ }, [actions, query]);
+
+ const groups = useMemo(
+ () =>
+ filtered.reduce>((acc, item) => {
+ if (!acc[item.group]) acc[item.group] = [];
+ acc[item.group].push(item);
+ return acc;
+ }, {}),
+ [filtered],
+ );
+
+ const groupKeys = Object.keys(groups);
+ const isEmpty = !isLoading && groupKeys.length === 0;
+
+ return (
+
+
+
+
+ setQuery(e.target.value)}
+ placeholder="Search Stirling — endpoints, pipelines, docs…"
+ aria-label="Search"
+ className="portal-search__input"
+ autoComplete="off"
+ spellCheck={false}
+ />
+
+ ESC
+
+
+
+
+ {isLoading && (
+
+
+
+
+
+
+ )}
+ {isEmpty && (
+
+ )}
+ {!isLoading &&
+ !isEmpty &&
+ Object.entries(groups).map(([group, items]) => (
+
+
{group}
+ {items.map((item) => (
+
+
+ {item.label}
+
+
+ {item.hint}
+
+
+ ))}
+
+ ))}
+
+
+
+ );
+}
diff --git a/frontend/portal/src/components/Sidebar.css b/frontend/portal/src/components/Sidebar.css
new file mode 100644
index 000000000..e422d92ed
--- /dev/null
+++ b/frontend/portal/src/components/Sidebar.css
@@ -0,0 +1,130 @@
+.portal-sidebar {
+ width: 15rem;
+ height: 100vh;
+ background: var(--color-sidebar-bg);
+ border-right: 1px solid var(--color-sidebar-border);
+ display: flex;
+ flex-direction: column;
+ flex-shrink: 0;
+ position: sticky;
+ top: 0;
+}
+
+/* Logo block */
+.portal-sidebar__logo {
+ height: 3.1875rem; /* 51px */
+ padding: 0 0.875rem;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ border-bottom: 1px solid var(--color-sidebar-divider);
+}
+
+.portal-sidebar__wordmark {
+ font-family: var(--font-brand);
+ font-size: 1.125rem;
+ font-weight: 700;
+ color: var(--color-logo-text);
+ letter-spacing: 0.02em;
+}
+
+.portal-sidebar__workspace {
+ margin-left: auto;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.25rem;
+ height: 1.25rem;
+ border-radius: var(--radius-sm);
+ color: var(--color-text-4);
+ transition:
+ background var(--motion-fast),
+ color var(--motion-fast);
+}
+.portal-sidebar__workspace:hover {
+ background: var(--color-bg-hover);
+ color: var(--color-text-2);
+}
+
+/* Nav body */
+.portal-sidebar__nav {
+ flex: 1 1 auto;
+ overflow-y: auto;
+ padding: 0.75rem 0.625rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.portal-sidebar__group {
+ display: flex;
+ flex-direction: column;
+ gap: 0.125rem;
+}
+
+.portal-sidebar__divider {
+ height: 1px;
+ background: var(--color-sidebar-divider);
+ margin: 0.25rem 0;
+}
+
+/* Footer */
+.portal-sidebar__footer {
+ border-top: 1px solid var(--color-sidebar-divider);
+ padding: 0.5rem 0.625rem 0.75rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.portal-sidebar__usage {
+ padding: 0.5rem 0.625rem;
+ font-size: 0.75rem;
+ color: var(--color-usage-text);
+}
+
+.portal-sidebar__usage-line {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+}
+
+.portal-sidebar__usage-label {
+ color: var(--color-text-4);
+}
+
+.portal-sidebar__usage-value {
+ color: var(--color-usage-value);
+ font-weight: 500;
+}
+
+.portal-sidebar__usage-track {
+ margin-top: 0.5rem;
+ height: 0.25rem;
+ background: var(--color-usage-track);
+ border-radius: var(--radius-pill);
+ overflow: hidden;
+}
+
+.portal-sidebar__usage-fill {
+ height: 100%;
+ background: var(--grad-blue-btn);
+ transition: width var(--motion-slow);
+}
+
+.portal-sidebar__plan {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.375rem;
+ color: var(--color-text-2);
+ font-weight: 500;
+}
+
+.portal-sidebar__plan-dot {
+ width: 0.4375rem;
+ height: 0.4375rem;
+ border-radius: 50%;
+ background: var(--color-green);
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-green) 30%, transparent);
+}
diff --git a/frontend/portal/src/components/Sidebar.stories.tsx b/frontend/portal/src/components/Sidebar.stories.tsx
new file mode 100644
index 000000000..df4d6c1cd
--- /dev/null
+++ b/frontend/portal/src/components/Sidebar.stories.tsx
@@ -0,0 +1,29 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { Sidebar } from "@portal/components/Sidebar";
+
+const meta: Meta = {
+ title: "Portal/Shell/Sidebar",
+ component: Sidebar,
+ parameters: { layout: "fullscreen" },
+ decorators: [
+ (S) => (
+
+
+
+ ),
+ ],
+};
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const FreeTier: Story = { globals: { tier: "free" } };
+
+export const EnterpriseTier: Story = { globals: { tier: "enterprise" } };
diff --git a/frontend/portal/src/components/Sidebar.tsx b/frontend/portal/src/components/Sidebar.tsx
new file mode 100644
index 000000000..5196c677c
--- /dev/null
+++ b/frontend/portal/src/components/Sidebar.tsx
@@ -0,0 +1,174 @@
+import { NavItem } from "@shared/components";
+import { useView, type ViewId } from "@portal/contexts/ViewContext";
+import { useTier } from "@portal/contexts/TierContext";
+import { useAsync } from "@portal/hooks/useAsync";
+import { fetchHomeKpis, type KpiEntry } from "@portal/api/home";
+import {
+ HomeIcon,
+ EditorIcon,
+ SourcesIcon,
+ PipelinesIcon,
+ DocumentsIcon,
+ InfrastructureIcon,
+ UsageIcon,
+ DocsIcon,
+ SettingsIcon,
+ ChevronDownIcon,
+} from "@portal/components/icons";
+import "@portal/components/Sidebar.css";
+
+interface NavEntry {
+ id: ViewId;
+ label: string;
+ icon: React.ReactNode;
+}
+
+const GROUP_PRIMARY: NavEntry[] = [
+ { id: "home", label: "Home", icon: },
+];
+
+const GROUP_OPERATIONAL: NavEntry[] = [
+ { id: "editor", label: "Editor", icon: },
+ { id: "sources", label: "Sources", icon: },
+ { id: "pipelines", label: "Pipelines", icon: },
+ { id: "documents", label: "Documents", icon: },
+];
+
+const GROUP_PLATFORM: NavEntry[] = [
+ {
+ id: "infrastructure",
+ label: "Infrastructure",
+ icon: ,
+ },
+ { id: "usage", label: "Usage & Billing", icon: },
+ { id: "docs", label: "Developer Docs", icon: },
+];
+
+function StirlingMark() {
+ // The Stirling brand mark — two stacked parallelograms in the brand blues.
+ return (
+
+
+
+
+ );
+}
+
+function UsageFooter() {
+ const { tier } = useTier();
+ // Read the same endpoint Home's KPI strip uses so the doc count here can't
+ // drift from the headline figure. The first KPI is always the doc total.
+ const { data: kpis, loading } = useAsync(
+ () => fetchHomeKpis(tier),
+ [tier],
+ );
+ const docs = loading ? undefined : kpis?.[0]?.value;
+
+ if (tier === "free") {
+ // The free doc KPI is formatted "used / cap"; parse it for the meter.
+ const [used, cap] =
+ typeof docs === "string"
+ ? docs.split("/").map((s) => Number(s.replace(/[^\d]/g, "")))
+ : [];
+ const pct = used && cap ? (used / cap) * 100 : 0;
+ return (
+
+
+ Docs processed
+ {docs ?? "—"}
+
+
+
+ );
+ }
+
+ const planLabel = tier === "pro" ? "Pay-as-you-go" : "Enterprise Plan";
+
+ return (
+
+
+
+
+ {planLabel}
+
+
+ {docs != null ? `${docs} docs` : "—"}
+
+
+
+ );
+}
+
+export function Sidebar() {
+ const { activeView, setActiveView } = useView();
+
+ function renderGroup(entries: NavEntry[]) {
+ return entries.map((entry) => (
+ setActiveView(id as ViewId)}
+ />
+ ));
+ }
+
+ return (
+
+
+
+ Stirling
+
+
+
+
+
+
+
+ {renderGroup(GROUP_PRIMARY)}
+
+
+
+ {renderGroup(GROUP_OPERATIONAL)}
+
+
+
+ {renderGroup(GROUP_PLATFORM)}
+
+
+
+
+ }
+ isActive={activeView === "settings"}
+ onClick={(id) => setActiveView(id as ViewId)}
+ />
+
+
+
+ );
+}
diff --git a/frontend/portal/src/components/SingleOpRunner.css b/frontend/portal/src/components/SingleOpRunner.css
new file mode 100644
index 000000000..47b998388
--- /dev/null
+++ b/frontend/portal/src/components/SingleOpRunner.css
@@ -0,0 +1,345 @@
+.portal-runner__layout {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr);
+ gap: 1rem;
+ min-height: 26rem;
+}
+
+@media (max-width: 50rem) {
+ .portal-runner__layout {
+ grid-template-columns: 1fr;
+ }
+}
+
+.portal-runner__left,
+.portal-runner__right {
+ display: flex;
+ flex-direction: column;
+ gap: 0.875rem;
+ min-width: 0;
+}
+
+/* Drop zone */
+.portal-runner__drop {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.625rem;
+ padding: 1.25rem;
+ border: 1.5px dashed var(--color-border-input);
+ border-radius: var(--radius-md);
+ background: var(--color-bg-subtle);
+ color: var(--color-text-3);
+ text-align: center;
+ transition:
+ border-color var(--motion-fast),
+ background var(--motion-fast);
+}
+
+.portal-runner__drop.is-armed,
+.portal-runner__drop:hover {
+ border-color: var(--color-blue);
+ background: var(--color-blue-light);
+ color: var(--color-blue);
+}
+
+.portal-runner__drop.has-file {
+ border-color: var(--color-green);
+ background: var(--color-green-light);
+ color: var(--color-text-2);
+}
+
+.portal-runner__drop-icon {
+ color: var(--color-text-4);
+}
+
+.portal-runner__drop.is-armed .portal-runner__drop-icon,
+.portal-runner__drop.has-file .portal-runner__drop-icon {
+ color: inherit;
+}
+
+.portal-runner__drop-text {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.125rem;
+ font-size: 0.8125rem;
+}
+
+.portal-runner__drop-text strong {
+ font-size: 0.875rem;
+ color: var(--color-text-1);
+ font-family: var(--font-mono);
+}
+
+.portal-runner__drop-text span {
+ font-size: 0.75rem;
+ color: var(--color-text-4);
+}
+
+.portal-runner__sample-btn {
+ font-size: 0.75rem;
+ font-weight: 500;
+ color: var(--color-blue);
+ padding: 0.25rem 0.5rem;
+ border-radius: var(--radius-sm);
+ transition: background var(--motion-fast);
+}
+
+.portal-runner__sample-btn:hover {
+ background: var(--color-bg-hover);
+}
+
+/* Op picker */
+.portal-runner__section-title {
+ font-size: 0.6875rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--color-section-label);
+ margin-bottom: 0.5rem;
+}
+
+.portal-runner__ops {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 0.375rem;
+}
+
+.portal-runner__op {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.125rem;
+ padding: 0.5rem 0.625rem;
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ text-align: left;
+ transition:
+ border-color var(--motion-fast),
+ background var(--motion-fast);
+}
+
+.portal-runner__op:hover {
+ border-color: var(--color-border-hover);
+ background: var(--color-bg-hover);
+}
+
+.portal-runner__op.is-selected {
+ border-color: var(--color-blue);
+ background: var(--color-blue-light);
+}
+
+.portal-runner__op[data-accent="purple"].is-selected {
+ border-color: var(--color-purple);
+ background: var(--color-purple-light);
+}
+.portal-runner__op[data-accent="green"].is-selected {
+ border-color: var(--color-green);
+ background: var(--color-green-light);
+}
+.portal-runner__op[data-accent="amber"].is-selected {
+ border-color: var(--color-amber);
+ background: var(--color-amber-light);
+}
+.portal-runner__op[data-accent="red"].is-selected {
+ border-color: var(--color-red);
+ background: var(--color-red-light);
+}
+
+.portal-runner__op-label {
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: var(--color-text-1);
+}
+
+.portal-runner__op-endpoint {
+ font-family: var(--font-mono);
+ font-size: 0.6875rem;
+ color: var(--color-text-4);
+}
+
+.portal-runner__op-blurb {
+ font-size: 0.6875rem;
+ color: var(--color-text-3);
+ line-height: 1.35;
+}
+
+.portal-runner__op--skeleton {
+ display: flex;
+ flex-direction: column;
+ gap: 0.375rem;
+ cursor: default;
+}
+.portal-runner__op--skeleton:hover {
+ border-color: var(--color-border-light);
+ background: transparent;
+}
+
+/* Right column states */
+.portal-runner__right {
+ border: 1px solid var(--color-border-light);
+ border-radius: var(--radius-md);
+ background: var(--color-bg-subtle);
+ padding: 1.125rem;
+ overflow: hidden;
+ min-height: 22rem;
+}
+
+.portal-runner__hint {
+ color: var(--color-text-3);
+}
+
+.portal-runner__hint-eyebrow {
+ font-size: 0.6875rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--color-blue);
+ margin-bottom: 0.5rem;
+}
+
+.portal-runner__hint h3 {
+ margin: 0 0 0.5rem;
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--color-text-1);
+}
+
+.portal-runner__hint h3 code {
+ font-family: var(--font-mono);
+ font-size: 0.9375rem;
+ color: var(--color-blue);
+ background: var(--color-blue-light);
+ padding: 0 0.25rem;
+ border-radius: var(--radius-xs);
+}
+
+.portal-runner__hint p {
+ margin: 0;
+ font-size: 0.8125rem;
+ line-height: 1.5;
+}
+
+.portal-runner__hint p code {
+ font-family: var(--font-mono);
+ font-size: 0.75rem;
+ color: var(--color-text-2);
+ background: var(--color-bg-muted);
+ padding: 0 0.25rem;
+ border-radius: var(--radius-xs);
+}
+
+.portal-runner__hint kbd {
+ font-family: var(--font-mono);
+ font-size: 0.6875rem;
+ background: var(--color-bg-muted);
+ padding: 0.0625rem 0.3125rem;
+ border-radius: var(--radius-xs);
+ border: 1px solid var(--color-border);
+}
+
+/* Running state */
+.portal-runner__running {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ padding: 1rem;
+ background: var(--color-blue-light);
+ border: 1px solid var(--color-blue-border);
+ border-radius: var(--radius-md);
+ color: var(--color-blue);
+}
+
+.portal-runner__running-title {
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: var(--color-text-1);
+ margin-bottom: 0.25rem;
+}
+
+.portal-runner__running code {
+ font-family: var(--font-mono);
+ font-size: 0.75rem;
+}
+
+.portal-runner__spinner,
+.portal-runner__spinner-lg {
+ border-radius: 50%;
+ border: 2px solid currentColor;
+ border-right-color: transparent;
+ animation: spin 0.7s linear infinite;
+ flex-shrink: 0;
+}
+
+.portal-runner__spinner {
+ width: 0.875rem;
+ height: 0.875rem;
+}
+
+.portal-runner__spinner-lg {
+ width: 1.5rem;
+ height: 1.5rem;
+}
+
+/* Done state */
+.portal-runner__result {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.portal-runner__result-head {
+ display: flex;
+ align-items: center;
+ gap: 0.625rem;
+ padding: 0.5rem 0.75rem;
+ background: var(--code-bg-header);
+ border-bottom: 1px solid var(--code-border);
+ border-radius: var(--radius-md) var(--radius-md) 0 0;
+ color: var(--code-muted);
+ font-family: var(--font-mono);
+ font-size: 0.75rem;
+}
+
+.portal-runner__result-head code {
+ flex: 1;
+ font-family: inherit;
+ color: var(--code-keyword);
+}
+
+.portal-runner__result-meta {
+ font-size: 0.6875rem;
+ color: var(--code-dim);
+}
+
+.portal-runner__result-code {
+ margin: 0;
+ padding: 0.875rem 1rem;
+ background: var(--code-bg);
+ color: var(--code-text);
+ font-family: var(--font-mono);
+ font-size: 0.75rem;
+ line-height: 1.6;
+ border-radius: 0 0 var(--radius-md) var(--radius-md);
+ overflow: auto;
+ white-space: pre;
+ max-height: 22rem;
+}
+
+/* Footer */
+.portal-runner__footer-status {
+ margin-right: auto;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.75rem;
+ color: var(--color-text-4);
+ font-family: var(--font-mono);
+}
+
+.portal-runner__footer-status code {
+ font-family: inherit;
+}
diff --git a/frontend/portal/src/components/SingleOpRunner.stories.tsx b/frontend/portal/src/components/SingleOpRunner.stories.tsx
new file mode 100644
index 000000000..7f696d194
--- /dev/null
+++ b/frontend/portal/src/components/SingleOpRunner.stories.tsx
@@ -0,0 +1,53 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { http, HttpResponse, delay } from "msw";
+import { SingleOpRunner } from "@portal/components/SingleOpRunner";
+
+const meta: Meta = {
+ title: "Portal/Home/SingleOpRunner",
+ component: SingleOpRunner,
+ parameters: { layout: "fullscreen" },
+ args: { open: true, onClose: () => console.log("close") },
+ decorators: [
+ (S) => (
+
+
+
+ ),
+ ],
+};
+export default meta;
+type Story = StoryObj;
+
+export const Idle: Story = {};
+
+export const PreSelectedOp: Story = {
+ args: { initialOpId: "redact" },
+};
+
+export const RunFailsWithUnknownOp: Story = {
+ parameters: {
+ msw: {
+ handlers: [
+ http.post("/v1/ops/:opId/run", () =>
+ HttpResponse.json({ error: "Unknown op" }, { status: 404 }),
+ ),
+ ],
+ },
+ },
+};
+
+export const SlowOp: Story = {
+ parameters: {
+ msw: {
+ handlers: [
+ http.post("/v1/ops/:opId/run", async () => {
+ await delay(4000);
+ return HttpResponse.json({
+ result: { schema: "demo", note: "took its time" },
+ durationMs: 4000,
+ });
+ }),
+ ],
+ },
+ },
+};
diff --git a/frontend/portal/src/components/SingleOpRunner.tsx b/frontend/portal/src/components/SingleOpRunner.tsx
new file mode 100644
index 000000000..e383652c4
--- /dev/null
+++ b/frontend/portal/src/components/SingleOpRunner.tsx
@@ -0,0 +1,355 @@
+import { useCallback, useEffect, useMemo, useState } from "react";
+import {
+ Button,
+ EmptyState,
+ Modal,
+ Skeleton,
+ StatusBadge,
+} from "@shared/components";
+import { useView } from "@portal/contexts/ViewContext";
+import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
+import {
+ fetchFeaturedOps,
+ runSingleOp,
+ type FeaturedOp,
+ type OpResultMap,
+} from "@portal/api/ops";
+import "@portal/components/SingleOpRunner.css";
+
+type Phase = "idle" | "running" | "done" | "error";
+
+interface SingleOpRunnerProps {
+ open: boolean;
+ onClose: () => void;
+ /** Optional pre-selected op id (e.g. carousel deep-link). */
+ initialOpId?: string;
+}
+
+const SAMPLE_DOCS = [
+ "certificate-of-insurance.pdf",
+ "loss-run-2025.pdf",
+ "invoice-acme-corp-q1.pdf",
+ "prior-auth-cigna-12471.pdf",
+ "contract-acme-msa-v3.pdf",
+];
+
+interface RunResult {
+ result: OpResultMap;
+ durationMs: number;
+ opId: string;
+}
+
+export function SingleOpRunner({
+ open,
+ onClose,
+ initialOpId,
+}: SingleOpRunnerProps) {
+ const { setActiveView } = useView();
+ const opsState = useAsync(() => fetchFeaturedOps(), []);
+ const { data: ops } = opsState;
+ const { isLoading: opsIsLoading, isEmpty: opsIsEmpty } =
+ useSectionFlags(opsState);
+
+ const [selectedOpId, setSelectedOpId] = useState(
+ initialOpId ?? null,
+ );
+ const [phase, setPhase] = useState("idle");
+ const [sample, setSample] = useState(null);
+ const [dragOver, setDragOver] = useState(false);
+ const [runResult, setRunResult] = useState(null);
+ const [errorMsg, setErrorMsg] = useState(null);
+
+ // Default selection once the catalogue loads.
+ useEffect(() => {
+ if (!selectedOpId && ops && ops.length > 0) {
+ setSelectedOpId(ops[0].id);
+ }
+ }, [ops, selectedOpId]);
+
+ const selectedOp = useMemo(
+ () => ops?.find((o) => o.id === selectedOpId) ?? null,
+ [ops, selectedOpId],
+ );
+
+ useEffect(() => {
+ if (open) {
+ setSelectedOpId(initialOpId ?? ops?.[0]?.id ?? null);
+ setPhase("idle");
+ setSample(null);
+ setRunResult(null);
+ setErrorMsg(null);
+ }
+ // ops is intentionally not in deps — we don't want to reset state when
+ // the catalogue arrives while the modal is open.
+ }, [open, initialOpId]);
+
+ const pickSample = useCallback(() => {
+ if (!ops) return;
+ const idx = ops.findIndex((o) => o.id === selectedOpId);
+ const safeIdx = idx < 0 ? 0 : idx;
+ setSample(SAMPLE_DOCS[safeIdx % SAMPLE_DOCS.length]);
+ }, [ops, selectedOpId]);
+
+ async function run() {
+ if (!selectedOp) return;
+ let activeSample = sample;
+ if (!activeSample) {
+ pickSample();
+ // pickSample updates state; use the synchronously-derived value for the
+ // call so we don't race.
+ const idx = ops?.findIndex((o) => o.id === selectedOpId) ?? 0;
+ activeSample = SAMPLE_DOCS[idx % SAMPLE_DOCS.length];
+ }
+ setPhase("running");
+ setErrorMsg(null);
+ try {
+ const res = await runSingleOp(selectedOp.id, activeSample);
+ setRunResult({ ...res, opId: selectedOp.id });
+ setPhase("done");
+ } catch (err) {
+ setPhase("error");
+ setErrorMsg(err instanceof Error ? err.message : String(err));
+ }
+ }
+
+ function reset() {
+ setPhase("idle");
+ setRunResult(null);
+ setErrorMsg(null);
+ }
+
+ function buildPipelineWithOp() {
+ onClose();
+ setActiveView("pipelines");
+ }
+
+ function onDragOver(e: React.DragEvent) {
+ e.preventDefault();
+ setDragOver(true);
+ }
+ function onDragLeave() {
+ setDragOver(false);
+ }
+ function onDrop(e: React.DragEvent) {
+ e.preventDefault();
+ setDragOver(false);
+ const file = e.dataTransfer.files[0];
+ if (file) setSample(file.name);
+ else pickSample();
+ }
+
+ return (
+
+
+ {phase === "running" && selectedOp && (
+ <>
+
+ POST {selectedOp.endpoint}
+ >
+ )}
+ {phase === "done" && (
+
+ 200 OK
+
+ )}
+ {phase === "error" && (
+
+ {errorMsg ?? "Failed"}
+
+ )}
+
+
+ Close
+
+ {phase === "done" ? (
+ <>
+
+ Run again
+
+ →}
+ >
+ Open the pipeline builder
+
+ >
+ ) : (
+ →}
+ >
+ {phase === "running" ? "Running…" : "Run operation"}
+
+ )}
+ >
+ }
+ >
+
+ {/* Left column: file + op picker */}
+
+
+
+
+ {sample ? (
+ <>
+ {sample}
+ Drop again or pick another sample to replace.
+ >
+ ) : (
+ <>
+ Drop a PDF here
+ or use a sample document.
+ >
+ )}
+
+
+ {sample ? "Pick another sample" : "Use a sample"}
+
+
+
+
+
Featured ops
+
+ {opsIsLoading &&
+ Array.from({ length: 4 }).map((_, i) => (
+
+
+
+
+
+ ))}
+ {opsIsEmpty && (
+
+ )}
+ {ops?.map((op) => (
+
{
+ setSelectedOpId(op.id);
+ if (phase === "done") {
+ setPhase("idle");
+ setRunResult(null);
+ }
+ }}
+ data-accent={op.accent}
+ >
+ {op.label}
+
+ {op.endpoint}
+
+ {op.blurb}
+
+ ))}
+
+
+
+
+ {/* Right column: state-driven result panel */}
+
+ {phase === "idle" && selectedOp && (
+
+
Ready
+
+ Run {selectedOp.label} on{" "}
+ {sample ?? "a sample"}
+
+
+ {selectedOp.blurb}. Press Run operation to invoke{" "}
+ POST {selectedOp.endpoint}.
+
+
+ )}
+ {phase === "running" && selectedOp && (
+
+
+
+
+ Running {selectedOp.label}…
+
+
POST {selectedOp.endpoint}
+
+
+ )}
+ {phase === "done" && selectedOp && runResult && (
+
+
+
+ Completed
+
+ POST {selectedOp.endpoint}
+
+ {runResult.durationMs} ms
+
+
+
+ {JSON.stringify(runResult.result, null, 2)}
+
+
+ )}
+ {phase === "error" && (
+
+
+ Failed
+
+
The operation didn’t complete
+
{errorMsg ?? "Unknown error"}
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/portal/src/components/UsageAreaChart.css b/frontend/portal/src/components/UsageAreaChart.css
new file mode 100644
index 000000000..c6d326c50
--- /dev/null
+++ b/frontend/portal/src/components/UsageAreaChart.css
@@ -0,0 +1,115 @@
+.portal-chart {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ padding: 1.125rem 1.25rem;
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+}
+
+.portal-chart__head {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 0.75rem;
+}
+
+.portal-chart__label {
+ font-size: 0.75rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--color-section-label);
+}
+
+.portal-chart__value {
+ margin-top: 0.125rem;
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: var(--color-text-1);
+ font-variant-numeric: tabular-nums;
+}
+
+.portal-chart__delta {
+ font-size: 0.75rem;
+ font-weight: 500;
+ padding: 0.1875rem 0.5rem;
+ border-radius: var(--radius-pill);
+}
+
+.portal-chart__delta.is-up {
+ color: var(--color-green-dark);
+ background: var(--color-green-light);
+}
+
+.portal-chart__delta.is-down {
+ color: var(--color-red);
+ background: var(--color-red-light);
+}
+
+.portal-chart__svg {
+ width: 100%;
+ height: 15rem;
+ display: block;
+ overflow: visible;
+}
+
+.portal-chart__gridline {
+ stroke: var(--color-border-light);
+ stroke-width: 1;
+ stroke-dasharray: 3 3;
+}
+
+.portal-chart__axis-label {
+ font-family: var(--font-sans);
+ font-size: 10.5px;
+ fill: var(--color-text-5);
+}
+
+.portal-chart__line {
+ fill: none;
+ stroke: var(--color-blue);
+ stroke-width: 1.75;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+.portal-chart__scrub {
+ stroke: var(--color-text-5);
+ stroke-width: 1;
+ stroke-dasharray: 2 3;
+ opacity: 0.6;
+}
+
+.portal-chart__dot {
+ fill: var(--color-blue);
+ stroke: var(--color-surface);
+ stroke-width: 2;
+}
+
+.portal-chart__tooltip {
+ position: absolute;
+ transform: translateX(-50%);
+ top: 4rem;
+ padding: 0.4375rem 0.625rem;
+ background: var(--color-tooltip-bg);
+ color: var(--color-tooltip-text);
+ border-radius: var(--radius-sm);
+ font-size: 0.75rem;
+ white-space: nowrap;
+ pointer-events: none;
+ box-shadow: var(--shadow-md);
+ z-index: 2;
+}
+
+.portal-chart__tooltip-date {
+ font-size: 0.6875rem;
+ opacity: 0.7;
+}
+
+.portal-chart__tooltip-value {
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+}
diff --git a/frontend/portal/src/components/UsageAreaChart.stories.tsx b/frontend/portal/src/components/UsageAreaChart.stories.tsx
new file mode 100644
index 000000000..5c1f3e3dd
--- /dev/null
+++ b/frontend/portal/src/components/UsageAreaChart.stories.tsx
@@ -0,0 +1,53 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { UsageAreaChart } from "@portal/components/UsageAreaChart";
+import { buildUsageSeries } from "@portal/mocks/home";
+
+const meta: Meta = {
+ title: "Portal/Home/UsageAreaChart",
+ component: UsageAreaChart,
+ parameters: { layout: "padded" },
+ decorators: [
+ (S) => (
+
+
+
+ ),
+ ],
+};
+export default meta;
+type Story = StoryObj;
+
+const series = buildUsageSeries();
+const total = series.reduce((sum, p) => sum + p.value, 0);
+
+export const Default: Story = {
+ args: { data: series, totalValue: total.toLocaleString(), deltaPct: 0.12 },
+};
+
+export const NegativeDelta: Story = {
+ args: { data: series, totalValue: total.toLocaleString(), deltaPct: -0.08 },
+};
+
+export const NoDelta: Story = {
+ args: { data: series },
+};
+
+export const Flat: Story = {
+ args: {
+ data: series.map((p) => ({ ...p, value: 1200 })),
+ totalValue: "36,000",
+ },
+};
+
+export const HighVolatility: Story = {
+ args: {
+ data: series.map((p, i) => ({
+ ...p,
+ value: Math.round(p.value * (1 + Math.sin(i) * 0.6)),
+ })),
+ },
+};
+
+export const Empty: Story = {
+ args: { data: [], totalValue: "—" },
+};
diff --git a/frontend/portal/src/components/UsageAreaChart.tsx b/frontend/portal/src/components/UsageAreaChart.tsx
new file mode 100644
index 000000000..778932e9b
--- /dev/null
+++ b/frontend/portal/src/components/UsageAreaChart.tsx
@@ -0,0 +1,278 @@
+import { useMemo, useRef, useState, type KeyboardEvent } from "react";
+import type { UsagePoint } from "@portal/api/home";
+import "@portal/components/UsageAreaChart.css";
+
+interface UsageAreaChartProps {
+ data: UsagePoint[];
+ /** Headline metric shown above the chart (e.g. total 30d, current value). */
+ totalLabel?: string;
+ totalValue?: string;
+ deltaPct?: number;
+}
+
+const PADDING = { top: 16, right: 20, bottom: 28, left: 32 } as const;
+const VIEW = { width: 800, height: 240 } as const;
+
+function formatTick(date: string): string {
+ return new Date(date).toLocaleDateString(undefined, {
+ month: "short",
+ day: "numeric",
+ });
+}
+
+function formatNumber(value: number): string {
+ if (value >= 1000) {
+ return `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`;
+ }
+ return value.toLocaleString();
+}
+
+export function UsageAreaChart({
+ data,
+ totalLabel = "Docs processed · last 30 days",
+ totalValue,
+ deltaPct,
+}: UsageAreaChartProps) {
+ const [hoverIndex, setHoverIndex] = useState(null);
+ const svgRef = useRef(null);
+
+ const { points, areaPath, linePath, yMax, yTicks, xTickIndices } =
+ useMemo(() => {
+ if (data.length === 0) {
+ return {
+ points: [] as Array<{ x: number; y: number; raw: UsagePoint }>,
+ areaPath: "",
+ linePath: "",
+ yMax: 0,
+ yTicks: [] as number[],
+ xTickIndices: [] as number[],
+ };
+ }
+ const max = Math.max(...data.map((d) => d.value));
+ // Round yMax up to a "nice" number for ticks. Floor at 500 so an
+ // all-zero series doesn't divide by zero (which would NaN every point).
+ const niceMax = Math.max(Math.ceil(max / 500) * 500, 500);
+ const yTicksCalc = [0, niceMax * 0.5, niceMax];
+
+ const innerW = VIEW.width - PADDING.left - PADDING.right;
+ const innerH = VIEW.height - PADDING.top - PADDING.bottom;
+ const xStep = innerW / Math.max(data.length - 1, 1);
+
+ const pts = data.map((raw, i) => ({
+ x: PADDING.left + i * xStep,
+ y: PADDING.top + innerH - (raw.value / niceMax) * innerH,
+ raw,
+ }));
+
+ const linePathStr = pts
+ .map(
+ (p, i) =>
+ `${i === 0 ? "M" : "L"} ${p.x.toFixed(2)} ${p.y.toFixed(2)}`,
+ )
+ .join(" ");
+ const baseY = PADDING.top + innerH;
+ const areaPathStr = `${linePathStr} L ${pts[pts.length - 1].x.toFixed(2)} ${baseY} L ${pts[0].x.toFixed(2)} ${baseY} Z`;
+
+ // X ticks: 5 evenly spread.
+ const xIdx: number[] = [];
+ const tickCount = 5;
+ for (let t = 0; t < tickCount; t++) {
+ xIdx.push(Math.round((t * (data.length - 1)) / (tickCount - 1)));
+ }
+
+ return {
+ points: pts,
+ areaPath: areaPathStr,
+ linePath: linePathStr,
+ yMax: niceMax,
+ yTicks: yTicksCalc,
+ xTickIndices: xIdx,
+ };
+ }, [data]);
+
+ function onMouseMove(e: React.MouseEvent) {
+ if (!svgRef.current || points.length === 0) return;
+ const rect = svgRef.current.getBoundingClientRect();
+ const xRatio = (e.clientX - rect.left) / rect.width;
+ const svgX = xRatio * VIEW.width;
+ // Find nearest point
+ let nearestIdx = 0;
+ let nearestDist = Infinity;
+ for (let i = 0; i < points.length; i++) {
+ const d = Math.abs(points[i].x - svgX);
+ if (d < nearestDist) {
+ nearestDist = d;
+ nearestIdx = i;
+ }
+ }
+ setHoverIndex(nearestIdx);
+ }
+
+ const hovered = hoverIndex !== null ? points[hoverIndex] : null;
+
+ function onKeyDown(e: KeyboardEvent) {
+ if (points.length === 0) return;
+ if (e.key === "ArrowRight") {
+ e.preventDefault();
+ setHoverIndex((idx) =>
+ idx === null ? 0 : Math.min(points.length - 1, idx + 1),
+ );
+ } else if (e.key === "ArrowLeft") {
+ e.preventDefault();
+ setHoverIndex((idx) =>
+ idx === null ? points.length - 1 : Math.max(0, idx - 1),
+ );
+ } else if (e.key === "Home") {
+ e.preventDefault();
+ setHoverIndex(0);
+ } else if (e.key === "End") {
+ e.preventDefault();
+ setHoverIndex(points.length - 1);
+ } else if (e.key === "Escape") {
+ setHoverIndex(null);
+ }
+ }
+
+ const displayTotal =
+ totalValue ?? data.reduce((sum, p) => sum + p.value, 0).toLocaleString();
+
+ return (
+
+
+
+
0 ? 0 : -1}
+ onMouseMove={onMouseMove}
+ onMouseLeave={() => setHoverIndex(null)}
+ onKeyDown={onKeyDown}
+ onBlur={() => setHoverIndex(null)}
+ >
+
+
+
+
+
+
+
+ {/* Y gridlines + labels */}
+ {yTicks.map((tick) => {
+ const innerH = VIEW.height - PADDING.top - PADDING.bottom;
+ const y = PADDING.top + innerH - (tick / yMax) * innerH;
+ return (
+
+
+
+ {formatNumber(tick)}
+
+
+ );
+ })}
+
+ {/* Area + line */}
+
+
+
+ {/* X ticks */}
+ {xTickIndices.map((idx) => {
+ const p = points[idx];
+ if (!p) return null;
+ return (
+
+ {formatTick(p.raw.date)}
+
+ );
+ })}
+
+ {/* Hover scrub + dot + tooltip */}
+ {hovered && (
+ <>
+
+
+ >
+ )}
+
+
+ {hovered && (
+
+
+ {new Date(hovered.raw.date).toLocaleDateString(undefined, {
+ weekday: "short",
+ month: "short",
+ day: "numeric",
+ })}
+
+
+ {hovered.raw.value.toLocaleString()} docs
+
+
+ )}
+
+ {hovered
+ ? `${new Date(hovered.raw.date).toLocaleDateString(undefined, {
+ weekday: "short",
+ month: "short",
+ day: "numeric",
+ })}: ${hovered.raw.value.toLocaleString()} docs`
+ : ""}
+
+
+ );
+}
diff --git a/frontend/portal/src/components/WelcomeCarousel.css b/frontend/portal/src/components/WelcomeCarousel.css
new file mode 100644
index 000000000..49a7d5fcc
--- /dev/null
+++ b/frontend/portal/src/components/WelcomeCarousel.css
@@ -0,0 +1,234 @@
+.portal-carousel {
+ position: relative;
+ overflow: hidden;
+ padding: 2rem;
+ border-radius: var(--radius-xl);
+ background: var(--grad-banner);
+ border: 1px solid var(--color-border);
+ isolation: isolate;
+}
+
+.portal-carousel__inner {
+ position: relative;
+ z-index: 1;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 20rem);
+ gap: 2rem;
+ align-items: center;
+ animation: fadeInUp var(--motion-enter) both;
+}
+
+@media (max-width: 50rem) {
+ .portal-carousel__inner {
+ grid-template-columns: 1fr;
+ }
+}
+
+.portal-carousel__text {
+ min-width: 0;
+}
+
+.portal-carousel__eyebrow {
+ font-size: 0.75rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--color-blue);
+ margin-bottom: 0.5rem;
+}
+
+.portal-carousel__title {
+ margin: 0 0 0.75rem;
+ font-size: 1.75rem;
+ font-weight: 700;
+ line-height: 1.15;
+ color: var(--color-text-1);
+}
+
+.portal-carousel__sub {
+ margin: 0 0 1.25rem;
+ font-size: 0.9375rem;
+ line-height: 1.55;
+ color: var(--color-text-3);
+ max-width: 36rem;
+}
+
+.portal-carousel__cta {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+/* Ornament container */
+.portal-carousel__ornament {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+/* Doc card ornament (slide 0) */
+.portal-carousel__doc {
+ width: 100%;
+ padding: 1rem;
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-md);
+}
+.portal-carousel__doc-title {
+ margin-top: 0.5rem;
+ font-size: 0.9375rem;
+ font-weight: 600;
+ color: var(--color-text-1);
+}
+.portal-carousel__doc-sub {
+ margin-top: 0.125rem;
+ font-size: 0.75rem;
+ color: var(--color-text-4);
+}
+.portal-carousel__doc-meta {
+ margin-top: 0.75rem;
+ font-size: 0.75rem;
+ color: var(--color-text-4);
+ display: flex;
+ gap: 0.375rem;
+ flex-wrap: wrap;
+}
+
+/* Code ornament (slide 1) */
+.portal-carousel__code {
+ width: 100%;
+ margin: 0;
+ padding: 0.875rem 1rem;
+ background: var(--code-bg);
+ color: var(--code-text);
+ border: 1px solid var(--code-border);
+ border-radius: var(--radius-md);
+ font-family: var(--font-mono);
+ font-size: 0.75rem;
+ line-height: 1.65;
+ white-space: pre-wrap;
+ word-break: break-word;
+ box-shadow: var(--shadow-lg);
+}
+.portal-carousel__code-method {
+ color: var(--code-fn);
+ font-weight: 600;
+}
+.portal-carousel__code-path {
+ color: var(--code-keyword);
+}
+.portal-carousel__code-key {
+ color: var(--code-property);
+}
+.portal-carousel__code-string {
+ color: var(--code-string);
+}
+
+/* Eval ornament (slide 2) */
+.portal-carousel__eval {
+ width: 100%;
+ padding: 1rem;
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-md);
+}
+.portal-carousel__eval-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+}
+.portal-carousel__eval-title {
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: var(--color-text-1);
+}
+.portal-carousel__eval-score {
+ font-size: 1.125rem;
+ font-weight: 700;
+ color: var(--color-green);
+}
+.portal-carousel__eval-sub {
+ font-size: 0.75rem;
+ color: var(--color-text-4);
+ margin-top: 0.125rem;
+}
+.portal-carousel__eval-bar {
+ margin-top: 0.75rem;
+ height: 0.375rem;
+ background: var(--color-bg-muted);
+ border-radius: var(--radius-pill);
+ overflow: hidden;
+}
+.portal-carousel__eval-fill {
+ height: 100%;
+ background: var(--grad-green-btn);
+}
+.portal-carousel__eval-meta {
+ margin-top: 0.625rem;
+ display: flex;
+ justify-content: space-between;
+ font-size: 0.6875rem;
+ color: var(--color-text-4);
+}
+
+/* Decoration blobs */
+.portal-carousel__decor {
+ position: absolute;
+ inset: 0;
+ z-index: 0;
+ pointer-events: none;
+}
+.portal-carousel__blob {
+ position: absolute;
+ border-radius: 50%;
+ filter: blur(2.5rem);
+}
+.portal-carousel__blob--1 {
+ top: -3rem;
+ right: -2rem;
+ width: 16rem;
+ height: 16rem;
+ background: radial-gradient(circle, var(--color-purple), transparent 70%);
+ opacity: 0.4;
+}
+.portal-carousel__blob--2 {
+ bottom: -4rem;
+ right: 8rem;
+ width: 12rem;
+ height: 12rem;
+ background: radial-gradient(circle, var(--color-blue), transparent 70%);
+ opacity: 0.3;
+}
+
+/* Pagination dots */
+.portal-carousel__dots {
+ position: absolute;
+ bottom: 0.875rem;
+ left: 50%;
+ transform: translateX(-50%);
+ display: flex;
+ gap: 0.375rem;
+ z-index: 2;
+}
+.portal-carousel__dot {
+ width: 0.4375rem;
+ height: 0.4375rem;
+ border-radius: 50%;
+ background: var(--color-text-5);
+ opacity: 0.4;
+ transition:
+ opacity var(--motion-fast),
+ background var(--motion-fast),
+ width var(--motion-fast);
+}
+.portal-carousel__dot:hover {
+ opacity: 0.7;
+}
+.portal-carousel__dot.is-active {
+ width: 1rem;
+ border-radius: var(--radius-pill);
+ background: var(--color-blue);
+ opacity: 1;
+}
diff --git a/frontend/portal/src/components/WelcomeCarousel.stories.tsx b/frontend/portal/src/components/WelcomeCarousel.stories.tsx
new file mode 100644
index 000000000..1db5790fc
--- /dev/null
+++ b/frontend/portal/src/components/WelcomeCarousel.stories.tsx
@@ -0,0 +1,20 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { WelcomeCarousel } from "@portal/components/WelcomeCarousel";
+
+const meta: Meta = {
+ title: "Portal/Home/WelcomeCarousel",
+ component: WelcomeCarousel,
+ args: { onTryOp: () => console.log("try op") },
+ parameters: { layout: "padded" },
+ decorators: [
+ (S) => (
+
+
+
+ ),
+ ],
+};
+export default meta;
+type Story = StoryObj;
+
+export const AutoRotating: Story = {};
diff --git a/frontend/portal/src/components/WelcomeCarousel.tsx b/frontend/portal/src/components/WelcomeCarousel.tsx
new file mode 100644
index 000000000..e62320085
--- /dev/null
+++ b/frontend/portal/src/components/WelcomeCarousel.tsx
@@ -0,0 +1,215 @@
+import { useEffect, useState, type ReactNode } from "react";
+import { Button, StatusBadge } from "@shared/components";
+import { useView, type ViewId } from "@portal/contexts/ViewContext";
+import "@portal/components/WelcomeCarousel.css";
+
+type SlideAction =
+ | { label: string; target: ViewId }
+ | { label: string; action: "try-op" };
+
+interface Slide {
+ id: string;
+ eyebrow: string;
+ title: string;
+ sub: string;
+ durationMs: number;
+ primary: SlideAction;
+ secondary: SlideAction;
+ ornament: ReactNode;
+}
+
+function EditorOrnament() {
+ return (
+
+
+ Critical
+
+
+ Vulnerability Assessment Report
+
+
CVE-2026-1847 · 12 pages
+
+ signed
+ ·
+ OCR-clean
+ ·
+ schema match 0.97
+
+
+ );
+}
+
+function PlatformOrnament() {
+ return (
+
+ POST {" "}
+ /v1/secure
+ {"\n"}
+ Authorization :{" "}
+ Bearer sk_live_a3f8…
+ {"\n"}
+ file :{" "}
+
+ federal_CUI_contract.pdf
+
+ {"\n"}
+ pipeline :{" "}
+ cui-redact-v2
+
+ );
+}
+
+function AgentOrnament() {
+ return (
+
+
+ KYC Processor
+ 94%
+
+
+ Eval pass rate · 28 test cases
+
+
+
+ 26 passed
+ 2 review
+ MCP · Claude
+
+
+ );
+}
+
+const SLIDES: Slide[] = [
+ {
+ id: "editor",
+ eyebrow: "PDF Editor",
+ title: "The #1 PDF Editor on GitHub",
+ sub: "Annotate, sign, redact, and review locally or in the cloud. Brought to the platform as the credibility anchor of the Stirling control plane.",
+ durationMs: 12000,
+ primary: { label: "Install PDF Editor", target: "editor" },
+ secondary: { label: "Connect an instance", target: "editor" },
+ ornament: ,
+ },
+ {
+ id: "platform",
+ eyebrow: "Platform",
+ title: "PDF Infrastructure for Developers",
+ sub: "Ingest from agents, APIs and connectors. Run composable pipelines with evals and golden sets. Land in a vault with zero-standing-access controls.",
+ durationMs: 8000,
+ primary: { label: "Try a PDF operation", action: "try-op" },
+ secondary: { label: "Get an API key", target: "infrastructure" },
+ ornament: ,
+ },
+ {
+ id: "agents",
+ eyebrow: "AI Agents",
+ title: "PDF Processor for AI Agents",
+ sub: "Wire your agent via MCP, REST or tool definitions. Deterministic operations and guardrails — test with scenarios and evals before you ship.",
+ durationMs: 8000,
+ primary: { label: "Try PDF Processor", target: "sources" },
+ secondary: { label: "View MCP docs", target: "docs" },
+ ornament: ,
+ },
+];
+
+interface WelcomeCarouselProps {
+ /** Called when a slide CTA wants to invoke the single-op runner. */
+ onTryOp: () => void;
+}
+
+export function WelcomeCarousel({ onTryOp }: WelcomeCarouselProps) {
+ const [index, setIndex] = useState(0);
+ const [paused, setPaused] = useState(false);
+ const { setActiveView } = useView();
+
+ function runAction(act: SlideAction) {
+ if ("action" in act && act.action === "try-op") {
+ onTryOp();
+ } else if ("target" in act) {
+ setActiveView(act.target);
+ }
+ }
+
+ useEffect(() => {
+ if (paused) return;
+ const duration = SLIDES[index].durationMs;
+ const t = window.setTimeout(() => {
+ setIndex((i) => (i + 1) % SLIDES.length);
+ }, duration);
+ return () => window.clearTimeout(t);
+ }, [index, paused]);
+
+ const slide = SLIDES[index];
+
+ return (
+ setPaused(true)}
+ onMouseLeave={() => setPaused(false)}
+ onFocus={() => setPaused(true)}
+ onBlur={(e) => {
+ // onBlur bubbles from children; only unpause when focus actually
+ // leaves the carousel, not when tabbing between the dot buttons.
+ if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
+ setPaused(false);
+ }
+ }}
+ aria-label="Stirling product highlights"
+ aria-roledescription="carousel"
+ >
+
+
+
{slide.eyebrow}
+
{slide.title}
+
{slide.sub}
+
+ runAction(slide.primary)}
+ trailingIcon={→ }
+ >
+ {slide.primary.label}
+
+ runAction(slide.secondary)}
+ >
+ {slide.secondary.label}
+
+
+
+
{slide.ornament}
+
+
+
+
+
+ {SLIDES.map((s, i) => (
+ setIndex(i)}
+ />
+ ))}
+
+
+ );
+}
diff --git a/frontend/portal/src/components/icons.tsx b/frontend/portal/src/components/icons.tsx
new file mode 100644
index 000000000..0018850c5
--- /dev/null
+++ b/frontend/portal/src/components/icons.tsx
@@ -0,0 +1,202 @@
+import type { ReactNode } from "react";
+
+interface IconProps {
+ size?: number;
+ className?: string;
+}
+
+function Svg({
+ size = 18,
+ className,
+ children,
+}: IconProps & { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function HomeIcon(props: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+export function EditorIcon(props: IconProps) {
+ return (
+
+
+
+
+
+
+ );
+}
+
+export function SourcesIcon(props: IconProps) {
+ return (
+
+
+
+
+
+
+ );
+}
+
+export function PipelinesIcon(props: IconProps) {
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+export function DocumentsIcon(props: IconProps) {
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+export function InfrastructureIcon(props: IconProps) {
+ return (
+
+
+
+
+
+
+ );
+}
+
+export function UsageIcon(props: IconProps) {
+ return (
+
+
+
+
+
+
+ );
+}
+
+export function DocsIcon(props: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+export function SettingsIcon(props: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+export function SearchIcon(props: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+export function SunIcon(props: IconProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export function MoonIcon(props: IconProps) {
+ return (
+
+
+
+ );
+}
+
+export function BellIcon(props: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+export function ChevronDownIcon(props: IconProps) {
+ return (
+
+
+
+ );
+}
+
+export function SparklesIcon(props: IconProps) {
+ return (
+
+
+
+
+
+ );
+}
+
+export function CloseIcon(props: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+export function SendIcon(props: IconProps) {
+ return (
+
+
+
+
+ );
+}
diff --git a/frontend/portal/src/contexts/ThemeContext.tsx b/frontend/portal/src/contexts/ThemeContext.tsx
new file mode 100644
index 000000000..ad979f5e9
--- /dev/null
+++ b/frontend/portal/src/contexts/ThemeContext.tsx
@@ -0,0 +1,57 @@
+import {
+ createContext,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+ type ReactNode,
+} from "react";
+
+export type Theme = "light" | "dark";
+
+interface ThemeContextValue {
+ theme: Theme;
+ setTheme: (theme: Theme) => void;
+ toggle: () => void;
+}
+
+const ThemeContext = createContext(null);
+
+const STORAGE_KEY = "stirling.portal.theme";
+
+function readInitialTheme(): Theme {
+ if (typeof window === "undefined") return "light";
+ const stored = window.localStorage.getItem(STORAGE_KEY);
+ if (stored === "light" || stored === "dark") return stored;
+ return window.matchMedia("(prefers-color-scheme: dark)").matches
+ ? "dark"
+ : "light";
+}
+
+export function ThemeProvider({ children }: { children: ReactNode }) {
+ const [theme, setTheme] = useState(readInitialTheme);
+
+ useEffect(() => {
+ document.documentElement.setAttribute("data-theme", theme);
+ window.localStorage.setItem(STORAGE_KEY, theme);
+ }, [theme]);
+
+ const value = useMemo(
+ () => ({
+ theme,
+ setTheme,
+ toggle: () => setTheme((t) => (t === "light" ? "dark" : "light")),
+ }),
+ [theme],
+ );
+
+ return (
+ {children}
+ );
+}
+
+export function useTheme(): ThemeContextValue {
+ const v = useContext(ThemeContext);
+ if (!v) throw new Error("useTheme must be used inside ");
+ return v;
+}
diff --git a/frontend/portal/src/contexts/TierContext.tsx b/frontend/portal/src/contexts/TierContext.tsx
new file mode 100644
index 000000000..4b69419a6
--- /dev/null
+++ b/frontend/portal/src/contexts/TierContext.tsx
@@ -0,0 +1,45 @@
+import {
+ createContext,
+ useContext,
+ useMemo,
+ useState,
+ type ReactNode,
+} from "react";
+
+export type Tier = "free" | "pro" | "enterprise";
+
+export interface TierInfo {
+ label: string;
+ dotColor: string;
+}
+
+export const TIER_INFO: Record = {
+ free: { label: "Free Plan", dotColor: "var(--color-text-4)" },
+ pro: { label: "Pay-as-you-go", dotColor: "var(--color-blue)" },
+ enterprise: { label: "Enterprise Plan", dotColor: "var(--color-purple)" },
+};
+
+interface TierContextValue {
+ tier: Tier;
+ setTier: (tier: Tier) => void;
+}
+
+const TierContext = createContext(null);
+
+export function TierProvider({
+ children,
+ initialTier = "pro",
+}: {
+ children: ReactNode;
+ initialTier?: Tier;
+}) {
+ const [tier, setTier] = useState(initialTier);
+ const value = useMemo(() => ({ tier, setTier }), [tier]);
+ return {children} ;
+}
+
+export function useTier(): TierContextValue {
+ const v = useContext(TierContext);
+ if (!v) throw new Error("useTier must be used inside ");
+ return v;
+}
diff --git a/frontend/portal/src/contexts/UIContext.tsx b/frontend/portal/src/contexts/UIContext.tsx
new file mode 100644
index 000000000..a2b5249f7
--- /dev/null
+++ b/frontend/portal/src/contexts/UIContext.tsx
@@ -0,0 +1,49 @@
+import {
+ createContext,
+ useContext,
+ useMemo,
+ useState,
+ type ReactNode,
+} from "react";
+
+interface UIContextValue {
+ searchOpen: boolean;
+ openSearch: () => void;
+ closeSearch: () => void;
+ toggleSearch: () => void;
+
+ assistantOpen: boolean;
+ openAssistant: () => void;
+ closeAssistant: () => void;
+ toggleAssistant: () => void;
+}
+
+const UIContext = createContext(null);
+
+export function UIProvider({ children }: { children: ReactNode }) {
+ const [searchOpen, setSearchOpen] = useState(false);
+ const [assistantOpen, setAssistantOpen] = useState(false);
+
+ const value = useMemo(
+ () => ({
+ searchOpen,
+ openSearch: () => setSearchOpen(true),
+ closeSearch: () => setSearchOpen(false),
+ toggleSearch: () => setSearchOpen((o) => !o),
+
+ assistantOpen,
+ openAssistant: () => setAssistantOpen(true),
+ closeAssistant: () => setAssistantOpen(false),
+ toggleAssistant: () => setAssistantOpen((o) => !o),
+ }),
+ [searchOpen, assistantOpen],
+ );
+
+ return {children} ;
+}
+
+export function useUI(): UIContextValue {
+ const v = useContext(UIContext);
+ if (!v) throw new Error("useUI must be used inside ");
+ return v;
+}
diff --git a/frontend/portal/src/contexts/ViewContext.tsx b/frontend/portal/src/contexts/ViewContext.tsx
new file mode 100644
index 000000000..28aa118e5
--- /dev/null
+++ b/frontend/portal/src/contexts/ViewContext.tsx
@@ -0,0 +1,73 @@
+import { useCallback, useMemo } from "react";
+import { useLocation, useNavigate } from "react-router-dom";
+
+export type ViewId =
+ | "home"
+ | "editor"
+ | "sources"
+ | "pipelines"
+ | "documents"
+ | "infrastructure"
+ | "usage"
+ | "docs"
+ | "settings";
+
+export const VIEW_LABELS: Record = {
+ home: "Home",
+ editor: "Editor",
+ sources: "Sources",
+ pipelines: "Pipelines",
+ documents: "Documents",
+ infrastructure: "Infrastructure",
+ usage: "Usage & Billing",
+ docs: "Developer Docs",
+ settings: "Settings",
+};
+
+export const VIEW_PATHS: Record = {
+ home: "/",
+ editor: "/editor",
+ sources: "/sources",
+ pipelines: "/pipelines",
+ documents: "/documents",
+ infrastructure: "/infrastructure",
+ usage: "/usage",
+ docs: "/docs",
+ settings: "/settings",
+};
+
+const PATH_TO_VIEW: Record = Object.fromEntries(
+ (Object.entries(VIEW_PATHS) as Array<[ViewId, string]>).map(
+ ([view, path]) => [path, view],
+ ),
+);
+
+function deriveActiveView(pathname: string): ViewId {
+ // Exact match first; otherwise treat the first segment as the view.
+ if (PATH_TO_VIEW[pathname]) return PATH_TO_VIEW[pathname];
+ const firstSegment = "/" + pathname.split("/").filter(Boolean)[0];
+ return PATH_TO_VIEW[firstSegment] ?? "home";
+}
+
+interface ViewContextValue {
+ activeView: ViewId;
+ setActiveView: (view: ViewId) => void;
+}
+
+/**
+ * Backwards-compatible facade over react-router. Components keep using
+ * useView()/setActiveView the way they did before; URLs are now real.
+ *
+ * No wrapper exists any more — the router is the provider.
+ * App.tsx wraps its children in .
+ */
+export function useView(): ViewContextValue {
+ const { pathname } = useLocation();
+ const navigate = useNavigate();
+ const activeView = useMemo(() => deriveActiveView(pathname), [pathname]);
+ const setActiveView = useCallback(
+ (view: ViewId) => navigate(VIEW_PATHS[view]),
+ [navigate],
+ );
+ return { activeView, setActiveView };
+}
diff --git a/frontend/portal/src/hooks/useAsync.ts b/frontend/portal/src/hooks/useAsync.ts
new file mode 100644
index 000000000..6837a0860
--- /dev/null
+++ b/frontend/portal/src/hooks/useAsync.ts
@@ -0,0 +1,97 @@
+import { useEffect, useMemo, useState } from "react";
+
+export interface AsyncState {
+ data: T | null;
+ loading: boolean;
+ error: Error | null;
+}
+
+/**
+ * Derived render flags for a panel backed by {@link useAsync}.
+ *
+ * Every async-driven section in the portal renders the same three-state shape:
+ *
+ * - `isLoading` — first load, no data yet → render skeletons
+ * - `isEmpty` — fetch failed OR data is genuinely empty → render
+ * - ready (neither flag set) — data is present → render the real UI
+ *
+ * Error and empty collapse into one branch on purpose: when there's no
+ * backend yet, fetch failures should surface as an empty page rather than
+ * an alarming error banner. The section header always renders regardless of
+ * which branch is active.
+ *
+ * The `ready` state is intentionally NOT exposed as a flag — callers should
+ * gate the ready branch on the actual data (`{events && events.length > 0
+ * && …}`) so TypeScript can narrow `events` from `T[] | null` to `T[]`.
+ */
+export interface SectionFlags {
+ isLoading: boolean;
+ isEmpty: boolean;
+}
+
+export function deriveSectionFlags(state: AsyncState): SectionFlags {
+ const { data, loading, error } = state;
+ const dataIsEmpty =
+ data === null || (Array.isArray(data) && data.length === 0);
+ return {
+ isLoading: loading && data === null,
+ isEmpty: !loading && (error !== null || dataIsEmpty),
+ };
+}
+
+/** Memoised variant for use inside components. */
+export function useSectionFlags(state: AsyncState): SectionFlags {
+ return useMemo(() => deriveSectionFlags(state), [state]);
+}
+
+/**
+ * Lightweight loading-state hook for async functions. Cancels the in-flight
+ * effect on unmount and on dependency change, so race conditions don't
+ * resolve into stale state.
+ *
+ * Intentionally minimal — when we want caching, retries, and revalidation
+ * we'll move this to TanStack Query or similar. For mocked data with sub-
+ * second latency, this is enough.
+ *
+ * @example
+ * const { data, loading } = useAsync(() => fetchDeployedPipelines(), []);
+ * if (loading) return ;
+ * if (!data) return null;
+ */
+export function useAsync(
+ fn: (signal: AbortSignal) => Promise,
+ deps: ReadonlyArray,
+): AsyncState {
+ const [state, setState] = useState>({
+ data: null,
+ loading: true,
+ error: null,
+ });
+
+ useEffect(() => {
+ const controller = new AbortController();
+ let cancelled = false;
+
+ setState((prev) => ({ ...prev, loading: true, error: null }));
+
+ fn(controller.signal)
+ .then((data) => {
+ if (cancelled) return;
+ setState({ data, loading: false, error: null });
+ })
+ .catch((error: unknown) => {
+ if (cancelled || controller.signal.aborted) return;
+ const err = error instanceof Error ? error : new Error(String(error));
+ setState({ data: null, loading: false, error: err });
+ });
+
+ return () => {
+ cancelled = true;
+ controller.abort();
+ };
+ // Callers own the dependency array — `fn` is intentionally not listed
+ // here so the effect only re-runs when the caller asks it to.
+ }, deps);
+
+ return state;
+}
diff --git a/frontend/portal/src/mocks/assistant.ts b/frontend/portal/src/mocks/assistant.ts
new file mode 100644
index 000000000..c5d9ed9ca
--- /dev/null
+++ b/frontend/portal/src/mocks/assistant.ts
@@ -0,0 +1,61 @@
+/** Mock assistant: routing rules + canned replies + suggested prompts. */
+
+export interface AssistantRoute {
+ patterns: readonly RegExp[];
+ reply: string;
+}
+
+export const ASSISTANT_ROUTES: AssistantRoute[] = [
+ {
+ patterns: [/extract/i, /schema/i],
+ reply:
+ "For extraction, point the doc at /v1/ and the schema-aware op infers fields from your typed catalogue. Confidence-check gates anything under 0.85 by default — adjust the threshold in the composer's per-op config.",
+ },
+ {
+ patterns: [/mcp/i, /\bagent\b/i, /connect.*agent/i],
+ reply:
+ "MCP wiring lives under Sources → Add → Agent. You'll get an MCP URL of the shape mcp://stirling.com/agents/{id} plus scoped credentials. Scenarios drive the eval set the agent must pass before it can take real traffic.",
+ },
+ {
+ patterns: [/redact/i, /pii/i],
+ reply:
+ "The redact op runs PII enforcement across SSN / DOB / accounts / contacts / names / addresses by default. Toggle categories in the composer; auto-detected PII supports blackout, replace, and mask styles.",
+ },
+ {
+ patterns: [/deploy/i, /docker/i, /self.host/i],
+ reply:
+ "Self-host via the docker variant from /editor → Self-Hosted. Air-gapped tarballs ship on Enterprise. Helm charts cover us-east-1, eu-west-1, ap-southeast-1; multi-region requires the Enterprise plan.",
+ },
+ {
+ patterns: [/rate.*limit/i, /quota/i, /throttl/i],
+ reply:
+ "Free is 4.2 req/min. Pro scales to 342 req/min with burst tolerance. Enterprise lifts to 2.4k req/min with per-region pools. The 429 response carries Retry-After in seconds.",
+ },
+ {
+ patterns: [/webhook/i, /callback/i],
+ reply:
+ "Outbound webhooks deliver via 3× exponential backoff. We sign every payload with HMAC-SHA256 — verify via the X-Stirling-Signature header. Inbound webhooks support Bearer, HMAC, or mTLS auth.",
+ },
+];
+
+export const ASSISTANT_DEFAULT_REPLY =
+ "I can help with extraction, MCP / agents, redaction, deployment, rate limits, and webhooks. Try one of the suggestions, or ask something specific.";
+
+export const ASSISTANT_SUGGESTIONS: readonly string[] = [
+ "How do I wire an MCP agent?",
+ "Extract fields from a COI",
+ "Redact PII before storage",
+ "Self-host with Docker",
+ "What are the rate limits?",
+ "Build a pipeline from a sample",
+];
+
+/** Resolve an input to a canned reply by walking the route table. */
+export function routeAssistantReply(input: string): string {
+ for (const route of ASSISTANT_ROUTES) {
+ for (const pattern of route.patterns) {
+ if (pattern.test(input)) return route.reply;
+ }
+ }
+ return ASSISTANT_DEFAULT_REPLY;
+}
diff --git a/frontend/portal/src/mocks/browser.ts b/frontend/portal/src/mocks/browser.ts
new file mode 100644
index 000000000..c69f3f85b
--- /dev/null
+++ b/frontend/portal/src/mocks/browser.ts
@@ -0,0 +1,23 @@
+import { setupWorker } from "msw/browser";
+import { handlers } from "@portal/mocks/handlers";
+
+export const worker = setupWorker(...handlers);
+
+let workerStarted = false;
+
+/**
+ * Start the MSW worker. Idempotent — calling repeatedly is safe.
+ *
+ * The toggle flips MSW by writing the preference to localStorage and
+ * reloading the page, so there's no need for a `stopMockWorker` counterpart:
+ * the next boot just decides whether to call this or not.
+ */
+export async function startMockWorker(): Promise {
+ if (workerStarted) return;
+ await worker.start({
+ onUnhandledRequest: "bypass",
+ serviceWorker: { url: "/mockServiceWorker.js" },
+ quiet: true,
+ });
+ workerStarted = true;
+}
diff --git a/frontend/portal/src/mocks/handlers/assistant.ts b/frontend/portal/src/mocks/handlers/assistant.ts
new file mode 100644
index 000000000..afbfea4c5
--- /dev/null
+++ b/frontend/portal/src/mocks/handlers/assistant.ts
@@ -0,0 +1,21 @@
+import { http, HttpResponse, delay } from "msw";
+import {
+ ASSISTANT_SUGGESTIONS,
+ routeAssistantReply,
+} from "@portal/mocks/assistant";
+
+interface AssistantMessageBody {
+ input: string;
+}
+
+export const assistantHandlers = [
+ http.get("/v1/assistant/suggestions", async () => {
+ return HttpResponse.json(ASSISTANT_SUGGESTIONS);
+ }),
+
+ http.post("/v1/assistant/messages", async ({ request }) => {
+ const body = (await request.json()) as AssistantMessageBody;
+ await delay(600 + Math.random() * 300);
+ return HttpResponse.json({ reply: routeAssistantReply(body.input) });
+ }),
+];
diff --git a/frontend/portal/src/mocks/handlers/endpoints.ts b/frontend/portal/src/mocks/handlers/endpoints.ts
new file mode 100644
index 000000000..0596b3bea
--- /dev/null
+++ b/frontend/portal/src/mocks/handlers/endpoints.ts
@@ -0,0 +1,15 @@
+import { http, HttpResponse, delay } from "msw";
+import { VERTICALS } from "@shared/data/endpoints";
+
+export const endpointsHandlers = [
+ http.get("/v1/endpoints", async ({ request }) => {
+ await delay(120);
+ const url = new URL(request.url);
+ const verticalKey = url.searchParams.get("vertical");
+ if (verticalKey) {
+ const v = VERTICALS.find((x) => x.key === verticalKey);
+ return HttpResponse.json(v ? v.endpoints : []);
+ }
+ return HttpResponse.json(VERTICALS);
+ }),
+];
diff --git a/frontend/portal/src/mocks/handlers/home.ts b/frontend/portal/src/mocks/handlers/home.ts
new file mode 100644
index 000000000..d5ba2825a
--- /dev/null
+++ b/frontend/portal/src/mocks/handlers/home.ts
@@ -0,0 +1,49 @@
+import { http, HttpResponse, delay } from "msw";
+import type { Tier } from "@portal/contexts/TierContext";
+import {
+ buildUsageSeries,
+ buildUsageSeriesResponse,
+ enterpriseKpisFor,
+ FREE_KPIS,
+ FREE_ONBOARDING,
+ proKpisFor,
+ RECENT_ACTIVITY,
+ REGION_HEALTH,
+ type KpiEntry,
+} from "@portal/mocks/home";
+
+function kpisFor(tier: Tier): KpiEntry[] {
+ if (tier === "free") return FREE_KPIS;
+ const docs30d = buildUsageSeries().reduce((sum, p) => sum + p.value, 0);
+ if (tier === "enterprise") return enterpriseKpisFor(docs30d);
+ return proKpisFor(docs30d);
+}
+
+export const homeHandlers = [
+ http.get("/v1/analytics/usage", async () => {
+ await delay(120);
+ return HttpResponse.json(buildUsageSeriesResponse());
+ }),
+
+ http.get("/v1/activity", async () => {
+ await delay(120);
+ return HttpResponse.json(RECENT_ACTIVITY);
+ }),
+
+ http.get("/v1/home/kpis", async ({ request }) => {
+ await delay(120);
+ const url = new URL(request.url);
+ const tier = (url.searchParams.get("tier") ?? "pro") as Tier;
+ return HttpResponse.json(kpisFor(tier));
+ }),
+
+ http.get("/v1/regions/health", async () => {
+ await delay(120);
+ return HttpResponse.json(REGION_HEALTH);
+ }),
+
+ http.get("/v1/onboarding", async () => {
+ await delay(120);
+ return HttpResponse.json(FREE_ONBOARDING);
+ }),
+];
diff --git a/frontend/portal/src/mocks/handlers/index.ts b/frontend/portal/src/mocks/handlers/index.ts
new file mode 100644
index 000000000..5f8afcf03
--- /dev/null
+++ b/frontend/portal/src/mocks/handlers/index.ts
@@ -0,0 +1,17 @@
+import { assistantHandlers } from "@portal/mocks/handlers/assistant";
+import { endpointsHandlers } from "@portal/mocks/handlers/endpoints";
+import { homeHandlers } from "@portal/mocks/handlers/home";
+import { notificationsHandlers } from "@portal/mocks/handlers/notifications";
+import { opsHandlers } from "@portal/mocks/handlers/ops";
+import { searchHandlers } from "@portal/mocks/handlers/search";
+
+export const handlers = [
+ ...homeHandlers,
+ ...opsHandlers,
+ ...notificationsHandlers,
+ ...assistantHandlers,
+ ...searchHandlers,
+ ...endpointsHandlers,
+];
+
+export { resetNotificationsStore } from "@portal/mocks/handlers/notifications";
diff --git a/frontend/portal/src/mocks/handlers/notifications.ts b/frontend/portal/src/mocks/handlers/notifications.ts
new file mode 100644
index 000000000..e9c809ac5
--- /dev/null
+++ b/frontend/portal/src/mocks/handlers/notifications.ts
@@ -0,0 +1,21 @@
+import { http, HttpResponse, delay } from "msw";
+import { NOTIFICATIONS, type Notification } from "@portal/mocks/notifications";
+
+let store: Notification[] = [...NOTIFICATIONS];
+
+export function resetNotificationsStore(seed?: Notification[]): void {
+ store = seed ? [...seed] : [...NOTIFICATIONS];
+}
+
+export const notificationsHandlers = [
+ http.get("/v1/notifications", async () => {
+ await delay(120);
+ return HttpResponse.json(store);
+ }),
+
+ http.post("/v1/notifications/mark-all-read", async () => {
+ await delay(120);
+ store = [];
+ return HttpResponse.json({ ok: true });
+ }),
+];
diff --git a/frontend/portal/src/mocks/handlers/ops.ts b/frontend/portal/src/mocks/handlers/ops.ts
new file mode 100644
index 000000000..e963cfcef
--- /dev/null
+++ b/frontend/portal/src/mocks/handlers/ops.ts
@@ -0,0 +1,30 @@
+import { http, HttpResponse, delay } from "msw";
+import { FEATURED_OPS, OP_RESULTS } from "@portal/mocks/ops";
+
+export const opsHandlers = [
+ http.get("/v1/ops/featured", async () => {
+ await delay(120);
+ return HttpResponse.json(FEATURED_OPS);
+ }),
+
+ http.post("/v1/ops/:opId/run", async ({ params }) => {
+ const start = performance.now();
+ await delay(800 + Math.random() * 200);
+ const opId = String(params.opId);
+ const result = OP_RESULTS[opId];
+ if (!result) {
+ return HttpResponse.json(
+ { error: `Unknown op: ${opId}` },
+ { status: 404 },
+ );
+ }
+ const enriched =
+ opId === "sign-output"
+ ? { ...result, signed_at: new Date().toISOString() }
+ : result;
+ return HttpResponse.json({
+ result: enriched,
+ durationMs: Math.round(performance.now() - start),
+ });
+ }),
+];
diff --git a/frontend/portal/src/mocks/handlers/search.ts b/frontend/portal/src/mocks/handlers/search.ts
new file mode 100644
index 000000000..b62e8bc91
--- /dev/null
+++ b/frontend/portal/src/mocks/handlers/search.ts
@@ -0,0 +1,8 @@
+import { http, HttpResponse } from "msw";
+import { QUICK_ACTIONS } from "@portal/mocks/search";
+
+export const searchHandlers = [
+ http.get("/v1/search/quick-actions", () => {
+ return HttpResponse.json(QUICK_ACTIONS);
+ }),
+];
diff --git a/frontend/portal/src/mocks/home.ts b/frontend/portal/src/mocks/home.ts
new file mode 100644
index 000000000..13b5f731f
--- /dev/null
+++ b/frontend/portal/src/mocks/home.ts
@@ -0,0 +1,255 @@
+/**
+ * Home dashboard fixtures and the types api/home.ts shares with them.
+ * api/home.ts imports the types; the MSW handlers in mocks/handlers/ serve the
+ * fixture data over the intercepted httpJson() calls. Components never reach
+ * into this module directly.
+ *
+ * Once a real backend exists, the MSW handlers stop being registered and these
+ * fixtures can be deleted (or kept as test seeds).
+ */
+
+export interface UsagePoint {
+ /** ISO date (YYYY-MM-DD). */
+ date: string;
+ /** Docs processed on that day. */
+ value: number;
+}
+
+/**
+ * Server response for the usage-series endpoint. Returning the prior window's
+ * total alongside the points lets the client derive the headline delta
+ * deterministically from real data rather than carrying a hardcoded figure.
+ */
+export interface UsageSeriesResponse {
+ points: UsagePoint[];
+ /** Equivalent docs total from the immediately prior 30-day window. */
+ priorTotal: number;
+}
+
+/** Builds 30 daily points ending today. Deterministic per day. */
+export function buildUsageSeries(): UsagePoint[] {
+ const points: UsagePoint[] = [];
+ const now = new Date();
+ for (let i = 29; i >= 0; i--) {
+ const d = new Date(now);
+ d.setDate(now.getDate() - i);
+ const day = d.getDay();
+ // Weekend dip + slow uptrend + bounded noise.
+ const weekend = day === 0 || day === 6 ? 0.55 : 1;
+ const trend = 1 + (30 - i) * 0.012;
+ const wobble = 1 + Math.sin(i * 1.3) * 0.18 + Math.cos(i * 0.6) * 0.09;
+ const base = 1450 * weekend * trend * wobble;
+ points.push({
+ date: d.toISOString().slice(0, 10),
+ value: Math.round(base),
+ });
+ }
+ return points;
+}
+
+/** Builds the full usage payload with a plausible prior-window total. */
+export function buildUsageSeriesResponse(): UsageSeriesResponse {
+ const points = buildUsageSeries();
+ const currentTotal = points.reduce((sum, p) => sum + p.value, 0);
+ // The current window's series simulates ~12% growth over the prior one.
+ const priorTotal = Math.round(currentTotal / 1.12);
+ return { points, priorTotal };
+}
+
+export type ActivityKind =
+ | "pipeline-run"
+ | "deploy"
+ | "drift"
+ | "eval"
+ | "agent"
+ | "billing";
+
+export interface ActivityEvent {
+ id: string;
+ kind: ActivityKind;
+ /** Short action verb shown at the top of the row. */
+ action: string;
+ /** Subject of the action (pipeline / endpoint / agent name). */
+ subject: string;
+ /** One-line detail line under the action. */
+ detail: string;
+ /** Relative-time string. */
+ time: string;
+ status: "success" | "warning" | "danger" | "info";
+}
+
+export const RECENT_ACTIVITY: ActivityEvent[] = [
+ {
+ id: "act-1",
+ kind: "pipeline-run",
+ action: "Pipeline run completed",
+ subject: "COI Compliance",
+ detail: "1,287 docs · 0.4% errors · P95 412 ms",
+ time: "2m ago",
+ status: "success",
+ },
+ {
+ id: "act-2",
+ kind: "deploy",
+ action: "Deployed",
+ subject: "Prior Auth v3.1.0",
+ detail: "Promoted to us-east-1, eu-west-1 · golden set 36/36",
+ time: "14m ago",
+ status: "success",
+ },
+ {
+ id: "act-3",
+ kind: "drift",
+ action: "Schema drift detected",
+ subject: "Invoice v3",
+ detail: "12 docs in 1h didn't match — confidence ↓ 0.07",
+ time: "1h ago",
+ status: "warning",
+ },
+ {
+ id: "act-4",
+ kind: "eval",
+ action: "Eval set passed",
+ subject: "KYC Processor",
+ detail: "94% (26/28) — 2 cases sent to review",
+ time: "3h ago",
+ status: "success",
+ },
+ {
+ id: "act-5",
+ kind: "agent",
+ action: "Agent escalated",
+ subject: "Contract Router",
+ detail: "Low-confidence DPA routed to L2 reviewer pool",
+ time: "5h ago",
+ status: "info",
+ },
+ {
+ id: "act-6",
+ kind: "pipeline-run",
+ action: "Pipeline run failed",
+ subject: "Contract Review",
+ detail: "8% error rate · 14 docs sent to review queue",
+ time: "8h ago",
+ status: "danger",
+ },
+ {
+ id: "act-7",
+ kind: "billing",
+ action: "Approaching cap",
+ subject: "Monthly usage",
+ detail: "389k of 500k docs · auto-upgrade disabled",
+ time: "yesterday",
+ status: "warning",
+ },
+ {
+ id: "act-8",
+ kind: "deploy",
+ action: "Rolled back",
+ subject: "COI Compliance v2.3.7",
+ detail: "Confidence regressions on Carrier supplement",
+ time: "2d ago",
+ status: "warning",
+ },
+];
+
+/**
+ * KPI labels are owned by the client (see `KPI_LABELS_BY_TIER` in Home.tsx)
+ * because they're product copy that should stay stable across loading / empty
+ * / ready states. The API only ships values + deltas.
+ */
+export interface KpiEntry {
+ value: string | number;
+ delta?: number;
+ description?: string;
+ deltaDirection?: "up" | "down" | "flat";
+}
+
+export const FREE_KPIS: KpiEntry[] = [
+ { value: "247 / 500" },
+ { value: 189 },
+ { value: 3 },
+ { value: 1 },
+];
+
+export function proKpisFor(docs30d: number): KpiEntry[] {
+ return [
+ { value: docs30d.toLocaleString(), delta: 0.12 },
+ { value: 12, delta: 0.16 },
+ { value: 7, delta: 0.4 },
+ { value: "94.6%", delta: 0.02 },
+ ];
+}
+
+export function enterpriseKpisFor(docs30d: number): KpiEntry[] {
+ return [
+ { value: docs30d.toLocaleString(), delta: 0.18 },
+ { value: "412 ms", delta: -0.05 },
+ { value: "96.2%", delta: 0.01 },
+ { value: "99.987%" },
+ ];
+}
+
+export interface RegionHealth {
+ name: string;
+ status: "healthy" | "degraded" | "down";
+ meta: string;
+}
+
+export const REGION_HEALTH: RegionHealth[] = [
+ {
+ name: "us-east-1",
+ status: "healthy",
+ meta: "2.1k/min · P95 287ms · 99.99% uptime",
+ },
+ {
+ name: "eu-west-1",
+ status: "healthy",
+ meta: "1.4k/min · P95 312ms · 99.98% uptime",
+ },
+ {
+ name: "ap-southeast-1",
+ status: "degraded",
+ meta: "412/min · P95 521ms · 99.92% uptime · degraded",
+ },
+];
+
+export interface OnboardingStep {
+ id: string;
+ title: string;
+ blurb: string;
+ done: boolean;
+ /** What to render in the per-step CTA slot. */
+ cta?: { kind: "try-op" } | { kind: "navigate"; target: string };
+}
+
+export const FREE_ONBOARDING: OnboardingStep[] = [
+ {
+ id: "first-op",
+ title: "Run your first operation",
+ blurb: "Try extract, redact, or OCR on a sample document.",
+ done: true,
+ cta: { kind: "try-op" },
+ },
+ {
+ id: "connect-source",
+ title: "Connect a source",
+ blurb: "Attach an S3 bucket, webhook, or email inbox.",
+ done: false,
+ cta: { kind: "navigate", target: "sources" },
+ },
+ {
+ id: "build-pipeline",
+ title: "Build a pipeline",
+ blurb: "Compose ops into a repeatable workflow.",
+ done: false,
+ cta: { kind: "navigate", target: "pipelines" },
+ },
+ {
+ id: "wire-agent",
+ title: "Wire an agent",
+ blurb: "Expose Stirling via MCP or REST tool definitions.",
+ done: false,
+ cta: { kind: "navigate", target: "sources" },
+ },
+];
diff --git a/frontend/portal/src/mocks/notifications.ts b/frontend/portal/src/mocks/notifications.ts
new file mode 100644
index 000000000..b267bbe34
--- /dev/null
+++ b/frontend/portal/src/mocks/notifications.ts
@@ -0,0 +1,63 @@
+/** Mock notifications for the header dropdown. */
+
+export type NotificationCategory =
+ | "pipeline"
+ | "deploy"
+ | "billing"
+ | "audit"
+ | "agent"
+ | "doc";
+
+export interface Notification {
+ id: string;
+ category: NotificationCategory;
+ title: string;
+ description: string;
+ /** Relative-time string. */
+ time: string;
+}
+
+export const NOTIFICATIONS: Notification[] = [
+ {
+ id: "n1",
+ category: "pipeline",
+ title: "COI Compliance — golden set passed",
+ description: "48/48 docs reproduced expected outputs after schema change",
+ time: "2m ago",
+ },
+ {
+ id: "n2",
+ category: "deploy",
+ title: "Deploy succeeded — Prior Auth v2.4",
+ description: "Rolled to us-east-1 + eu-west-1. P99 142ms.",
+ time: "14m ago",
+ },
+ {
+ id: "n3",
+ category: "billing",
+ title: "Approaching 80% of monthly cap",
+ description: "389k of 500k docs processed. Auto-upgrade not enabled.",
+ time: "1h ago",
+ },
+ {
+ id: "n4",
+ category: "audit",
+ title: "Four-eyes elevation requested",
+ description: "Compliance Lead approval pending for redacted doc #28471",
+ time: "3h ago",
+ },
+ {
+ id: "n5",
+ category: "agent",
+ title: "KYC Processor — eval drop",
+ description: "Pass rate fell to 87% on the latest 72h window",
+ time: "5h ago",
+ },
+ {
+ id: "n6",
+ category: "doc",
+ title: "Schema drift detected — Invoice v3",
+ description: "12 docs in last 24h didn't match expected schema",
+ time: "yesterday",
+ },
+];
diff --git a/frontend/portal/src/mocks/ops.ts b/frontend/portal/src/mocks/ops.ts
new file mode 100644
index 000000000..fc1bd203d
--- /dev/null
+++ b/frontend/portal/src/mocks/ops.ts
@@ -0,0 +1,166 @@
+/**
+ * Mock op catalogue + canned operation results for the single-op runner.
+ * Only api/ops.ts imports from this file.
+ */
+
+export type OpResultMap = Record;
+
+export interface FeaturedOp {
+ id: string;
+ label: string;
+ endpoint: string;
+ accent: "blue" | "purple" | "green" | "amber" | "red";
+ /** Shown in the picker — short single line. */
+ blurb: string;
+}
+
+export const FEATURED_OPS: FeaturedOp[] = [
+ {
+ id: "extract",
+ label: "Extract",
+ endpoint: "/v1/extract",
+ accent: "blue",
+ blurb: "Pull structured fields into a typed schema",
+ },
+ {
+ id: "redact",
+ label: "Redact PII",
+ endpoint: "/v1/redact",
+ accent: "red",
+ blurb: "Mask SSN, DOB, addresses, accounts before storage",
+ },
+ {
+ id: "classify",
+ label: "Classify",
+ endpoint: "/v1/classify",
+ accent: "purple",
+ blurb: "Identify document type with a confidence score",
+ },
+ {
+ id: "ocr",
+ label: "OCR",
+ endpoint: "/v1/ocr",
+ accent: "green",
+ blurb: "Text-recognize scanned or image pages",
+ },
+ {
+ id: "validate",
+ label: "Schema validate",
+ endpoint: "/v1/validate",
+ accent: "blue",
+ blurb: "Check fields, rules, and coverage against the schema",
+ },
+ {
+ id: "sign-output",
+ label: "Sign output",
+ endpoint: "/v1/sign",
+ accent: "green",
+ blurb: "Tamper-evident signature over artifact + run metadata",
+ },
+ {
+ id: "authenticity",
+ label: "Authenticity",
+ endpoint: "/v1/authenticity",
+ accent: "blue",
+ blurb: "Verify issuer signature, watermark, and metadata",
+ },
+ {
+ id: "tamper-check",
+ label: "Tamper check",
+ endpoint: "/v1/tamper-check",
+ accent: "amber",
+ blurb: "Detect modifications since signing or last-known-good state",
+ },
+ {
+ id: "encrypt-rest",
+ label: "Encrypt at rest",
+ endpoint: "/v1/encrypt",
+ accent: "purple",
+ blurb: "AES-256 with Stirling-managed, BYOK, or HYOK keys",
+ },
+ {
+ id: "smart-redact",
+ label: "Smart redact",
+ endpoint: "/v1/smart-redact",
+ accent: "red",
+ blurb: "Schema-aware redaction with confidence gating",
+ },
+];
+
+/** Canned JSON for each featured op's runner "done" state. */
+export const OP_RESULTS: Record = {
+ extract: {
+ schema: "coi.v2",
+ fields: {
+ carrier: "Travelers Casualty",
+ policy_number: "PHB-1108-2025",
+ gl_limit: 1_000_000,
+ umbrella_limit: 5_000_000,
+ effective: "2026-01-15",
+ expiry: "2027-01-15",
+ },
+ confidence_avg: 0.96,
+ },
+ redact: {
+ redacted_pages: 4,
+ pii_types: ["SSN", "DOB", "ADDRESS", "EMAIL"],
+ occurrences: 19,
+ redaction_style: "blackout",
+ audit_id: "rdct_01HVQ7K3ZA9YJ8C",
+ },
+ classify: {
+ schema: "invoice.v3",
+ confidence: 0.94,
+ alternatives: [
+ { schema: "credit_memo.v1", confidence: 0.04 },
+ { schema: "purchase_order.v2", confidence: 0.02 },
+ ],
+ processing_ms: 287,
+ },
+ ocr: {
+ pages: 12,
+ characters_recognized: 28471,
+ confidence_avg: 0.987,
+ languages_detected: ["en"],
+ processing_ms: 1840,
+ },
+ validate: {
+ schema: "coi.v2",
+ passed: true,
+ checks_run: 14,
+ warnings: [
+ { field: "additional_insured", message: "Optional field empty" },
+ ],
+ },
+ "sign-output": {
+ algorithm: "Ed25519",
+ key_id: "kx-prod-2026",
+ manifest_hash:
+ "0xb3f0c1a9d54fa1c0b8fd4eebd7fa11b1b16c9a3e2d2cc6f1f5a2f0a87e1b7a04",
+ },
+ authenticity: {
+ verified: true,
+ issuer: "State of California DMV",
+ signed_at: "2025-11-04T17:22:00Z",
+ watermark_match: true,
+ },
+ "tamper-check": {
+ tampered: false,
+ hash_match: true,
+ modifications_detected: 0,
+ last_known_good: "2026-04-22T09:14:00Z",
+ },
+ "encrypt-rest": {
+ algorithm: "AES-256-GCM",
+ key_mode: "BYOK",
+ key_id: "arn:aws:kms:us-east-1:123:key/abc-…",
+ object_id: "obj_01HVQ7M9B2",
+ },
+ "smart-redact": {
+ schema: "coi.v2",
+ redacted_fields: ["named_insured", "dob"],
+ occurrences: 7,
+ confidence_gate: 0.85,
+ gated_by_confidence: 1,
+ },
+};
diff --git a/frontend/portal/src/mocks/preference.ts b/frontend/portal/src/mocks/preference.ts
new file mode 100644
index 000000000..466a62e65
--- /dev/null
+++ b/frontend/portal/src/mocks/preference.ts
@@ -0,0 +1,23 @@
+///
+
+/**
+ * Lightweight preference helpers — pulled out of mocks/browser.ts so they
+ * don't drag MSW + every handler + every fixture into any chunk that just
+ * needs to *read* the user's choice. Loading the actual worker stays a
+ * dynamic import.
+ */
+
+const STORAGE_KEY = "stirling.portal.mocks-enabled";
+
+export function readMocksPreference(): boolean {
+ if (typeof window === "undefined") return false;
+ const stored = window.localStorage.getItem(STORAGE_KEY);
+ if (stored === "true") return true;
+ if (stored === "false") return false;
+ return import.meta.env.DEV;
+}
+
+export function writeMocksPreference(enabled: boolean): void {
+ if (typeof window === "undefined") return;
+ window.localStorage.setItem(STORAGE_KEY, String(enabled));
+}
diff --git a/frontend/portal/src/mocks/search.ts b/frontend/portal/src/mocks/search.ts
new file mode 100644
index 000000000..f8c5990c7
--- /dev/null
+++ b/frontend/portal/src/mocks/search.ts
@@ -0,0 +1,18 @@
+/** Mock quick-action catalogue for the ⌘K search palette. */
+
+export interface QuickAction {
+ group: "Jump to" | "Create" | "Theme";
+ label: string;
+ /** Keyboard hint shown to the right. */
+ hint: string;
+}
+
+export const QUICK_ACTIONS: QuickAction[] = [
+ { group: "Jump to", label: "Home", hint: "G H" },
+ { group: "Jump to", label: "Pipelines", hint: "G P" },
+ { group: "Jump to", label: "Sources", hint: "G S" },
+ { group: "Jump to", label: "Documents", hint: "G D" },
+ { group: "Create", label: "New pipeline", hint: "N P" },
+ { group: "Create", label: "New API key", hint: "N K" },
+ { group: "Theme", label: "Toggle dark / light", hint: "T" },
+];
diff --git a/frontend/portal/src/theme/MantineIntegration.stories.tsx b/frontend/portal/src/theme/MantineIntegration.stories.tsx
new file mode 100644
index 000000000..a744ee735
--- /dev/null
+++ b/frontend/portal/src/theme/MantineIntegration.stories.tsx
@@ -0,0 +1,46 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import {
+ Button as MantineButton,
+ Group,
+ Stack,
+ TextInput,
+} from "@mantine/core";
+import { Button as SuiButton } from "@shared/components";
+
+/**
+ * Proof that Mantine components render with the SUI-bound theme — a Mantine
+ * filled button uses --color-blue, so it sits next to a SUI button as one
+ * system. This is the "escape hatch" pattern: reach for Mantine when a
+ * component isn't worth rebuilding in SUI, and it still looks on-brand.
+ */
+const meta: Meta = {
+ title: "Portal/Theme/Mantine Integration",
+ parameters: { layout: "padded" },
+};
+export default meta;
+
+type Story = StoryObj;
+
+export const SideBySide: Story = {
+ render: () => (
+
+
+ SUI Button
+ Mantine Button
+
+
+ Mantine green
+ Mantine red
+ Mantine amber
+ Mantine purple
+
+
+ Light variant (uses --color-blue-light)
+
+
+
+ ),
+};
diff --git a/frontend/portal/src/theme/mantineTheme.ts b/frontend/portal/src/theme/mantineTheme.ts
new file mode 100644
index 000000000..fa8e6d529
--- /dev/null
+++ b/frontend/portal/src/theme/mantineTheme.ts
@@ -0,0 +1,87 @@
+import { createTheme, type MantineColorsTuple } from "@mantine/core";
+
+/**
+ * Mantine theme for the portal, bound to the SUI design tokens in
+ * shared/tokens/tokens.css. This is what lets a Mantine component (e.g. a
+ * Combobox we don't want to rebuild in SUI) sit next to a SUI and look
+ * like one system.
+ *
+ * SUI exposes ~4 named shades per colour (`-light`, `-border`, base, `-dark`),
+ * but Mantine requires a 10-slot tuple. `tuple()` spreads the SUI shades across
+ * the 10 slots, with index 6 — Mantine's default `filled` shade — landing on
+ * the base brand colour. Because every slot is a `var(--color-*)` reference and
+ * those variables flip under `[data-theme="dark"]`, the Mantine theme follows
+ * SUI's light/dark switch automatically with no extra wiring.
+ *
+ * Caveat: every shade is a `var(--color-*)` reference, not a literal colour, so
+ * Mantine's JS colour maths can't read it. Don't enable `autoContrast` or rely
+ * on `theme.fn.lighten/darken/alpha` for these palettes — they need real hex
+ * values. CSS `color-mix()` variants work fine.
+ */
+function tuple(
+ light: string,
+ border: string,
+ base: string,
+ dark: string,
+): MantineColorsTuple {
+ return [
+ `var(${light})`, // 0 subtle background
+ `var(${light})`, // 1
+ `var(${border})`, // 2
+ `var(${border})`, // 3
+ `var(${base})`, // 4
+ `var(${base})`, // 5
+ `var(${base})`, // 6 default filled shade
+ `var(${dark})`, // 7 hover
+ `var(${dark})`, // 8
+ `var(${dark})`, // 9
+ ];
+}
+
+const blue = tuple(
+ "--color-blue-light",
+ "--color-blue-border",
+ "--color-blue",
+ "--color-blue-dark",
+);
+const green = tuple(
+ "--color-green-light",
+ "--color-green-border",
+ "--color-green",
+ "--color-green-dark",
+);
+const red = tuple(
+ "--color-red-light",
+ "--color-red-border",
+ "--color-red",
+ "--color-red-dark",
+);
+const amber = tuple(
+ "--color-amber-light",
+ "--color-amber-border",
+ "--color-amber",
+ "--color-amber-dark",
+);
+const purple = tuple(
+ "--color-purple-light",
+ "--color-purple-border",
+ "--color-purple",
+ "--color-purple-dark",
+);
+
+export const mantineTheme = createTheme({
+ primaryColor: "blue",
+ // Mantine uses index 6 of the tuple for filled components by default, which
+ // is where tuple() places the base brand shade.
+ primaryShade: 6,
+ colors: {
+ blue,
+ green,
+ red,
+ amber,
+ purple,
+ },
+ fontFamily: "var(--font-sans)",
+ fontFamilyMonospace: "var(--font-mono)",
+ defaultRadius: "var(--radius-md)",
+});
diff --git a/frontend/portal/src/views/Documents.css b/frontend/portal/src/views/Documents.css
new file mode 100644
index 000000000..202a734ac
--- /dev/null
+++ b/frontend/portal/src/views/Documents.css
@@ -0,0 +1,5 @@
+.portal-documents {
+ padding: 1.5rem;
+ max-width: 78rem;
+ margin: 0 auto;
+}
diff --git a/frontend/portal/src/views/Documents.tsx b/frontend/portal/src/views/Documents.tsx
new file mode 100644
index 000000000..c4bd89f31
--- /dev/null
+++ b/frontend/portal/src/views/Documents.tsx
@@ -0,0 +1,15 @@
+import { DocumentTypeGrid } from "@portal/components/DocumentTypeGrid";
+import "@portal/views/Documents.css";
+
+/**
+ * Full document-type catalogue — the exhaustive per-vertical endpoint list.
+ * Home only teases four use cases (see PopularUseCases); the complete,
+ * tab-filterable grid lives here on its own surface.
+ */
+export function Documents() {
+ return (
+
+
+
+ );
+}
diff --git a/frontend/portal/src/views/Home.css b/frontend/portal/src/views/Home.css
new file mode 100644
index 000000000..0f7b435aa
--- /dev/null
+++ b/frontend/portal/src/views/Home.css
@@ -0,0 +1,276 @@
+.portal-home {
+ display: flex;
+ flex-direction: column;
+ gap: 1.25rem;
+ padding: 1.5rem;
+ max-width: 78rem;
+ margin: 0 auto;
+}
+
+/* Product card grid */
+.portal-home__product-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 0.875rem;
+}
+
+@media (max-width: 60rem) {
+ .portal-home__product-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+.portal-home__product-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ margin-bottom: 0.5rem;
+}
+
+.portal-home__product-title {
+ margin: 0;
+ font-size: 1.0625rem;
+ font-weight: 600;
+ color: var(--color-text-1);
+}
+
+.portal-home__product-blurb {
+ margin: 0 0 1rem;
+ font-size: 0.8125rem;
+ line-height: 1.5;
+ color: var(--color-text-3);
+}
+
+/* Metric strip */
+.portal-home__metrics {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 0.75rem;
+}
+
+@media (max-width: 50rem) {
+ .portal-home__metrics {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+/* Two-column dashboard row (activity + quick actions) */
+.portal-home__row {
+ display: grid;
+ grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
+ gap: 1rem;
+ align-items: stretch;
+}
+
+@media (max-width: 60rem) {
+ .portal-home__row {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* Quick actions card */
+.portal-home__quick-head {
+ margin-bottom: 0.625rem;
+}
+
+.portal-home__quick-title {
+ margin: 0;
+ font-size: 0.9375rem;
+ font-weight: 600;
+ color: var(--color-text-1);
+}
+
+.portal-home__quick-sub {
+ font-size: 0.75rem;
+ color: var(--color-text-4);
+}
+
+.portal-home__quick-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.375rem;
+}
+
+.portal-home__quick-row {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ align-items: center;
+ gap: 0.625rem;
+ padding: 0.5rem 0.625rem;
+ background: var(--color-bg-subtle);
+ border: 1px solid var(--color-border-light);
+ border-radius: var(--radius-md);
+ text-align: left;
+ transition:
+ background var(--motion-fast),
+ border-color var(--motion-fast);
+}
+
+.portal-home__quick-row:hover {
+ background: var(--color-bg-hover);
+ border-color: var(--color-border);
+}
+
+.portal-home__quick-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.75rem;
+ height: 1.75rem;
+ border-radius: var(--radius-md);
+ font-size: 0.875rem;
+ font-weight: 600;
+}
+
+.portal-home__quick-text {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.portal-home__quick-text strong {
+ font-size: 0.8125rem;
+ color: var(--color-text-1);
+ font-weight: 600;
+}
+
+.portal-home__quick-text span {
+ font-size: 0.75rem;
+ color: var(--color-text-4);
+}
+
+.portal-home__quick-arrow {
+ font-size: 0.875rem;
+ color: var(--color-text-5);
+}
+
+.portal-home__quick-row:hover .portal-home__quick-arrow {
+ color: var(--color-blue);
+}
+
+/* Free-tier onboarding card */
+.portal-home__onboard-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 0.5rem;
+ margin-bottom: 0.875rem;
+}
+
+.portal-home__onboard-title {
+ margin: 0;
+ font-size: 0.9375rem;
+ font-weight: 600;
+ color: var(--color-text-1);
+}
+
+.portal-home__onboard-sub {
+ margin: 0;
+ font-size: 0.75rem;
+ color: var(--color-text-4);
+}
+
+.portal-home__onboard-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.portal-home__onboard-step {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.5rem 0.625rem;
+ background: var(--color-bg-subtle);
+ border: 1px solid var(--color-border-light);
+ border-radius: var(--radius-md);
+}
+
+.portal-home__onboard-step.is-done {
+ background: var(--color-green-light);
+ border-color: color-mix(in srgb, var(--color-green) 30%, transparent);
+}
+
+.portal-home__onboard-mark {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.5rem;
+ height: 1.5rem;
+ border-radius: 50%;
+ font-size: 0.75rem;
+ font-weight: 600;
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ color: var(--color-text-4);
+}
+
+.portal-home__onboard-step.is-done .portal-home__onboard-mark {
+ background: var(--color-green);
+ color: var(--color-text-on-accent);
+ border-color: var(--color-green);
+}
+
+.portal-home__onboard-text {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.portal-home__onboard-text strong {
+ font-size: 0.8125rem;
+ color: var(--color-text-1);
+ font-weight: 600;
+}
+
+.portal-home__onboard-text span {
+ font-size: 0.75rem;
+ color: var(--color-text-4);
+}
+
+/* Enterprise region strip */
+.portal-home__regions {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 0.5rem;
+}
+
+@media (max-width: 50rem) {
+ .portal-home__regions {
+ grid-template-columns: 1fr;
+ }
+}
+
+.portal-home__region {
+ padding: 0.625rem 0.75rem;
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+}
+
+.portal-home__region-name {
+ display: flex;
+ align-items: center;
+ gap: 0.375rem;
+ font-size: 0.8125rem;
+ font-weight: 500;
+ color: var(--color-text-1);
+}
+
+.portal-home__region-dot {
+ width: 0.4375rem;
+ height: 0.4375rem;
+ border-radius: 50%;
+}
+
+.portal-home__region-meta {
+ margin-top: 0.25rem;
+ font-size: 0.6875rem;
+ color: var(--color-text-4);
+ font-family: var(--font-mono);
+}
diff --git a/frontend/portal/src/views/Home.stories.tsx b/frontend/portal/src/views/Home.stories.tsx
new file mode 100644
index 000000000..6e7aec49a
--- /dev/null
+++ b/frontend/portal/src/views/Home.stories.tsx
@@ -0,0 +1,29 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { http, HttpResponse, delay } from "msw";
+import { Home } from "@portal/views/Home";
+
+const meta: Meta = {
+ title: "Portal/Views/Home",
+ component: Home,
+ parameters: { layout: "fullscreen" },
+};
+export default meta;
+type Story = StoryObj;
+
+export const ProTier: Story = { globals: { tier: "pro" } };
+export const FreeTier: Story = { globals: { tier: "free" } };
+export const EnterpriseTier: Story = { globals: { tier: "enterprise" } };
+
+export const SlowUsage: Story = {
+ globals: { tier: "pro" },
+ parameters: {
+ msw: {
+ handlers: [
+ http.get("/v1/analytics/usage", async () => {
+ await delay(3000);
+ return HttpResponse.json({ points: [], priorTotal: 0 });
+ }),
+ ],
+ },
+ },
+};
diff --git a/frontend/portal/src/views/Home.tsx b/frontend/portal/src/views/Home.tsx
new file mode 100644
index 000000000..9ccf7ea08
--- /dev/null
+++ b/frontend/portal/src/views/Home.tsx
@@ -0,0 +1,553 @@
+import { useMemo, useState } from "react";
+import {
+ Button,
+ Card,
+ EmptyState,
+ MetricCard,
+ Skeleton,
+ StatusBadge,
+} from "@shared/components";
+import { useTier, type Tier } from "@portal/contexts/TierContext";
+import { useView, type ViewId } from "@portal/contexts/ViewContext";
+import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
+import {
+ fetchHomeKpis,
+ fetchOnboarding,
+ fetchRegionHealth,
+ fetchUsageSeries,
+ type KpiEntry,
+ type OnboardingStep,
+ type RegionHealth,
+ type UsageSeriesResponse,
+} from "@portal/api/home";
+import { WelcomeCarousel } from "@portal/components/WelcomeCarousel";
+import { PopularUseCases } from "@portal/components/PopularUseCases";
+import { UsageAreaChart } from "@portal/components/UsageAreaChart";
+import { RecentActivity } from "@portal/components/RecentActivity";
+import { SingleOpRunner } from "@portal/components/SingleOpRunner";
+import "@portal/views/Home.css";
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* Product cards (Sources / Pipelines / Agents) */
+/* ──────────────────────────────────────────────────────────────────────── */
+
+interface ProductCardProps {
+ accent: "blue" | "purple";
+ badge?: string;
+ title: string;
+ blurb: string;
+ cta: string;
+ target: ViewId;
+}
+
+function ProductCard({
+ accent,
+ badge,
+ title,
+ blurb,
+ cta,
+ target,
+}: ProductCardProps) {
+ const { setActiveView } = useView();
+ return (
+
+
+
{title}
+ {badge && (
+
+ {badge}
+
+ )}
+
+ {blurb}
+ setActiveView(target)}
+ trailingIcon={→ }
+ >
+ {cta}
+
+
+ );
+}
+
+function ProductGrid() {
+ return (
+
+ );
+}
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* Quick actions card */
+/* ──────────────────────────────────────────────────────────────────────── */
+
+function QuickActions({ onTryOp }: { onTryOp: () => void }) {
+ const { setActiveView } = useView();
+ return (
+
+
+
Quick actions
+ Top tasks for today
+
+
+
+
+ ▶
+
+
+ Try a PDF operation
+ Drop a sample, pick an op, see the JSON
+
+
+ →
+
+
+ setActiveView("pipelines")}
+ >
+
+ ⌃
+
+
+ Build a pipeline
+ 3-step composer over the typed op library
+
+
+ →
+
+
+ setActiveView("sources")}
+ >
+
+ ⇢
+
+
+ Connect a source
+ S3, agents, webhooks, watched folders
+
+
+ →
+
+
+ setActiveView("infrastructure")}
+ >
+
+ ⚙
+
+
+ Issue an API key
+ Scoped key with rate limits and IP allowlist
+
+
+ →
+
+
+
+
+ );
+}
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* Free-tier onboarding checklist */
+/* ──────────────────────────────────────────────────────────────────────── */
+
+function FreeOnboarding({ onTryOp }: { onTryOp: () => void }) {
+ const { setActiveView } = useView();
+ const state = useAsync(() => fetchOnboarding(), []);
+ const { data: steps } = state;
+ const { isLoading, isEmpty } = useSectionFlags(state);
+
+ const doneCount = steps?.filter((s) => s.done).length ?? 0;
+
+ function renderCta(step: OnboardingStep) {
+ if (step.done) {
+ return (
+
+ Run again
+
+ );
+ }
+ if (!step.cta) return null;
+ if (step.cta.kind === "try-op") {
+ return (
+
+ Start
+
+ );
+ }
+ const target = step.cta.target;
+ return (
+ setActiveView(target as ViewId)}
+ >
+ Start
+
+ );
+ }
+
+ return (
+
+
+
+
Get to value
+
+ Four steps to a production-shaped Stirling project.
+
+
+ {steps && steps.length > 0 && (
+
+ {doneCount} / {steps.length} done
+
+ )}
+
+
+ {isLoading && (
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+
+
+
+ ))}
+
+ )}
+
+ {isEmpty && (
+
+ )}
+
+ {steps && steps.length > 0 && (
+
+ {steps.map((s, i) => (
+
+
+ {s.done ? "✓" : i + 1}
+
+
+ {s.title}
+ {s.blurb}
+
+ {renderCta(s)}
+
+ ))}
+
+ )}
+
+ );
+}
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* Tier KPI strip */
+/* ──────────────────────────────────────────────────────────────────────── */
+
+interface KpiLabelSpec {
+ label: string;
+ description?: string;
+}
+
+/**
+ * KPI labels are product copy — they describe what each metric IS, not what
+ * its current value is. Labels stay client-side so the strip's structure is
+ * stable across loading / error / ready states. Only the values + deltas
+ * flow through the API.
+ */
+const KPI_LABELS_BY_TIER: Record = {
+ free: [
+ { label: "Docs processed", description: "Free plan cap" },
+ { label: "Operations" },
+ { label: "Pipelines" },
+ { label: "Agents" },
+ ],
+ pro: [
+ { label: "Docs / 30d" },
+ { label: "Pipelines" },
+ { label: "Agents active" },
+ { label: "Eval pass rate" },
+ ],
+ enterprise: [
+ { label: "Docs / 30d" },
+ { label: "P95 latency" },
+ { label: "Eval pass rate" },
+ { label: "SLA uptime (30d)" },
+ ],
+};
+
+function TierKpiStrip() {
+ const { tier } = useTier();
+ const labels = KPI_LABELS_BY_TIER[tier];
+ const { data: kpis, loading } = useAsync(
+ () => fetchHomeKpis(tier),
+ [tier],
+ );
+
+ return (
+
+ {labels.map((spec, i) => {
+ // useAsync keeps the previous tier's data during a refetch; ignore it
+ // while loading so the new labels never pair with stale values.
+ const fetched = loading ? undefined : kpis?.[i];
+ return (
+
+ );
+ })}
+
+ );
+}
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* Enterprise region health */
+/* ──────────────────────────────────────────────────────────────────────── */
+
+const REGION_DOT: Record = {
+ healthy: "var(--color-green)",
+ degraded: "var(--color-amber)",
+ down: "var(--color-red)",
+};
+
+function EnterpriseRegions() {
+ const state = useAsync(() => fetchRegionHealth(), []);
+ const { data: regions } = state;
+ const { isLoading, isEmpty } = useSectionFlags(state);
+
+ return (
+
+
+
+ {isLoading && (
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+
+
+
+ ))}
+
+ )}
+
+ {isEmpty && (
+
+ )}
+
+ {regions && regions.length > 0 && (
+
+ {regions.map((r) => (
+
+
+
+ {r.name}
+ {` — ${r.status}`}
+
+
{r.meta}
+
+ ))}
+
+ )}
+
+ );
+}
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* Chart section */
+/* ──────────────────────────────────────────────────────────────────────── */
+
+function ChartSection() {
+ const state = useAsync(() => fetchUsageSeries(), []);
+ const { data: usage } = state;
+ const { isLoading } = useSectionFlags(state);
+
+ const docs30d = useMemo(
+ () => usage?.points.reduce((sum, p) => sum + p.value, 0) ?? 0,
+ [usage],
+ );
+ const deltaPct = useMemo(() => {
+ if (!usage || usage.priorTotal <= 0) return undefined;
+ return (docs30d - usage.priorTotal) / usage.priorTotal;
+ }, [usage, docs30d]);
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ if (!usage || usage.points.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* Home view */
+/* ──────────────────────────────────────────────────────────────────────── */
+
+export function Home() {
+ const { tier } = useTier();
+ const [runnerOpen, setRunnerOpen] = useState(false);
+
+ return (
+
+
setRunnerOpen(true)} />
+
+ {tier === "free" && (
+ <>
+
+
+ setRunnerOpen(true)} />
+ setRunnerOpen(true)} />
+
+
+
+ >
+ )}
+
+ {tier === "pro" && (
+ <>
+
+
+
+
+ setRunnerOpen(true)} />
+
+
+
+ >
+ )}
+
+ {tier === "enterprise" && (
+ <>
+
+
+
+
+
+ setRunnerOpen(true)} />
+
+
+
+ >
+ )}
+
+ setRunnerOpen(false)} />
+
+ );
+}
diff --git a/frontend/portal/src/views/Placeholder.css b/frontend/portal/src/views/Placeholder.css
new file mode 100644
index 000000000..0bc1f0c8e
--- /dev/null
+++ b/frontend/portal/src/views/Placeholder.css
@@ -0,0 +1,33 @@
+.portal-placeholder {
+ display: flex;
+ justify-content: center;
+ padding: 3rem 1.5rem;
+}
+
+.portal-placeholder__card {
+ max-width: 36rem;
+ text-align: center;
+}
+
+.portal-placeholder__eyebrow {
+ font-size: 0.75rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--color-blue);
+ margin-bottom: 0.5rem;
+}
+
+.portal-placeholder__title {
+ margin: 0 0 0.75rem;
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: var(--color-text-1);
+}
+
+.portal-placeholder__copy {
+ margin: 0;
+ font-size: 0.9375rem;
+ line-height: 1.55;
+ color: var(--color-text-3);
+}
diff --git a/frontend/portal/src/views/Placeholder.stories.tsx b/frontend/portal/src/views/Placeholder.stories.tsx
new file mode 100644
index 000000000..dcd37a982
--- /dev/null
+++ b/frontend/portal/src/views/Placeholder.stories.tsx
@@ -0,0 +1,26 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { Placeholder } from "@portal/views/Placeholder";
+
+const meta: Meta = {
+ title: "Portal/Views/Placeholder",
+ component: Placeholder,
+ parameters: { layout: "fullscreen" },
+};
+export default meta;
+type Story = StoryObj;
+
+export const Sources: Story = {
+ args: { view: "sources", phase: "Phase 5 — Sources & Agents" },
+};
+export const Documents: Story = {
+ args: { view: "documents", phase: "Phase 6 — Documents" },
+};
+export const Infrastructure: Story = {
+ args: { view: "infrastructure", phase: "Phase 7 — Infrastructure" },
+};
+export const Editor: Story = {
+ args: { view: "editor", phase: "Phase 8 — Editor" },
+};
+export const Settings: Story = {
+ args: { view: "settings", phase: "Settings — modal overlay" },
+};
diff --git a/frontend/portal/src/views/Placeholder.tsx b/frontend/portal/src/views/Placeholder.tsx
new file mode 100644
index 000000000..6110c2b1c
--- /dev/null
+++ b/frontend/portal/src/views/Placeholder.tsx
@@ -0,0 +1,27 @@
+import { Card } from "@shared/components";
+import { VIEW_LABELS, type ViewId } from "@portal/contexts/ViewContext";
+import "@portal/views/Placeholder.css";
+
+interface PlaceholderProps {
+ view: ViewId;
+ phase?: string;
+}
+
+export function Placeholder({ view, phase }: PlaceholderProps) {
+ return (
+
+
+
+ {phase ?? "Coming soon"}
+
+ {VIEW_LABELS[view]}
+
+ This surface is part of the build plan but hasn’t been wired up
+ yet. The shell, theme, and tier behaviour around it are already live —
+ you can switch views from the sidebar, flip the theme, and change tier
+ to see how the chrome reacts.
+
+
+
+ );
+}
diff --git a/frontend/portal/tsconfig.json b/frontend/portal/tsconfig.json
new file mode 100644
index 000000000..e4f1a3258
--- /dev/null
+++ b/frontend/portal/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "target": "es2022",
+ "jsx": "react-jsx",
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "baseUrl": "./",
+ "paths": {
+ "@portal/*": ["src/*"],
+ "@shared/*": ["../shared/*"]
+ },
+ "resolveJsonModule": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "strict": true,
+ "skipLibCheck": true
+ },
+ "include": ["src", "main.tsx", "vite.config.ts"]
+}
diff --git a/frontend/portal/vite.config.ts b/frontend/portal/vite.config.ts
new file mode 100644
index 000000000..2db925a7b
--- /dev/null
+++ b/frontend/portal/vite.config.ts
@@ -0,0 +1,65 @@
+import { resolve } from "node:path";
+import { defineConfig, loadEnv } from "vite";
+import react from "@vitejs/plugin-react-swc";
+import tsconfigPaths from "vite-tsconfig-paths";
+
+// Portal app — sibling to frontend/editor/. Standalone vite config so the
+// editor's config stays editor-only.
+//
+// Layout:
+// frontend/portal/ <-- this config's root
+// index.html
+// main.tsx
+// src/
+// public/
+// tsconfig.json
+// frontend/shared/ <-- imported via @shared/*
+// frontend/node_modules/ <-- hoisted workspace deps
+//
+// The build emits to frontend/dist-portal/ (parallel to the editor's dist/).
+
+export default defineConfig(async ({ mode }) => {
+ // Load .env files relative to this config, regardless of where invoked from.
+ const env = loadEnv(mode, import.meta.dirname, "");
+
+ return {
+ plugins: [
+ react(),
+ tsconfigPaths({
+ projects: [resolve(import.meta.dirname, "tsconfig.json")],
+ }),
+ ],
+ // Explicit aliases — tsconfigPaths only resolves imports inside files
+ // covered by portal/tsconfig.json's `include`. shared/components/index.ts
+ // re-exports via `@shared/*` from inside shared/ itself, so we need
+ // resolve.alias for vite to handle those too.
+ resolve: {
+ alias: {
+ "@portal": resolve(import.meta.dirname, "src"),
+ "@shared": resolve(import.meta.dirname, "..", "shared"),
+ },
+ },
+ // The portal's @shared/* alias resolves to frontend/shared/, one level up
+ // from this config's root. Vite refuses to serve files outside its root
+ // by default; whitelisting the workspace root opens up shared/ and the
+ // hoisted node_modules.
+ server: {
+ host: true,
+ port: 5173,
+ strictPort: true,
+ fs: {
+ allow: [resolve(import.meta.dirname, "..")],
+ },
+ },
+ preview: {
+ host: true,
+ port: 5173,
+ strictPort: true,
+ },
+ build: {
+ outDir: "../dist-portal",
+ emptyOutDir: true,
+ },
+ base: env.RUN_SUBPATH ? `/${env.RUN_SUBPATH}` : "./",
+ };
+});
diff --git a/frontend/shared/components/Avatar.css b/frontend/shared/components/Avatar.css
new file mode 100644
index 000000000..07187c439
--- /dev/null
+++ b/frontend/shared/components/Avatar.css
@@ -0,0 +1,68 @@
+.sui-avatar {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ font-weight: 600;
+ color: var(--color-text-on-accent);
+ font-family: var(--font-sans);
+ letter-spacing: 0.01em;
+ overflow: hidden;
+ flex-shrink: 0;
+}
+
+.sui-avatar--interactive {
+ cursor: pointer;
+ transition: transform var(--motion-fast);
+}
+.sui-avatar--interactive:hover {
+ transform: scale(1.05);
+}
+
+.sui-avatar--xs {
+ width: 1.25rem;
+ height: 1.25rem;
+ font-size: 0.625rem;
+}
+.sui-avatar--sm {
+ width: 1.625rem;
+ height: 1.625rem;
+ font-size: 0.6875rem;
+}
+.sui-avatar--md {
+ width: 2rem;
+ height: 2rem;
+ font-size: 0.8125rem;
+}
+.sui-avatar--lg {
+ width: 2.5rem;
+ height: 2.5rem;
+ font-size: 1rem;
+}
+
+.sui-avatar__img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+}
+
+.sui-avatar--blue {
+ background: var(--grad-blue-btn);
+}
+.sui-avatar--purple {
+ background: var(--grad-purple-btn);
+}
+.sui-avatar--green {
+ background: var(--grad-green-btn);
+}
+.sui-avatar--amber {
+ background: var(--grad-amber-btn);
+}
+.sui-avatar--red {
+ background: var(--grad-red-btn);
+}
+.sui-avatar--neutral {
+ background: var(--color-bg-muted);
+ color: var(--color-text-2);
+}
diff --git a/frontend/shared/components/Avatar.stories.tsx b/frontend/shared/components/Avatar.stories.tsx
new file mode 100644
index 000000000..ac959f47a
--- /dev/null
+++ b/frontend/shared/components/Avatar.stories.tsx
@@ -0,0 +1,46 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { Avatar } from "@shared/components/Avatar";
+
+const meta: Meta = {
+ title: "Primitives/Avatar",
+ component: Avatar,
+ tags: ["autodocs"],
+ parameters: { layout: "centered" },
+ args: { name: "Harper Lee", size: "md", tone: "blue" },
+ argTypes: {
+ size: { control: "inline-radio", options: ["xs", "sm", "md", "lg"] },
+ tone: {
+ control: "inline-radio",
+ options: ["blue", "purple", "green", "amber", "red", "neutral"],
+ },
+ onClick: { action: "clicked" },
+ },
+};
+export default meta;
+type Story = StoryObj;
+
+/** Flip name / size / tone / interactive in controls. */
+export const Playground: Story = {};
+
+export const SizeRow: Story = {
+ render: () => (
+
+ ),
+};
+
+export const ToneRow: Story = {
+ render: () => (
+
+ {(["blue", "purple", "green", "amber", "red", "neutral"] as const).map(
+ (t) => (
+
+ ),
+ )}
+
+ ),
+};
diff --git a/frontend/shared/components/Avatar.tsx b/frontend/shared/components/Avatar.tsx
new file mode 100644
index 000000000..5246e2400
--- /dev/null
+++ b/frontend/shared/components/Avatar.tsx
@@ -0,0 +1,79 @@
+import "@shared/components/Avatar.css";
+
+export type AvatarSize = "xs" | "sm" | "md" | "lg";
+export type AvatarTone =
+ | "blue"
+ | "purple"
+ | "green"
+ | "amber"
+ | "red"
+ | "neutral";
+
+export interface AvatarProps {
+ /** Image source. Falls back to initials when missing or load fails. */
+ src?: string;
+ /** Full name. Initials are derived from the first letter of each word, max 2. */
+ name: string;
+ size?: AvatarSize;
+ /** Background tone when rendering initials. Defaults to blue. */
+ tone?: AvatarTone;
+ /** Optional click handler — renders as a button when supplied. */
+ onClick?: () => void;
+ ariaLabel?: string;
+ className?: string;
+}
+
+function initialsOf(name: string): string {
+ const parts = name.trim().split(/\s+/).filter(Boolean);
+ if (parts.length === 0) return "?";
+ if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
+ return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
+}
+
+/**
+ * Round avatar showing either an image or coloured initials. Used by the
+ * Header account button, reviewer rows, and assistant message gutters.
+ */
+export function Avatar({
+ src,
+ name,
+ size = "md",
+ tone = "blue",
+ onClick,
+ ariaLabel,
+ className,
+}: AvatarProps) {
+ const classes = [
+ "sui-avatar",
+ `sui-avatar--${size}`,
+ `sui-avatar--${tone}`,
+ onClick ? "sui-avatar--interactive" : "",
+ className ?? "",
+ ]
+ .filter(Boolean)
+ .join(" ");
+
+ const content = src ? (
+
+ ) : (
+ {initialsOf(name)}
+ );
+
+ if (onClick) {
+ return (
+
+ {content}
+
+ );
+ }
+ return (
+
+ {content}
+
+ );
+}
diff --git a/frontend/shared/components/Banner.css b/frontend/shared/components/Banner.css
new file mode 100644
index 000000000..7d249295b
--- /dev/null
+++ b/frontend/shared/components/Banner.css
@@ -0,0 +1,94 @@
+.sui-banner {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-3);
+ padding: var(--space-3) var(--space-4);
+ border-radius: var(--radius-md);
+ border: 1px solid transparent;
+ font-size: 0.8125rem;
+ line-height: 1.5;
+}
+
+.sui-banner--info {
+ background: var(--color-blue-light);
+ color: var(--color-text-2);
+ border-color: var(--color-blue-border);
+}
+.sui-banner--success {
+ background: var(--color-green-light);
+ color: var(--color-text-2);
+ border-color: var(--color-green-border);
+}
+.sui-banner--warning {
+ background: var(--color-amber-light);
+ color: var(--color-text-2);
+ border-color: var(--color-amber-border);
+}
+.sui-banner--danger {
+ background: var(--color-red-light);
+ color: var(--color-text-2);
+ border-color: var(--color-red-border);
+}
+.sui-banner--neutral {
+ background: var(--color-bg-subtle);
+ color: var(--color-text-2);
+ border-color: var(--color-border);
+}
+
+.sui-banner__icon {
+ flex: 0 0 auto;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.25rem;
+ height: 1.25rem;
+}
+
+.sui-banner--info .sui-banner__icon {
+ color: var(--color-blue);
+}
+.sui-banner--success .sui-banner__icon {
+ color: var(--color-green);
+}
+.sui-banner--warning .sui-banner__icon {
+ color: var(--color-amber-dark);
+}
+.sui-banner--danger .sui-banner__icon {
+ color: var(--color-red);
+}
+
+.sui-banner__body {
+ flex: 1 1 auto;
+ min-width: 0;
+}
+.sui-banner__title {
+ font-weight: 600;
+ color: var(--color-text-1);
+}
+.sui-banner__desc {
+ color: var(--color-text-3);
+ margin-top: 0.125rem;
+}
+
+.sui-banner__action {
+ flex: 0 0 auto;
+ display: flex;
+ align-items: center;
+}
+
+.sui-banner__close {
+ flex: 0 0 auto;
+ width: 1.5rem;
+ height: 1.5rem;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: var(--radius-sm);
+ color: var(--color-text-4);
+ font-size: 1rem;
+ line-height: 1;
+}
+.sui-banner__close:hover {
+ background: rgba(0, 0, 0, 0.06);
+ color: var(--color-text-1);
+}
diff --git a/frontend/shared/components/Banner.stories.tsx b/frontend/shared/components/Banner.stories.tsx
new file mode 100644
index 000000000..55ea4ac16
--- /dev/null
+++ b/frontend/shared/components/Banner.stories.tsx
@@ -0,0 +1,70 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { Banner } from "@shared/components/Banner";
+import { Button } from "@shared/components/Button";
+
+const meta: Meta = {
+ title: "Primitives/Banner",
+ component: Banner,
+ tags: ["autodocs"],
+ parameters: { layout: "padded" },
+ args: {
+ tone: "info",
+ title: "Heads up",
+ description: "Storage quota approaching 80%.",
+ },
+ argTypes: {
+ tone: {
+ control: "inline-radio",
+ options: ["info", "success", "warning", "danger", "neutral"],
+ },
+ onDismiss: { action: "dismissed" },
+ },
+ decorators: [
+ (S) => (
+
+
+
+ ),
+ ],
+};
+export default meta;
+type Story = StoryObj;
+
+/** Flip tone / title / description / action / onDismiss in controls. */
+export const Playground: Story = {};
+
+export const WithAction: Story = {
+ args: {
+ tone: "warning",
+ title: "Approaching cap",
+ description: "389k of 500k docs processed.",
+ action: (
+
+ Upgrade
+
+ ),
+ },
+};
+
+export const ToneMatrix: Story = {
+ render: () => (
+
+
+
+
+
+
+ ),
+};
diff --git a/frontend/shared/components/Banner.tsx b/frontend/shared/components/Banner.tsx
new file mode 100644
index 000000000..f419ebcc2
--- /dev/null
+++ b/frontend/shared/components/Banner.tsx
@@ -0,0 +1,65 @@
+import type { ReactNode } from "react";
+import "@shared/components/Banner.css";
+
+export type BannerTone = "info" | "success" | "warning" | "danger" | "neutral";
+
+export interface BannerProps {
+ tone?: BannerTone;
+ title?: ReactNode;
+ description?: ReactNode;
+ /** Right-aligned action — typically a button. */
+ action?: ReactNode;
+ /** When set, shows an × button that calls this handler. */
+ onDismiss?: () => void;
+ /** Optional leading icon (caller supplies — keeps the primitive icon-set-agnostic). */
+ icon?: ReactNode;
+ className?: string;
+ children?: ReactNode;
+}
+
+/**
+ * Inline alert. Use `tone` to convey severity; pair with `action` for
+ * "Approaching cap → Upgrade" style flows. Use `Toast` (separate primitive)
+ * for transient/dismissible notifications layered over the UI.
+ */
+export function Banner({
+ tone = "info",
+ title,
+ description,
+ action,
+ onDismiss,
+ icon,
+ className,
+ children,
+}: BannerProps) {
+ return (
+
+ {icon && (
+
+ {icon}
+
+ )}
+
+ {title &&
{title}
}
+ {description &&
{description}
}
+ {children}
+
+ {action &&
{action}
}
+ {onDismiss && (
+
+ ×
+
+ )}
+
+ );
+}
diff --git a/frontend/shared/components/Button.css b/frontend/shared/components/Button.css
new file mode 100644
index 000000000..8b474e7f4
--- /dev/null
+++ b/frontend/shared/components/Button.css
@@ -0,0 +1,118 @@
+.sui-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ border: 1px solid transparent;
+ border-radius: var(--radius-md);
+ font-family: var(--font-sans);
+ font-weight: 500;
+ cursor: pointer;
+ transition:
+ transform var(--motion-fast),
+ box-shadow var(--motion-fast),
+ background var(--motion-fast),
+ color var(--motion-fast),
+ border-color var(--motion-fast);
+ white-space: nowrap;
+ user-select: none;
+}
+.sui-btn:disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+}
+.sui-btn:focus-visible {
+ outline: 2px solid var(--color-blue);
+ outline-offset: 2px;
+}
+
+/* sizes */
+.sui-btn--md {
+ font-size: 0.8125rem;
+ padding: 0.4375rem 0.875rem;
+ min-height: 2rem;
+}
+.sui-btn--sm {
+ font-size: 0.75rem;
+ padding: 0.3125rem 0.625rem;
+ min-height: 1.625rem;
+ border-radius: var(--radius-sm);
+}
+
+.sui-btn--full {
+ width: 100%;
+ justify-content: center;
+}
+
+/* gradient (primary) */
+.sui-btn--gradient {
+ color: var(--color-text-on-accent);
+ background: var(--grad-blue-btn);
+ box-shadow: var(--shadow-blue);
+}
+.sui-btn--gradient.sui-btn--purple {
+ background: var(--grad-purple-btn);
+}
+.sui-btn--gradient.sui-btn--green {
+ background: var(--grad-green-btn);
+}
+.sui-btn--gradient:hover:not(:disabled) {
+ box-shadow: var(--shadow-blue-hover);
+ transform: translateY(-0.0625rem);
+}
+.sui-btn--gradient:active:not(:disabled) {
+ transform: translateY(0);
+}
+
+/* outline (secondary) */
+.sui-btn--outline {
+ color: var(--color-text-2);
+ background: var(--color-surface);
+ border-color: var(--color-border);
+}
+.sui-btn--outline.sui-btn--blue {
+ color: var(--color-blue);
+ border-color: var(--color-blue-border);
+}
+.sui-btn--outline.sui-btn--purple {
+ color: var(--color-purple);
+ border-color: var(--color-purple-border);
+}
+.sui-btn--outline.sui-btn--green {
+ color: var(--color-green);
+ border-color: var(--color-green-border);
+}
+.sui-btn--outline.sui-btn--amber {
+ color: var(--color-amber);
+ border-color: var(--color-amber-border);
+}
+.sui-btn--outline.sui-btn--red {
+ color: var(--color-red);
+ border-color: var(--color-red-border);
+}
+.sui-btn--outline:hover:not(:disabled) {
+ background: var(--color-bg-hover);
+ border-color: var(--color-border-hover);
+}
+
+/* ghost (tertiary) */
+.sui-btn--ghost {
+ color: var(--color-text-3);
+ background: transparent;
+}
+.sui-btn--ghost:hover:not(:disabled) {
+ color: var(--color-text-1);
+ background: var(--color-bg-hover);
+}
+
+/* spinner */
+.sui-btn__spinner {
+ width: 0.875rem;
+ height: 0.875rem;
+ border-radius: 50%;
+ border: 2px solid currentColor;
+ border-right-color: transparent;
+ animation: spin 0.7s linear infinite;
+}
+.sui-btn__label {
+ display: inline-block;
+}
diff --git a/frontend/shared/components/Button.stories.tsx b/frontend/shared/components/Button.stories.tsx
new file mode 100644
index 000000000..e2c3189d5
--- /dev/null
+++ b/frontend/shared/components/Button.stories.tsx
@@ -0,0 +1,57 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { Button } from "@shared/components/Button";
+
+const meta: Meta = {
+ title: "Primitives/Button",
+ component: Button,
+ parameters: { layout: "centered" },
+ args: { children: "Connect agent" },
+ argTypes: {
+ variant: {
+ control: "inline-radio",
+ options: ["gradient", "outline", "ghost"],
+ },
+ accent: {
+ control: "inline-radio",
+ options: ["blue", "purple", "green", "amber", "red"],
+ },
+ size: { control: "inline-radio", options: ["sm", "md"] },
+ },
+};
+export default meta;
+type Story = StoryObj;
+
+export const Gradient: Story = { args: { variant: "gradient" } };
+export const Outline: Story = { args: { variant: "outline" } };
+export const Ghost: Story = { args: { variant: "ghost" } };
+
+export const WithTrailingArrow: Story = {
+ args: {
+ variant: "gradient",
+ children: "Build a pipeline",
+ trailingIcon: → ,
+ },
+};
+
+export const Loading: Story = { args: { variant: "gradient", loading: true } };
+export const Disabled: Story = {
+ args: { variant: "gradient", disabled: true },
+};
+
+export const AccentMatrix: Story = {
+ render: () => (
+
+ {(["gradient", "outline"] as const).flatMap((variant) =>
+ (["blue", "purple", "green", "amber", "red"] as const).map((accent) => (
+
+ {variant} · {accent}
+
+ )),
+ )}
+
+ ),
+};
diff --git a/frontend/shared/components/Button.tsx b/frontend/shared/components/Button.tsx
new file mode 100644
index 000000000..0d978ea19
--- /dev/null
+++ b/frontend/shared/components/Button.tsx
@@ -0,0 +1,68 @@
+import type { ButtonHTMLAttributes, ReactNode } from "react";
+import "@shared/components/Button.css";
+
+export type ButtonVariant = "gradient" | "outline" | "ghost";
+export type ButtonSize = "sm" | "md";
+export type ButtonAccent = "blue" | "purple" | "green" | "amber" | "red";
+
+export interface ButtonProps extends ButtonHTMLAttributes {
+ /** Visual variant. `gradient` is the primary CTA; `outline` is secondary; `ghost` is tertiary/text. */
+ variant?: ButtonVariant;
+ /** Optional accent colour. Currently affects `gradient` and `outline` variants. */
+ accent?: ButtonAccent;
+ size?: ButtonSize;
+ /** Icon node rendered before the label. */
+ leadingIcon?: ReactNode;
+ /** Icon node rendered after the label (arrow → for "next" CTAs). */
+ trailingIcon?: ReactNode;
+ /** Show a spinner and disable interactivity. */
+ loading?: boolean;
+ /** Stretches to fill its parent container. */
+ fullWidth?: boolean;
+ children?: ReactNode;
+}
+
+/**
+ * Stirling's two-button system: a gradient primary CTA and an outlined
+ * secondary action. Ghost is reserved for tertiary links inside a row.
+ *
+ * Buttons compose with the existing CSS variables — they re-skin cleanly in
+ * dark mode without per-variant overrides.
+ */
+export function Button({
+ variant = "gradient",
+ accent = "blue",
+ size = "md",
+ leadingIcon,
+ trailingIcon,
+ loading = false,
+ fullWidth = false,
+ disabled,
+ className,
+ children,
+ ...rest
+}: ButtonProps) {
+ const classes = [
+ "sui-btn",
+ `sui-btn--${variant}`,
+ `sui-btn--${accent}`,
+ `sui-btn--${size}`,
+ fullWidth ? "sui-btn--full" : "",
+ loading ? "sui-btn--loading" : "",
+ className ?? "",
+ ]
+ .filter(Boolean)
+ .join(" ");
+
+ return (
+
+ {loading ? (
+
+ ) : (
+ leadingIcon
+ )}
+ {children && {children} }
+ {!loading && trailingIcon}
+
+ );
+}
diff --git a/frontend/shared/components/Card.css b/frontend/shared/components/Card.css
new file mode 100644
index 000000000..f38e2adac
--- /dev/null
+++ b/frontend/shared/components/Card.css
@@ -0,0 +1,62 @@
+.sui-card {
+ position: relative;
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-md);
+ transition:
+ box-shadow var(--motion-fast),
+ border-color var(--motion-fast),
+ transform var(--motion-fast);
+}
+.sui-card--pad-none {
+ padding: 0;
+ overflow: hidden;
+}
+.sui-card--pad-tight {
+ padding: 0.75rem;
+}
+.sui-card--pad-default {
+ padding: 1.125rem 1.25rem;
+}
+.sui-card--pad-loose {
+ padding: 1.5rem 1.75rem;
+}
+
+.sui-card--interactive {
+ cursor: pointer;
+}
+.sui-card--interactive:hover {
+ border-color: var(--color-border-hover);
+ box-shadow: var(--shadow-lg);
+ transform: translateY(-0.0625rem);
+}
+
+.sui-card--accent-blue::before,
+.sui-card--accent-purple::before,
+.sui-card--accent-green::before,
+.sui-card--accent-amber::before,
+.sui-card--accent-red::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ width: 0.25rem;
+ border-radius: var(--radius-lg) 0 0 var(--radius-lg);
+}
+.sui-card--accent-blue::before {
+ background: var(--color-blue);
+}
+.sui-card--accent-purple::before {
+ background: var(--color-purple);
+}
+.sui-card--accent-green::before {
+ background: var(--color-green);
+}
+.sui-card--accent-amber::before {
+ background: var(--color-amber);
+}
+.sui-card--accent-red::before {
+ background: var(--color-red);
+}
diff --git a/frontend/shared/components/Card.stories.tsx b/frontend/shared/components/Card.stories.tsx
new file mode 100644
index 000000000..10b31b978
--- /dev/null
+++ b/frontend/shared/components/Card.stories.tsx
@@ -0,0 +1,189 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { Card } from "@shared/components/Card";
+import { Button } from "@shared/components/Button";
+import { MetricCard } from "@shared/components/MetricCard";
+import { StatusBadge } from "@shared/components/StatusBadge";
+
+function CardBody({ color }: { color: string }) {
+ return (
+
+
+ {color} card
+
+
+ Surface treatment with the {color} accent strip.
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Primitives/Card",
+ component: Card,
+ tags: ["autodocs"],
+ parameters: { layout: "padded" },
+ args: {
+ accent: undefined,
+ padding: "default",
+ interactive: false,
+ },
+ argTypes: {
+ accent: {
+ control: "inline-radio",
+ options: [undefined, "blue", "purple", "green", "amber", "red"],
+ },
+ padding: {
+ control: "inline-radio",
+ options: ["tight", "default", "loose"],
+ },
+ interactive: { control: "boolean" },
+ },
+ decorators: [
+ (S) => (
+
+
+
+ ),
+ ],
+ render: (args) => (
+
+
+
+ ),
+};
+export default meta;
+type Story = StoryObj;
+
+/** Flip accent / padding / interactive in controls. */
+export const Playground: Story = {};
+
+export const AccentMatrix: Story = {
+ decorators: [
+ (S) => (
+
+
+
+ ),
+ ],
+ render: () => (
+
+ {(["blue", "purple", "green", "amber", "red", undefined] as const).map(
+ (accent) => (
+
+
+
+ ),
+ )}
+
+ ),
+};
+
+export const InContext_ProductGrid: Story = {
+ decorators: [
+ (S) => (
+
+
+
+ ),
+ ],
+ render: () => (
+
+
+ Sources
+
+ Attach pipelines where PDFs already live.
+
+
+ Connect a source
+
+
+
+
+
Pipelines
+
+ Hero
+
+
+
+ Compose document workflows from typed operations.
+
+
+ Build a pipeline
+
+
+
+ Agents
+
+ Wire your agent via MCP, REST, or tool definitions.
+
+
+ Connect an agent
+
+
+
+ ),
+};
+
+export const InContext_MetricsInsideCard: Story = {
+ args: { padding: "loose" },
+ render: (args) => (
+
+ Last 24 hours
+
+
+
+
+
+
+
+ ),
+};
diff --git a/frontend/shared/components/Card.tsx b/frontend/shared/components/Card.tsx
new file mode 100644
index 000000000..2afd2ddc7
--- /dev/null
+++ b/frontend/shared/components/Card.tsx
@@ -0,0 +1,47 @@
+import type { HTMLAttributes, ReactNode } from "react";
+import "@shared/components/Card.css";
+
+export interface CardProps extends HTMLAttributes {
+ /** Adds an accent strip on the left edge of the card. */
+ accent?: "blue" | "purple" | "green" | "amber" | "red";
+ /**
+ * Padding profile. `tight` = 0.75rem, `default` = 1.125rem, `loose` = 1.5rem.
+ * `none` removes padding so the card surface can host edge-to-edge content
+ * (e.g. a list with row dividers).
+ */
+ padding?: "none" | "tight" | "default" | "loose";
+ /** Use the lifted surface treatment (taller shadow, hover affordance). */
+ interactive?: boolean;
+ children?: ReactNode;
+}
+
+/**
+ * Generic surface — the prototype's cardStyle / cardStyleSm helper, lifted
+ * into a primitive. Any list item, panel, or detail card that needs the
+ * standard surface treatment composes from here.
+ */
+export function Card({
+ accent,
+ padding = "default",
+ interactive = false,
+ className,
+ children,
+ ...rest
+}: CardProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/shared/components/Checkbox.css b/frontend/shared/components/Checkbox.css
new file mode 100644
index 000000000..a9f8aeb4e
--- /dev/null
+++ b/frontend/shared/components/Checkbox.css
@@ -0,0 +1,89 @@
+.sui-check {
+ display: inline-flex;
+ align-items: flex-start;
+ gap: var(--space-2);
+ cursor: pointer;
+ font-size: 0.8125rem;
+ color: var(--color-text-2);
+}
+
+.sui-check--disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.sui-check__input {
+ position: absolute;
+ opacity: 0;
+ width: 0;
+ height: 0;
+}
+
+.sui-check__box {
+ flex: 0 0 auto;
+ width: 1rem;
+ height: 1rem;
+ margin-top: 0.0625rem;
+ border-radius: var(--radius-xs);
+ border: 1.5px solid var(--color-border-hover);
+ background: var(--color-surface);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ transition:
+ background var(--motion-fast),
+ border-color var(--motion-fast);
+ position: relative;
+}
+
+.sui-check__input:focus-visible + .sui-check__box {
+ outline: 2px solid var(--color-blue);
+ outline-offset: 2px;
+}
+
+.sui-check__input:checked + .sui-check__box {
+ background: var(--color-blue);
+ border-color: var(--color-blue);
+ color: var(--color-text-on-accent);
+}
+
+.sui-check__tick {
+ opacity: 0;
+}
+.sui-check__input:checked + .sui-check__box .sui-check__tick {
+ opacity: 1;
+}
+
+.sui-check--mixed .sui-check__box {
+ background: var(--color-blue);
+ border-color: var(--color-blue);
+}
+.sui-check--mixed .sui-check__tick {
+ opacity: 0;
+}
+.sui-check--mixed .sui-check__dash {
+ position: absolute;
+ width: 0.5rem;
+ height: 2px;
+ background: var(--color-text-on-accent);
+ border-radius: 1px;
+}
+.sui-check__dash {
+ display: none;
+}
+.sui-check--mixed .sui-check__dash {
+ display: block;
+}
+
+.sui-check__text {
+ display: flex;
+ flex-direction: column;
+}
+.sui-check__label {
+ color: var(--color-text-1);
+ font-weight: 500;
+}
+.sui-check__desc {
+ font-size: 0.75rem;
+ color: var(--color-text-4);
+}
diff --git a/frontend/shared/components/Checkbox.tsx b/frontend/shared/components/Checkbox.tsx
new file mode 100644
index 000000000..245242046
--- /dev/null
+++ b/frontend/shared/components/Checkbox.tsx
@@ -0,0 +1,71 @@
+import { forwardRef, type InputHTMLAttributes, type ReactNode } from "react";
+import "@shared/components/Checkbox.css";
+
+export interface CheckboxProps extends Omit<
+ InputHTMLAttributes,
+ "type" | "size"
+> {
+ label?: ReactNode;
+ description?: ReactNode;
+ /** Tri-state checkbox visual (still focusable + submittable, but renders the dash). */
+ indeterminate?: boolean;
+}
+
+export const Checkbox = forwardRef(
+ function Checkbox(
+ { label, description, indeterminate, className, id, ...rest },
+ ref,
+ ) {
+ return (
+
+ {
+ if (typeof ref === "function") ref(el);
+ else if (ref)
+ (ref as React.MutableRefObject).current =
+ el;
+ if (el) el.indeterminate = !!indeterminate;
+ }}
+ type="checkbox"
+ id={id}
+ className="sui-check__input"
+ {...rest}
+ />
+
+
+
+
+
+
+ {(label || description) && (
+
+ {label && {label} }
+ {description && (
+ {description}
+ )}
+
+ )}
+
+ );
+ },
+);
diff --git a/frontend/shared/components/Chip.css b/frontend/shared/components/Chip.css
new file mode 100644
index 000000000..78c18800e
--- /dev/null
+++ b/frontend/shared/components/Chip.css
@@ -0,0 +1,96 @@
+.sui-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1_5);
+ border-radius: var(--radius-pill);
+ font-family: var(--font-sans);
+ font-weight: 500;
+ white-space: nowrap;
+ border: 1px solid transparent;
+ transition:
+ background var(--motion-fast),
+ border-color var(--motion-fast),
+ transform var(--motion-fast);
+}
+
+.sui-chip--sm {
+ padding: var(--space-0_5) var(--space-2);
+ font-size: 0.6875rem;
+}
+.sui-chip--md {
+ padding: var(--space-1) var(--space-2_5, 0.5rem);
+ font-size: 0.75rem;
+}
+.sui-chip--md {
+ padding: var(--space-1) 0.625rem;
+ font-size: 0.75rem;
+}
+
+.sui-chip--neutral {
+ background: var(--color-bg-muted);
+ color: var(--color-text-2);
+ border-color: var(--color-border);
+}
+.sui-chip--blue {
+ background: var(--color-blue-light);
+ color: var(--color-blue);
+ border-color: var(--color-blue-border);
+}
+.sui-chip--purple {
+ background: var(--color-purple-light);
+ color: var(--color-purple);
+ border-color: var(--color-purple-border);
+}
+.sui-chip--green {
+ background: var(--color-green-light);
+ color: var(--color-green);
+ border-color: var(--color-green-border);
+}
+.sui-chip--amber {
+ background: var(--color-amber-light);
+ color: var(--color-amber-dark);
+ border-color: var(--color-amber-border);
+}
+.sui-chip--red {
+ background: var(--color-red-light);
+ color: var(--color-red);
+ border-color: var(--color-red-border);
+}
+
+.sui-chip--interactive {
+ cursor: pointer;
+}
+.sui-chip--interactive:hover {
+ transform: translateY(-0.0625rem);
+ filter: brightness(1.05);
+}
+
+.sui-chip__dot {
+ width: 0.4375rem;
+ height: 0.4375rem;
+ border-radius: 50%;
+ background: currentColor;
+ opacity: 0.85;
+}
+
+.sui-chip__icon {
+ display: inline-flex;
+ line-height: 1;
+}
+
+.sui-chip__remove {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1rem;
+ height: 1rem;
+ border-radius: 50%;
+ color: inherit;
+ opacity: 0.6;
+ font-size: 0.875rem;
+ line-height: 1;
+}
+.sui-chip__remove:hover {
+ opacity: 1;
+ background: rgba(0, 0, 0, 0.08);
+}
diff --git a/frontend/shared/components/Chip.stories.tsx b/frontend/shared/components/Chip.stories.tsx
new file mode 100644
index 000000000..c92b21344
--- /dev/null
+++ b/frontend/shared/components/Chip.stories.tsx
@@ -0,0 +1,79 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { Chip } from "@shared/components/Chip";
+
+const meta: Meta = {
+ title: "Primitives/Chip",
+ component: Chip,
+ tags: ["autodocs"],
+ parameters: { layout: "centered" },
+ args: {
+ children: "us-east-1",
+ tone: "neutral",
+ size: "md",
+ showDot: false,
+ },
+ argTypes: {
+ tone: {
+ control: "inline-radio",
+ options: ["neutral", "blue", "purple", "green", "amber", "red"],
+ },
+ size: { control: "inline-radio", options: ["sm", "md"] },
+ showDot: { control: "boolean" },
+ onClick: { action: "clicked" },
+ onRemove: { action: "removed" },
+ },
+};
+export default meta;
+type Story = StoryObj;
+
+/** Flip tone / size / dot / interactive / removable in controls. */
+export const Playground: Story = {};
+
+export const ToneRow: Story = {
+ render: () => (
+
+ {(["neutral", "blue", "purple", "green", "amber", "red"] as const).map(
+ (t) => (
+
+ {t}
+
+ ),
+ )}
+
+ ),
+};
+
+export const InContext_OpChain: Story = {
+ render: () => (
+
+
+ ocr
+
+
+ classify
+
+
+ extract
+
+
+ validate
+
+
+ redact
+
+
+ encrypt-rest
+
+
+ store-primary
+
+
+ ),
+};
diff --git a/frontend/shared/components/Chip.tsx b/frontend/shared/components/Chip.tsx
new file mode 100644
index 000000000..3cc670a2a
--- /dev/null
+++ b/frontend/shared/components/Chip.tsx
@@ -0,0 +1,88 @@
+import type { ReactNode } from "react";
+import "@shared/components/Chip.css";
+
+export type ChipTone =
+ | "neutral"
+ | "blue"
+ | "purple"
+ | "green"
+ | "amber"
+ | "red";
+
+export type ChipSize = "sm" | "md";
+
+export interface ChipProps {
+ tone?: ChipTone;
+ size?: ChipSize;
+ leadingIcon?: ReactNode;
+ trailingIcon?: ReactNode;
+ /** Show a `×` button. Calls `onRemove` when clicked. */
+ onRemove?: () => void;
+ /** Renders as a button when set. */
+ onClick?: () => void;
+ /** Show the leading dot affordance. Defaults to false (set true for status-style chips). */
+ showDot?: boolean;
+ children?: ReactNode;
+ className?: string;
+}
+
+/**
+ * Generic chip / tag — `StatusBadge` has a fixed taxonomy (success/warning/…),
+ * `MethodBadge` is HTTP-method-only; this is the open-ended one for tag rows
+ * (selected ops, document regions, kbd hints, sort chips, etc).
+ */
+export function Chip({
+ tone = "neutral",
+ size = "md",
+ leadingIcon,
+ trailingIcon,
+ onRemove,
+ onClick,
+ showDot,
+ children,
+ className,
+}: ChipProps) {
+ const Tag = onClick ? "button" : "span";
+ const classes = [
+ "sui-chip",
+ `sui-chip--${tone}`,
+ `sui-chip--${size}`,
+ onClick ? "sui-chip--interactive" : "",
+ className ?? "",
+ ]
+ .filter(Boolean)
+ .join(" ");
+ return (
+
+ {showDot && }
+ {leadingIcon && (
+
+ {leadingIcon}
+
+ )}
+ {children}
+ {trailingIcon && !onRemove && (
+
+ {trailingIcon}
+
+ )}
+ {onRemove && (
+ {
+ e.stopPropagation();
+ onRemove();
+ }}
+ aria-label="Remove"
+ >
+ ×
+
+ )}
+
+ );
+}
diff --git a/frontend/shared/components/CodeBlock.css b/frontend/shared/components/CodeBlock.css
new file mode 100644
index 000000000..b74a03c67
--- /dev/null
+++ b/frontend/shared/components/CodeBlock.css
@@ -0,0 +1,69 @@
+.sui-code {
+ background: var(--code-bg);
+ color: var(--code-text);
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+ border: 1px solid var(--code-border);
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
+}
+.sui-code__chrome {
+ display: flex;
+ align-items: center;
+ gap: 0.625rem;
+ background: var(--code-bg-header);
+ padding: 0.5rem 0.75rem;
+ border-bottom: 1px solid var(--code-border);
+ font-size: 0.6875rem;
+ color: var(--code-muted);
+}
+.sui-code__dots {
+ display: inline-flex;
+ gap: 0.375rem;
+ align-items: center;
+}
+.sui-code__dots > span {
+ width: 0.625rem;
+ height: 0.625rem;
+ border-radius: 50%;
+ background: var(--code-dot);
+}
+.sui-code__caption {
+ color: var(--code-muted);
+ margin-left: 0.25rem;
+ font-family: var(--font-mono);
+}
+.sui-code__lang {
+ margin-left: auto;
+ font-family: var(--font-mono);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--code-dim);
+}
+.sui-code__copy {
+ background: transparent;
+ color: var(--code-text);
+ border: 1px solid var(--code-border);
+ border-radius: var(--radius-sm);
+ padding: 0.125rem 0.5rem;
+ font-size: 0.6875rem;
+ cursor: pointer;
+ font-family: var(--font-sans);
+ transition: background var(--motion-fast);
+}
+.sui-code__copy:hover {
+ background: rgba(255, 255, 255, 0.05);
+}
+.sui-code__pre {
+ margin: 0;
+ padding: 0.875rem 1rem;
+ font-family: var(--font-mono);
+ font-size: 0.78125rem;
+ line-height: 1.55;
+ overflow: auto;
+ white-space: pre;
+}
+.sui-code__pre code {
+ white-space: inherit;
+ color: inherit;
+ font-family: inherit;
+}
diff --git a/frontend/shared/components/CodeBlock.stories.tsx b/frontend/shared/components/CodeBlock.stories.tsx
new file mode 100644
index 000000000..cad00e08b
--- /dev/null
+++ b/frontend/shared/components/CodeBlock.stories.tsx
@@ -0,0 +1,85 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { CodeBlock } from "@shared/components/CodeBlock";
+
+const CURL_EXAMPLE = `curl https://api.stirling.com/v1/coi \\
+ -H "Authorization: Bearer sk_live_a3f8..." \\
+ -F "file=@certificate.pdf"`;
+
+const JSON_RESULT = `{
+ "schema": "coi.v2",
+ "fields": {
+ "carrier": "Travelers Casualty",
+ "policy_number": "PHB-1108-2025",
+ "gl_limit": 1000000,
+ "umbrella_limit": 5000000,
+ "effective": "2026-01-15",
+ "expiry": "2027-01-15"
+ },
+ "confidence_avg": 0.96
+}`;
+
+const PYTHON_EXAMPLE = `import stirling
+
+client = stirling.Client(api_key="sk_live_a3f8...")
+result = client.extract(file="certificate.pdf", schema="coi.v2")
+print(result.fields)`;
+
+const meta: Meta = {
+ title: "Primitives/CodeBlock",
+ component: CodeBlock,
+ tags: ["autodocs"],
+ parameters: { layout: "padded" },
+ args: { code: CURL_EXAMPLE, lang: "curl", copyable: true, maxHeight: 400 },
+ argTypes: {
+ lang: {
+ control: "inline-radio",
+ options: [
+ "json",
+ "javascript",
+ "typescript",
+ "python",
+ "bash",
+ "curl",
+ "http",
+ "plain",
+ ],
+ },
+ copyable: { control: "boolean" },
+ maxHeight: { control: "number" },
+ code: { control: "text" },
+ },
+};
+export default meta;
+type Story = StoryObj;
+
+/** Flip lang / copyable / maxHeight / code in controls. */
+export const Playground: Story = {};
+
+export const LongScrolling: Story = {
+ args: {
+ code: Array.from(
+ { length: 40 },
+ (_, i) => `line ${i + 1}: const x = ${i};`,
+ ).join("\n"),
+ lang: "javascript",
+ maxHeight: 240,
+ },
+};
+
+export const InContext_TwoUpComparison: Story = {
+ render: () => (
+
+
+
+
+ ),
+};
+
+export const InContext_Quickstart: Story = {
+ render: () => (
+
+
+
+
+ ),
+};
diff --git a/frontend/shared/components/CodeBlock.tsx b/frontend/shared/components/CodeBlock.tsx
new file mode 100644
index 000000000..c3952a3f8
--- /dev/null
+++ b/frontend/shared/components/CodeBlock.tsx
@@ -0,0 +1,82 @@
+import { useState, type ReactNode } from "react";
+import "@shared/components/CodeBlock.css";
+
+export type CodeLang =
+ | "json"
+ | "javascript"
+ | "typescript"
+ | "python"
+ | "bash"
+ | "curl"
+ | "http"
+ | "plain";
+
+export interface CodeBlockProps {
+ /** The code content. */
+ code: string;
+ /** Language label shown in the chrome bar. Highlight wiring is out of scope here — bring Shiki/Prism. */
+ lang?: CodeLang;
+ /** Optional caption text shown in the chrome bar (e.g. a file path). */
+ caption?: ReactNode;
+ /** Show a copy-to-clipboard button. Defaults to true. */
+ copyable?: boolean;
+ /** Max height in pixels; longer content scrolls. */
+ maxHeight?: number;
+ className?: string;
+}
+
+/**
+ * Always-dark code block, matched to the prototype's CODE palette.
+ *
+ * Highlighting is intentionally not wired here — drop in Shiki at the call
+ * site and feed pre-highlighted HTML through `dangerouslySetInnerHTML` on a
+ * fork of this component if you need it. For most surfaces the raw
+ * monospaced text plus copy button is sufficient (and ~80% lighter).
+ */
+export function CodeBlock({
+ code,
+ lang = "plain",
+ caption,
+ copyable = true,
+ maxHeight = 400,
+ className,
+}: CodeBlockProps) {
+ const [copied, setCopied] = useState(false);
+
+ async function copy() {
+ try {
+ await navigator.clipboard.writeText(code);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1500);
+ } catch {
+ // Older browsers / non-secure contexts — silently fall through.
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+ {caption && {caption} }
+ {lang}
+ {copyable && (
+
+ {copied ? "Copied" : "Copy"}
+
+ )}
+
+
+ {code}
+
+
+ );
+}
diff --git a/frontend/shared/components/Drawer.css b/frontend/shared/components/Drawer.css
new file mode 100644
index 000000000..d2b90f26a
--- /dev/null
+++ b/frontend/shared/components/Drawer.css
@@ -0,0 +1,110 @@
+.sui-drawer__backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(15, 23, 42, 0.32);
+ z-index: var(--z-drawer);
+ animation: fadeIn 0.18s ease both;
+}
+
+.sui-drawer {
+ position: fixed;
+ top: 0;
+ bottom: 0;
+ max-width: 100vw;
+ background: var(--color-surface);
+ display: flex;
+ flex-direction: column;
+ z-index: calc(var(--z-drawer) + 1);
+ animation: slideInRight 0.22s cubic-bezier(0.4, 0, 0.2, 1) both;
+}
+
+.sui-drawer--right {
+ right: 0;
+ border-left: 1px solid var(--color-border);
+ box-shadow: -4px 0 24px rgba(15, 23, 42, 0.12);
+}
+
+.sui-drawer--left {
+ left: 0;
+ border-right: 1px solid var(--color-border);
+ box-shadow: 4px 0 24px rgba(15, 23, 42, 0.12);
+ animation-name: slideInLeft;
+}
+
+@keyframes slideInLeft {
+ from {
+ transform: translateX(-100%);
+ }
+ to {
+ transform: translateX(0);
+ }
+}
+
+.sui-drawer--sm {
+ width: 22rem;
+}
+.sui-drawer--md {
+ width: 27.5rem;
+}
+.sui-drawer--lg {
+ width: 36rem;
+}
+
+.sui-drawer__header {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-3);
+ padding: var(--space-4) var(--space-4) var(--space-3);
+ border-bottom: 1px solid var(--color-border-light);
+}
+
+.sui-drawer__header-text {
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+.sui-drawer__title {
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--color-text-1);
+}
+
+.sui-drawer__sub {
+ margin-top: 0.125rem;
+ font-size: 0.8125rem;
+ color: var(--color-text-4);
+}
+
+.sui-drawer__close {
+ flex: 0 0 auto;
+ width: 1.75rem;
+ height: 1.75rem;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: var(--radius-sm);
+ color: var(--color-text-4);
+ transition:
+ background var(--motion-fast),
+ color var(--motion-fast);
+}
+.sui-drawer__close:hover {
+ background: var(--color-bg-hover);
+ color: var(--color-text-1);
+}
+
+.sui-drawer__body {
+ flex: 1 1 auto;
+ overflow-y: auto;
+ padding: var(--space-3) var(--space-4) var(--space-6);
+}
+
+.sui-drawer__footer {
+ padding: var(--space-3) var(--space-4);
+ border-top: 1px solid var(--color-border-light);
+ background: var(--color-bg-subtle);
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: var(--space-2);
+}
diff --git a/frontend/shared/components/Drawer.stories.tsx b/frontend/shared/components/Drawer.stories.tsx
new file mode 100644
index 000000000..1f218ed19
--- /dev/null
+++ b/frontend/shared/components/Drawer.stories.tsx
@@ -0,0 +1,91 @@
+import { useState } from "react";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { Drawer } from "@shared/components/Drawer";
+import { Button } from "@shared/components/Button";
+
+const meta: Meta = {
+ title: "Primitives/Drawer",
+ component: Drawer,
+ parameters: { layout: "fullscreen" },
+ tags: ["autodocs"],
+ args: {
+ side: "right",
+ width: "md",
+ title: "Pipeline detail",
+ subtitle: "COI Compliance · us-east-1",
+ },
+ argTypes: {
+ side: { control: "inline-radio", options: ["left", "right"] },
+ width: { control: "inline-radio", options: ["sm", "md", "lg"] },
+ },
+ decorators: [
+ (S) => (
+
+
+
+ ),
+ ],
+ render: (args) => {
+ function Bound() {
+ const [open, setOpen] = useState(true);
+ return (
+ <>
+ setOpen(true)}>Open drawer
+ setOpen(false)}>
+
+ The drawer body scrolls when its content overflows. The header and
+ footer (when present) are sticky.
+
+
+ >
+ );
+ }
+ return ;
+ },
+};
+export default meta;
+type Story = StoryObj;
+
+/** Flip side / width / title / subtitle in controls. */
+export const Playground: Story = {};
+
+export const WithFooter: Story = {
+ render: () => {
+ function Bound() {
+ const [open, setOpen] = useState(true);
+ return (
+ <>
+ setOpen(true)}>Open drawer
+ setOpen(false)}
+ side="right"
+ width="md"
+ title="Pipeline detail"
+ subtitle="COI Compliance · us-east-1"
+ footer={
+ <>
+ setOpen(false)}>
+ Close
+
+ Edit composition
+ View runs
+ >
+ }
+ >
+
+ Sticky footer demo — scroll the body, footer stays anchored.
+
+
+ >
+ );
+ }
+ return ;
+ },
+};
diff --git a/frontend/shared/components/Drawer.tsx b/frontend/shared/components/Drawer.tsx
new file mode 100644
index 000000000..add63fc20
--- /dev/null
+++ b/frontend/shared/components/Drawer.tsx
@@ -0,0 +1,171 @@
+import { useEffect, useId, useRef, type ReactNode } from "react";
+import { createPortal } from "react-dom";
+import "@shared/components/Drawer.css";
+
+const FOCUSABLE_SELECTOR =
+ 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
+
+export type DrawerSide = "right" | "left";
+export type DrawerWidth = "sm" | "md" | "lg";
+
+export interface DrawerProps {
+ open: boolean;
+ onClose: () => void;
+ side?: DrawerSide;
+ /** Width preset. sm=22rem, md=27.5rem, lg=36rem. */
+ width?: DrawerWidth;
+ title?: ReactNode;
+ subtitle?: ReactNode;
+ /** Sticky footer slot below the body. */
+ footer?: ReactNode;
+ disableBackdropClose?: boolean;
+ disableEscapeClose?: boolean;
+ className?: string;
+ ariaLabel?: string;
+ children?: ReactNode;
+}
+
+/**
+ * Side drawer — sibling to {@link Modal}. Slides in from `side`, locks body
+ * scroll, closes on backdrop click or Escape. PipelineDetailDrawer, doc
+ * detail drawers, etc. should all sit on top of this primitive.
+ */
+export function Drawer({
+ open,
+ onClose,
+ side = "right",
+ width = "md",
+ title,
+ subtitle,
+ footer,
+ disableBackdropClose = false,
+ disableEscapeClose = false,
+ className,
+ ariaLabel,
+ children,
+}: DrawerProps) {
+ const dialogRef = useRef(null);
+ const titleId = useId();
+
+ useEffect(() => {
+ if (!open || disableEscapeClose) return;
+ function onKey(e: KeyboardEvent) {
+ if (e.key === "Escape") onClose();
+ }
+ document.addEventListener("keydown", onKey);
+ return () => document.removeEventListener("keydown", onKey);
+ }, [open, disableEscapeClose, onClose]);
+
+ useEffect(() => {
+ if (!open) return;
+ const previous = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+ return () => {
+ document.body.style.overflow = previous;
+ };
+ }, [open]);
+
+ useEffect(() => {
+ if (!open) return;
+ const previouslyFocused = document.activeElement as HTMLElement | null;
+ const dialog = dialogRef.current;
+ const first = dialog?.querySelector(FOCUSABLE_SELECTOR);
+ first?.focus();
+
+ function onKey(e: KeyboardEvent) {
+ if (e.key !== "Tab" || !dialog) return;
+ const focusables =
+ dialog.querySelectorAll(FOCUSABLE_SELECTOR);
+ if (focusables.length === 0) {
+ e.preventDefault();
+ return;
+ }
+ const firstEl = focusables[0];
+ const lastEl = focusables[focusables.length - 1];
+ const active = document.activeElement;
+ if (e.shiftKey && active === firstEl) {
+ e.preventDefault();
+ lastEl.focus();
+ } else if (!e.shiftKey && active === lastEl) {
+ e.preventDefault();
+ firstEl.focus();
+ }
+ }
+
+ document.addEventListener("keydown", onKey);
+ return () => {
+ document.removeEventListener("keydown", onKey);
+ previouslyFocused?.focus?.();
+ };
+ }, [open]);
+
+ if (!open) return null;
+
+ function onBackdropClick() {
+ if (!disableBackdropClose) onClose();
+ }
+
+ const hasTitle = title !== undefined && title !== null;
+
+ return createPortal(
+ <>
+
+
+ {(title || subtitle) && (
+
+
+ {title && (
+
+ {title}
+
+ )}
+ {subtitle &&
{subtitle}
}
+
+
+
+
+
+
+
+
+ )}
+ {children}
+ {footer && }
+
+ >,
+ document.body,
+ );
+}
diff --git a/frontend/shared/components/Dropdown.css b/frontend/shared/components/Dropdown.css
new file mode 100644
index 000000000..c96aec167
--- /dev/null
+++ b/frontend/shared/components/Dropdown.css
@@ -0,0 +1,77 @@
+.sui-dd {
+ position: relative;
+ display: inline-flex;
+}
+
+.sui-dd__menu {
+ position: absolute;
+ top: calc(100% + var(--space-1));
+ min-width: 12rem;
+ padding: var(--space-1);
+ background: var(--color-dropdown-bg);
+ border: 1px solid var(--color-dropdown-border);
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-lg);
+ z-index: var(--z-dropdown);
+ animation: fadeInUp var(--motion-enter) both;
+ display: flex;
+ flex-direction: column;
+ gap: 0.0625rem;
+}
+
+.sui-dd__menu--start {
+ left: 0;
+}
+.sui-dd__menu--end {
+ right: 0;
+}
+
+.sui-dd__item {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-1_5) var(--space-2);
+ width: 100%;
+ text-align: left;
+ font-size: 0.8125rem;
+ color: var(--color-text-2);
+ border-radius: var(--radius-sm);
+ transition:
+ background var(--motion-fast),
+ color var(--motion-fast);
+}
+
+.sui-dd__item:hover:not(.is-disabled) {
+ background: var(--color-dropdown-hover);
+ color: var(--color-text-1);
+}
+
+.sui-dd__item.is-active {
+ background: var(--color-nav-active);
+ color: var(--color-nav-active-text);
+ font-weight: 500;
+}
+
+.sui-dd__item.is-disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.sui-dd__item-leading,
+.sui-dd__item-trailing {
+ display: inline-flex;
+ align-items: center;
+}
+
+.sui-dd__item-trailing {
+ margin-left: auto;
+ font-family: var(--font-mono);
+ font-size: 0.6875rem;
+ color: var(--color-text-5);
+}
+
+.sui-dd__divider {
+ height: 1px;
+ background: var(--color-divider);
+ margin: var(--space-1) 0;
+}
diff --git a/frontend/shared/components/Dropdown.stories.tsx b/frontend/shared/components/Dropdown.stories.tsx
new file mode 100644
index 000000000..f86aeb04c
--- /dev/null
+++ b/frontend/shared/components/Dropdown.stories.tsx
@@ -0,0 +1,82 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { Dropdown } from "@shared/components/Dropdown";
+import { Button } from "@shared/components/Button";
+
+const meta: Meta = {
+ title: "Primitives/Dropdown",
+ parameters: { layout: "padded" },
+ decorators: [
+ (S) => (
+
+
+
+ ),
+ ],
+};
+export default meta;
+type Story = StoryObj;
+
+export const Basic: Story = {
+ render: () => (
+
+
+ Open menu
+
+
+ console.log("a")}>Item A
+ console.log("b")}>Item B
+ console.log("c")} active>
+ Item C (active)
+
+
+
+ ),
+};
+
+export const WithDivider: Story = {
+ render: () => (
+
+
+ Account
+
+
+ Profile
+ Workspace settings
+
+ Sign out
+
+
+ ),
+};
+
+export const WithTrailingHints: Story = {
+ render: () => (
+
+
+ Commands
+
+
+ Search
+ New pipeline
+ New API key
+
+ Toggle theme
+
+
+
+ ),
+};
+
+export const AlignStart: Story = {
+ render: () => (
+
+
+ Aligned to start
+
+
+ One
+ Two
+
+
+ ),
+};
diff --git a/frontend/shared/components/Dropdown.tsx b/frontend/shared/components/Dropdown.tsx
new file mode 100644
index 000000000..8d47ba9df
--- /dev/null
+++ b/frontend/shared/components/Dropdown.tsx
@@ -0,0 +1,233 @@
+import {
+ cloneElement,
+ createContext,
+ isValidElement,
+ useCallback,
+ useContext,
+ useEffect,
+ useId,
+ useMemo,
+ useRef,
+ useState,
+ type ReactElement,
+ type ReactNode,
+} from "react";
+import "@shared/components/Dropdown.css";
+
+type Alignment = "start" | "end";
+
+interface DropdownContextValue {
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ triggerRef: React.RefObject;
+ menuId: string;
+ align: Alignment;
+}
+
+const DropdownContext = createContext(null);
+
+function useDropdownCtx(): DropdownContextValue {
+ const ctx = useContext(DropdownContext);
+ if (!ctx)
+ throw new Error(
+ "Dropdown subcomponents must be used inside ",
+ );
+ return ctx;
+}
+
+export interface DropdownRootProps {
+ /** Controlled open state. Omit for uncontrolled. */
+ open?: boolean;
+ defaultOpen?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ /** Alignment of the menu relative to the trigger. */
+ align?: Alignment;
+ children: ReactNode;
+ className?: string;
+}
+
+function Root({
+ open: openProp,
+ defaultOpen,
+ onOpenChange,
+ align = "end",
+ children,
+ className,
+}: DropdownRootProps) {
+ const [uncontrolled, setUncontrolled] = useState(defaultOpen ?? false);
+ const isControlled = openProp !== undefined;
+ const open = isControlled ? openProp : uncontrolled;
+
+ const setOpen = useCallback(
+ (next: boolean) => {
+ if (!isControlled) setUncontrolled(next);
+ onOpenChange?.(next);
+ },
+ [isControlled, onOpenChange],
+ );
+
+ const triggerRef = useRef(null);
+ const containerRef = useRef(null);
+ const menuId = useId();
+
+ // Click-outside + Escape close.
+ useEffect(() => {
+ if (!open) return;
+ function onDocClick(e: MouseEvent) {
+ if (
+ containerRef.current &&
+ !containerRef.current.contains(e.target as Node)
+ ) {
+ setOpen(false);
+ }
+ }
+ function onKey(e: KeyboardEvent) {
+ if (e.key === "Escape") {
+ setOpen(false);
+ triggerRef.current?.focus();
+ }
+ }
+ document.addEventListener("mousedown", onDocClick);
+ document.addEventListener("keydown", onKey);
+ return () => {
+ document.removeEventListener("mousedown", onDocClick);
+ document.removeEventListener("keydown", onKey);
+ };
+ }, [open, setOpen]);
+
+ const value = useMemo(
+ () => ({ open, setOpen, triggerRef, menuId, align }),
+ [open, setOpen, menuId, align],
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+export interface DropdownTriggerProps {
+ /** A single button-like element. Receives onClick + aria props. */
+ children: ReactElement<{
+ onClick?: (e: React.MouseEvent) => void;
+ "aria-haspopup"?: string;
+ "aria-expanded"?: boolean;
+ "aria-controls"?: string;
+ ref?: React.Ref;
+ }>;
+}
+
+function Trigger({ children }: DropdownTriggerProps) {
+ const { open, setOpen, triggerRef, menuId } = useDropdownCtx();
+ if (!isValidElement(children)) {
+ throw new Error(
+ "Dropdown.Trigger requires exactly one React element child",
+ );
+ }
+ return cloneElement(children, {
+ ref: triggerRef as React.Ref,
+ onClick: (e: React.MouseEvent) => {
+ children.props.onClick?.(e);
+ setOpen(!open);
+ },
+ "aria-haspopup": "menu",
+ "aria-expanded": open,
+ "aria-controls": menuId,
+ });
+}
+
+export interface DropdownMenuProps {
+ children: ReactNode;
+ className?: string;
+ /** Optional min-width override (px or CSS length). */
+ width?: string | number;
+}
+
+function Menu({ children, className, width }: DropdownMenuProps) {
+ const { open, menuId, align } = useDropdownCtx();
+ if (!open) return null;
+ const style =
+ width !== undefined
+ ? { minWidth: typeof width === "number" ? `${width}px` : width }
+ : undefined;
+ return (
+
+ );
+}
+
+export interface DropdownItemProps {
+ onSelect?: () => void;
+ /** Active visual state (e.g. current value in a switcher). */
+ active?: boolean;
+ disabled?: boolean;
+ /** Optional leading visual. */
+ leading?: ReactNode;
+ /** Optional trailing visual (kbd hint, badge, etc). */
+ trailing?: ReactNode;
+ children?: ReactNode;
+ className?: string;
+}
+
+function Item({
+ onSelect,
+ active,
+ disabled,
+ leading,
+ trailing,
+ children,
+ className,
+}: DropdownItemProps) {
+ const { setOpen } = useDropdownCtx();
+ return (
+ {
+ if (disabled) return;
+ onSelect?.();
+ setOpen(false);
+ }}
+ >
+ {leading && {leading} }
+ {children}
+ {trailing && {trailing} }
+
+ );
+}
+
+function Divider() {
+ return
;
+}
+
+export const Dropdown = {
+ Root,
+ Trigger,
+ Menu,
+ Item,
+ Divider,
+};
diff --git a/frontend/shared/components/EmptyState.css b/frontend/shared/components/EmptyState.css
new file mode 100644
index 000000000..e78db8b66
--- /dev/null
+++ b/frontend/shared/components/EmptyState.css
@@ -0,0 +1,57 @@
+.sui-empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ padding: var(--space-6) var(--space-4);
+ gap: var(--space-2);
+}
+
+.sui-empty--compact {
+ padding: var(--space-4) var(--space-3);
+}
+
+.sui-empty__icon {
+ display: inline-flex;
+ margin-bottom: var(--space-2);
+ color: var(--color-text-4);
+}
+
+.sui-empty__eyebrow {
+ font-size: 0.75rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--color-blue);
+}
+
+.sui-empty__title {
+ margin: 0;
+ font-size: 1.125rem;
+ font-weight: 600;
+ color: var(--color-text-1);
+}
+
+.sui-empty--compact .sui-empty__title {
+ font-size: 0.9375rem;
+}
+
+.sui-empty__copy {
+ margin: 0;
+ max-width: 32rem;
+ font-size: 0.875rem;
+ line-height: 1.5;
+ color: var(--color-text-3);
+}
+
+.sui-empty--compact .sui-empty__copy {
+ font-size: 0.8125rem;
+}
+
+.sui-empty__actions {
+ margin-top: var(--space-2);
+ display: flex;
+ gap: var(--space-2);
+ flex-wrap: wrap;
+ justify-content: center;
+}
diff --git a/frontend/shared/components/EmptyState.stories.tsx b/frontend/shared/components/EmptyState.stories.tsx
new file mode 100644
index 000000000..e3fc1f496
--- /dev/null
+++ b/frontend/shared/components/EmptyState.stories.tsx
@@ -0,0 +1,52 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { EmptyState } from "@shared/components/EmptyState";
+import { Button } from "@shared/components/Button";
+import { Card } from "@shared/components/Card";
+
+const meta: Meta = {
+ title: "Primitives/EmptyState",
+ component: EmptyState,
+ tags: ["autodocs"],
+ parameters: { layout: "padded" },
+ args: {
+ title: "Nothing here yet",
+ description: "When pipelines are deployed they'll appear in this list.",
+ size: "default",
+ },
+ argTypes: {
+ size: { control: "inline-radio", options: ["default", "compact"] },
+ },
+};
+export default meta;
+type Story = StoryObj;
+
+/** Flip title / description / eyebrow / size / actions in controls. */
+export const Playground: Story = {};
+
+export const WithCTAs: Story = {
+ args: {
+ eyebrow: "No pipelines yet",
+ title: "Start from a template — or compose from scratch.",
+ description:
+ "The fastest way in is forking a pre-bundled pipeline like PII Sweep or Compliance Pack.",
+ actions: (
+ <>
+ →}>
+ Browse templates
+
+ Build from scratch
+ >
+ ),
+ },
+};
+
+export const InCard: Story = {
+ render: () => (
+
+
+
+ ),
+};
diff --git a/frontend/shared/components/EmptyState.tsx b/frontend/shared/components/EmptyState.tsx
new file mode 100644
index 000000000..1981c0a91
--- /dev/null
+++ b/frontend/shared/components/EmptyState.tsx
@@ -0,0 +1,51 @@
+import type { ReactNode } from "react";
+import "@shared/components/EmptyState.css";
+
+export interface EmptyStateProps {
+ /** Eyebrow text shown above the title (e.g. "No pipelines yet"). */
+ eyebrow?: ReactNode;
+ /** Headline. */
+ title: ReactNode;
+ /** One- or two-line body copy. */
+ description?: ReactNode;
+ /** Optional visual at the top (icon, illustration, etc). */
+ icon?: ReactNode;
+ /** Primary + secondary CTAs. */
+ actions?: ReactNode;
+ /** Visual size. `compact` removes the icon row's padding for inline use. */
+ size?: "compact" | "default";
+ className?: string;
+}
+
+/**
+ * Centered "nothing here yet" panel. Replaces the bespoke empty cards inline
+ * in Pipelines (free tier), NotificationsDropdown (all caught up), and the
+ * search modal (no matches).
+ */
+export function EmptyState({
+ eyebrow,
+ title,
+ description,
+ icon,
+ actions,
+ size = "default",
+ className,
+}: EmptyStateProps) {
+ return (
+
+ {icon && (
+
+ {icon}
+
+ )}
+ {eyebrow &&
{eyebrow}
}
+
{title}
+ {description &&
{description}
}
+ {actions &&
{actions}
}
+
+ );
+}
diff --git a/frontend/shared/components/FormField.css b/frontend/shared/components/FormField.css
new file mode 100644
index 000000000..0ae531bc6
--- /dev/null
+++ b/frontend/shared/components/FormField.css
@@ -0,0 +1,33 @@
+.sui-field {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+ min-width: 0;
+}
+
+.sui-field__label {
+ font-size: 0.75rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--color-section-label);
+}
+
+.sui-field__required {
+ color: var(--color-red);
+}
+
+.sui-field__control {
+ display: flex;
+ flex-direction: column;
+}
+
+.sui-field__help {
+ font-size: 0.6875rem;
+ color: var(--color-text-4);
+ line-height: 1.45;
+}
+
+.sui-field--error .sui-field__help {
+ color: var(--color-red);
+}
diff --git a/frontend/shared/components/FormField.tsx b/frontend/shared/components/FormField.tsx
new file mode 100644
index 000000000..dc84d2715
--- /dev/null
+++ b/frontend/shared/components/FormField.tsx
@@ -0,0 +1,79 @@
+import {
+ cloneElement,
+ isValidElement,
+ useId,
+ type ReactElement,
+ type ReactNode,
+} from "react";
+import "@shared/components/FormField.css";
+
+export interface FormFieldProps {
+ label?: ReactNode;
+ /** Helper text shown under the control. Replaced by `error` when present. */
+ helperText?: ReactNode;
+ /** Error string. Causes the control + helper region to swap to the error tone. */
+ error?: ReactNode;
+ required?: boolean;
+ /** A single form control as a React element. Receives id + aria-* props. */
+ children: ReactElement<{
+ id?: string;
+ "aria-invalid"?: boolean;
+ "aria-describedby"?: string;
+ required?: boolean;
+ }>;
+ className?: string;
+}
+
+/**
+ * Standardised label + control + helper/error wrapper. Works with the bare
+ * native controls in this design system (Input, Select, Checkbox, Radio,
+ * Slider) or with any third-party control that accepts `id` + `aria-*`.
+ */
+export function FormField({
+ label,
+ helperText,
+ error,
+ required,
+ children,
+ className,
+}: FormFieldProps) {
+ const autoId = useId();
+ if (!isValidElement(children)) {
+ throw new Error("FormField requires exactly one React element child");
+ }
+ const controlId = children.props.id ?? autoId;
+ const describedById = error || helperText ? `${controlId}-help` : undefined;
+
+ const child = cloneElement(children, {
+ id: controlId,
+ "aria-invalid": error ? true : undefined,
+ "aria-describedby": describedById,
+ required: required ?? children.props.required,
+ });
+
+ return (
+
+ {label && (
+
+ {label}
+ {required && (
+
+ {" "}
+ *
+
+ )}
+
+ )}
+
{child}
+ {(error || helperText) && (
+
+ {error ?? helperText}
+
+ )}
+
+ );
+}
diff --git a/frontend/shared/components/Forms.stories.tsx b/frontend/shared/components/Forms.stories.tsx
new file mode 100644
index 000000000..d38bc3834
--- /dev/null
+++ b/frontend/shared/components/Forms.stories.tsx
@@ -0,0 +1,303 @@
+import { useState } from "react";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { FormField } from "@shared/components/FormField";
+import { Input } from "@shared/components/Input";
+import { Select } from "@shared/components/Select";
+import { Checkbox } from "@shared/components/Checkbox";
+import { RadioGroup } from "@shared/components/Radio";
+import { Slider } from "@shared/components/Slider";
+import { Stack } from "@shared/components/Stack";
+import { Inline } from "@shared/components/Inline";
+
+// Inline icon to avoid a cross-layer import; shared/ must not depend on portal/.
+function SearchIcon({ size = 14 }: { size?: number }) {
+ return (
+
+
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Primitives/Forms",
+ parameters: { layout: "padded" },
+ decorators: [
+ (S) => (
+
+
+
+ ),
+ ],
+};
+export default meta;
+type Story = StoryObj;
+
+export const Input_Default: Story = {
+ render: () => (
+
+
+
+ ),
+};
+
+export const Input_WithIcon: Story = {
+ render: () => (
+
+ }
+ placeholder="Search Stirling…"
+ />
+
+ ),
+};
+
+export const Input_Error: Story = {
+ render: () => (
+
+ {}} />
+
+ ),
+};
+
+export const Select_Default: Story = {
+ render: () => (
+
+
+
+ ),
+};
+
+export const Checkbox_Single: Story = {
+ render: () => (
+
+
+
+
+
+
+
+ ),
+};
+
+export const Checkbox_GridOfCategories: Story = {
+ render: () => (
+
+
+ {["SSN", "DOB", "Accounts", "Contacts", "Names", "Addresses"].map(
+ (c) => (
+
+ ),
+ )}
+
+
+ ),
+};
+
+export const Radio_Group: Story = {
+ render: () => {
+ function Bound() {
+ const [mode, setMode] = useState<"stirling" | "byok" | "hyok">(
+ "stirling",
+ );
+ return (
+
+
+
+ );
+ }
+ return ;
+ },
+};
+
+export const Radio_Horizontal: Story = {
+ render: () => {
+ function Bound() {
+ const [v, setV] = useState("us");
+ return (
+
+
+
+ );
+ }
+ return ;
+ },
+};
+
+export const Slider_Confidence: Story = {
+ render: () => {
+ function Bound() {
+ const [v, setV] = useState(0.85);
+ return (
+
+ x.toFixed(2)}
+ />
+
+ );
+ }
+ return ;
+ },
+};
+
+export const Slider_Retention: Story = {
+ render: () => {
+ function Bound() {
+ const [days, setDays] = useState(90);
+ return (
+
+ `${d} days`}
+ />
+
+ );
+ }
+ return ;
+ },
+};
+
+export const FullForm: Story = {
+ render: () => {
+ function Form() {
+ const [name, setName] = useState("");
+ const [retention, setRetention] = useState("90");
+ const [mode, setMode] = useState<"stirling" | "byok" | "hyok">(
+ "stirling",
+ );
+ const [conf, setConf] = useState(0.85);
+ const [notify, setNotify] = useState(true);
+ const [review, setReview] = useState(false);
+ return (
+
+
+ setName(e.target.value)}
+ placeholder="e.g. coi-compliance"
+ />
+
+
+ setRetention(e.target.value)}
+ options={[
+ { value: "30", label: "30 days" },
+ { value: "90", label: "90 days" },
+ { value: "365", label: "1 year" },
+ ]}
+ />
+
+
+
+
+
+ v.toFixed(2)}
+ />
+
+
+
+ setNotify(e.target.checked)}
+ label="Notify on failure"
+ />
+ setReview(e.target.checked)}
+ label="Send low-confidence to review"
+ />
+
+
+
+ );
+ }
+ return ;
+ },
+};
diff --git a/frontend/shared/components/Inline.css b/frontend/shared/components/Inline.css
new file mode 100644
index 000000000..ec6d5314b
--- /dev/null
+++ b/frontend/shared/components/Inline.css
@@ -0,0 +1,76 @@
+.sui-inline {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ min-width: 0;
+}
+
+.sui-inline--nowrap {
+ flex-wrap: nowrap;
+}
+
+.sui-inline--gap-0 {
+ gap: var(--space-0);
+}
+.sui-inline--gap-0_5 {
+ gap: var(--space-0_5);
+}
+.sui-inline--gap-1 {
+ gap: var(--space-1);
+}
+.sui-inline--gap-1_5 {
+ gap: var(--space-1_5);
+}
+.sui-inline--gap-2 {
+ gap: var(--space-2);
+}
+.sui-inline--gap-3 {
+ gap: var(--space-3);
+}
+.sui-inline--gap-4 {
+ gap: var(--space-4);
+}
+.sui-inline--gap-5 {
+ gap: var(--space-5);
+}
+.sui-inline--gap-6 {
+ gap: var(--space-6);
+}
+.sui-inline--gap-8 {
+ gap: var(--space-8);
+}
+
+.sui-inline--align-stretch {
+ align-items: stretch;
+}
+.sui-inline--align-start {
+ align-items: flex-start;
+}
+.sui-inline--align-center {
+ align-items: center;
+}
+.sui-inline--align-end {
+ align-items: flex-end;
+}
+.sui-inline--align-baseline {
+ align-items: baseline;
+}
+
+.sui-inline--justify-start {
+ justify-content: flex-start;
+}
+.sui-inline--justify-center {
+ justify-content: center;
+}
+.sui-inline--justify-end {
+ justify-content: flex-end;
+}
+.sui-inline--justify-between {
+ justify-content: space-between;
+}
+.sui-inline--justify-around {
+ justify-content: space-around;
+}
+.sui-inline--justify-evenly {
+ justify-content: space-evenly;
+}
diff --git a/frontend/shared/components/Inline.stories.tsx b/frontend/shared/components/Inline.stories.tsx
new file mode 100644
index 000000000..2737d956e
--- /dev/null
+++ b/frontend/shared/components/Inline.stories.tsx
@@ -0,0 +1,54 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { Inline } from "@shared/components/Inline";
+import { Button } from "@shared/components/Button";
+import { StatusBadge } from "@shared/components/StatusBadge";
+
+const meta: Meta = {
+ title: "Primitives/Layout/Inline",
+ component: Inline,
+ tags: ["autodocs"],
+ parameters: { layout: "padded" },
+};
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: () => (
+
+ Primary
+ Secondary
+ Cancel
+
+ ),
+};
+
+export const SpaceBetween: Story = {
+ render: () => (
+
+ Pipeline name
+
+ healthy
+
+
+ ),
+};
+
+export const Wrap: Story = {
+ render: () => (
+
+ {Array.from({ length: 12 }).map((_, i) => (
+
+ chip-{i + 1}
+
+ ))}
+
+ ),
+};
diff --git a/frontend/shared/components/Inline.tsx b/frontend/shared/components/Inline.tsx
new file mode 100644
index 000000000..2ee5f2ac9
--- /dev/null
+++ b/frontend/shared/components/Inline.tsx
@@ -0,0 +1,50 @@
+import type { ElementType, ReactNode, HTMLAttributes } from "react";
+import type {
+ StackGap,
+ StackAlign,
+ StackJustify,
+} from "@shared/components/Stack";
+import "@shared/components/Inline.css";
+
+export interface InlineProps extends HTMLAttributes {
+ /** Token-aligned gap between children (maps to `--space-*`). */
+ gap?: StackGap;
+ align?: StackAlign;
+ justify?: StackJustify;
+ /** Wrap to a new line when children overflow. Defaults to true. */
+ wrap?: boolean;
+ as?: ElementType;
+ children?: ReactNode;
+}
+
+/**
+ * Horizontal flex row with token-aligned gap. Sister to {@link Stack}.
+ * Wraps by default — pass `wrap={false}` for a strict single-line layout.
+ */
+export function Inline({
+ gap = "2",
+ align = "center",
+ justify,
+ wrap = true,
+ as,
+ className,
+ children,
+ ...rest
+}: InlineProps) {
+ const Tag: ElementType = as ?? "div";
+ const classes = [
+ "sui-inline",
+ `sui-inline--gap-${gap}`,
+ `sui-inline--align-${align}`,
+ justify ? `sui-inline--justify-${justify}` : "",
+ wrap ? "" : "sui-inline--nowrap",
+ className ?? "",
+ ]
+ .filter(Boolean)
+ .join(" ");
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/shared/components/Input.css b/frontend/shared/components/Input.css
new file mode 100644
index 000000000..09a7ca0cd
--- /dev/null
+++ b/frontend/shared/components/Input.css
@@ -0,0 +1,66 @@
+.sui-input {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: 0 var(--space-2);
+ background: var(--color-surface);
+ border: 1px solid var(--color-border-input);
+ border-radius: var(--radius-md);
+ color: var(--color-text-1);
+ transition:
+ border-color var(--motion-fast),
+ box-shadow var(--motion-fast);
+}
+
+.sui-input:focus-within {
+ border-color: var(--color-blue);
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-blue) 16%, transparent);
+}
+
+.sui-input--sm {
+ min-height: 1.75rem;
+ padding: 0 var(--space-2);
+}
+.sui-input--md {
+ min-height: 2.25rem;
+ padding: 0 var(--space-3);
+}
+
+.sui-input--invalid {
+ border-color: var(--color-red);
+}
+.sui-input--invalid:focus-within {
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-red) 16%, transparent);
+}
+
+.sui-input--disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.sui-input__el {
+ flex: 1 1 auto;
+ min-width: 0;
+ font: inherit;
+ font-size: 0.875rem;
+ background: transparent;
+ border: none;
+ color: inherit;
+ outline: none;
+ padding: 0;
+}
+
+.sui-input--sm .sui-input__el {
+ font-size: 0.8125rem;
+}
+
+.sui-input__el::placeholder {
+ color: var(--color-text-placeholder);
+}
+
+.sui-input__icon {
+ display: inline-flex;
+ align-items: center;
+ color: var(--color-text-4);
+ flex-shrink: 0;
+}
diff --git a/frontend/shared/components/Input.tsx b/frontend/shared/components/Input.tsx
new file mode 100644
index 000000000..61cf136b2
--- /dev/null
+++ b/frontend/shared/components/Input.tsx
@@ -0,0 +1,47 @@
+import { forwardRef, type InputHTMLAttributes, type ReactNode } from "react";
+import "@shared/components/Input.css";
+
+export type InputSize = "sm" | "md";
+
+export interface InputProps extends Omit<
+ InputHTMLAttributes,
+ "size"
+> {
+ /** Visual size. Distinct from the HTML `size` attribute (which is for character width). */
+ inputSize?: InputSize;
+ leadingIcon?: ReactNode;
+ trailingIcon?: ReactNode;
+ /** Force the error tone independently of FormField. */
+ invalid?: boolean;
+}
+
+export const Input = forwardRef(function Input(
+ { inputSize = "md", leadingIcon, trailingIcon, invalid, className, ...rest },
+ ref,
+) {
+ return (
+
+ {leadingIcon && (
+
+ {leadingIcon}
+
+ )}
+
+ {trailingIcon && (
+
+ {trailingIcon}
+
+ )}
+
+ );
+});
diff --git a/frontend/shared/components/MethodBadge.css b/frontend/shared/components/MethodBadge.css
new file mode 100644
index 000000000..75703b92d
--- /dev/null
+++ b/frontend/shared/components/MethodBadge.css
@@ -0,0 +1,36 @@
+.sui-method {
+ display: inline-block;
+ font-family: var(--font-mono);
+ font-size: 0.6875rem;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ padding: 0.125rem 0.4375rem;
+ border-radius: var(--radius-sm);
+ border: 1px solid transparent;
+ line-height: 1.4;
+}
+.sui-method--get {
+ color: var(--color-green);
+ background: var(--color-green-light);
+ border-color: var(--color-green-border);
+}
+.sui-method--post {
+ color: var(--color-blue);
+ background: var(--color-blue-light);
+ border-color: var(--color-blue-border);
+}
+.sui-method--put {
+ color: var(--color-amber-dark);
+ background: var(--color-amber-light);
+ border-color: var(--color-amber-border);
+}
+.sui-method--patch {
+ color: var(--color-purple);
+ background: var(--color-purple-light);
+ border-color: var(--color-purple-border);
+}
+.sui-method--delete {
+ color: var(--color-red);
+ background: var(--color-red-light);
+ border-color: var(--color-red-border);
+}
diff --git a/frontend/shared/components/MethodBadge.stories.tsx b/frontend/shared/components/MethodBadge.stories.tsx
new file mode 100644
index 000000000..698689f86
--- /dev/null
+++ b/frontend/shared/components/MethodBadge.stories.tsx
@@ -0,0 +1,42 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { MethodBadge, type HttpMethod } from "@shared/components/MethodBadge";
+
+const meta: Meta = {
+ title: "Primitives/MethodBadge",
+ component: MethodBadge,
+ parameters: { layout: "centered" },
+ args: { method: "POST" },
+ argTypes: {
+ method: {
+ control: "inline-radio",
+ options: ["GET", "POST", "PUT", "PATCH", "DELETE"] satisfies HttpMethod[],
+ },
+ },
+};
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const InRow: Story = {
+ render: () => (
+
+
+
+ /v1/coi
+
+
+ ),
+};
+
+export const Matrix: Story = {
+ render: () => (
+
+ {(["GET", "POST", "PUT", "PATCH", "DELETE"] as const).map((m) => (
+
+ ))}
+
+ ),
+};
diff --git a/frontend/shared/components/MethodBadge.tsx b/frontend/shared/components/MethodBadge.tsx
new file mode 100644
index 000000000..d64ad3a5e
--- /dev/null
+++ b/frontend/shared/components/MethodBadge.tsx
@@ -0,0 +1,28 @@
+import "@shared/components/MethodBadge.css";
+
+export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
+
+export interface MethodBadgeProps {
+ method: HttpMethod;
+ className?: string;
+}
+
+/**
+ * Method chip used in the docs and the pipeline row to label HTTP verbs.
+ * Colours match the prototype: GET green, POST blue, PUT amber, DELETE red.
+ */
+export function MethodBadge({ method, className }: MethodBadgeProps) {
+ return (
+
+ {method}
+
+ );
+}
diff --git a/frontend/shared/components/MetricCard.css b/frontend/shared/components/MetricCard.css
new file mode 100644
index 000000000..03cc4c154
--- /dev/null
+++ b/frontend/shared/components/MetricCard.css
@@ -0,0 +1,84 @@
+.sui-metric {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ padding: 1.125rem 1.25rem;
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-md);
+ transition:
+ box-shadow var(--motion-fast),
+ transform var(--motion-fast),
+ border-color var(--motion-fast);
+ min-width: 10rem;
+ /* When rendered as in the interactive variant, neutralise UA chrome. */
+ font: inherit;
+ color: inherit;
+ text-align: left;
+}
+.sui-metric--primary {
+ background: var(--color-bg-subtle);
+}
+.sui-metric--interactive {
+ cursor: pointer;
+}
+.sui-metric--interactive:hover {
+ border-color: var(--color-border-hover);
+ box-shadow: var(--shadow-lg);
+ transform: translateY(-0.0625rem);
+}
+.sui-metric--interactive:focus-visible {
+ outline: 2px solid var(--color-blue);
+ outline-offset: 2px;
+}
+
+.sui-metric__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+}
+.sui-metric__label {
+ font-size: 0.75rem;
+ color: var(--color-text-4);
+ font-weight: 500;
+ letter-spacing: 0.01em;
+}
+.sui-metric__icon {
+ color: var(--color-text-5);
+ display: inline-flex;
+}
+.sui-metric__value {
+ font-size: 1.625rem;
+ font-weight: 600;
+ color: var(--color-text-1);
+ line-height: 1.1;
+}
+.sui-metric__footer {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.75rem;
+}
+.sui-metric__delta {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.1875rem;
+ font-weight: 500;
+}
+.sui-metric__delta-arrow {
+ font-size: 0.6875rem;
+}
+.sui-metric__delta--up {
+ color: var(--color-green);
+}
+.sui-metric__delta--down {
+ color: var(--color-red);
+}
+.sui-metric__delta--flat {
+ color: var(--color-text-5);
+}
+.sui-metric__desc {
+ color: var(--color-text-4);
+}
diff --git a/frontend/shared/components/MetricCard.stories.tsx b/frontend/shared/components/MetricCard.stories.tsx
new file mode 100644
index 000000000..2a63397c0
--- /dev/null
+++ b/frontend/shared/components/MetricCard.stories.tsx
@@ -0,0 +1,87 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { MetricCard } from "@shared/components/MetricCard";
+
+const meta: Meta = {
+ title: "Primitives/MetricCard",
+ component: MetricCard,
+ tags: ["autodocs"],
+ parameters: { layout: "padded" },
+ args: {
+ label: "Docs / 24h",
+ value: "2,481",
+ delta: 0.07,
+ emphasis: "default",
+ },
+ argTypes: {
+ emphasis: { control: "inline-radio", options: ["default", "primary"] },
+ deltaDirection: {
+ control: "inline-radio",
+ options: ["up", "down", "flat", undefined],
+ },
+ delta: { control: { type: "number", step: 0.01 } },
+ },
+ decorators: [
+ (S) => (
+
+
+
+ ),
+ ],
+};
+export default meta;
+type Story = StoryObj;
+
+/** Flip value / delta / description / emphasis in controls. */
+export const Playground: Story = {};
+
+export const ProTierStrip: Story = {
+ decorators: [
+ (S) => (
+
+
+
+ ),
+ ],
+ render: () => (
+
+
+
+
+
+
+ ),
+};
+
+export const FreeTierStrip: Story = {
+ decorators: [
+ (S) => (
+
+
+
+ ),
+ ],
+ render: () => (
+
+
+
+
+
+
+ ),
+};
diff --git a/frontend/shared/components/MetricCard.tsx b/frontend/shared/components/MetricCard.tsx
new file mode 100644
index 000000000..9771a9821
--- /dev/null
+++ b/frontend/shared/components/MetricCard.tsx
@@ -0,0 +1,98 @@
+import type { ReactNode } from "react";
+import "@shared/components/MetricCard.css";
+
+export type DeltaDirection = "up" | "down" | "flat";
+
+export interface MetricCardProps {
+ label: string;
+ value: string | number;
+ /** Optional description shown under the delta line. */
+ description?: string;
+ /** Numeric delta as a fraction (0.12 = +12%). The sign drives direction unless `deltaDirection` is set. */
+ delta?: number;
+ /** Override the inferred direction — useful when you only want the colour, not the value. */
+ deltaDirection?: DeltaDirection;
+ /** Visual emphasis. `primary` = darker surface, used for hero metrics. */
+ emphasis?: "default" | "primary";
+ /** Optional icon shown in the top-right corner. */
+ icon?: ReactNode;
+ onClick?: () => void;
+ className?: string;
+}
+
+function inferDirection(delta?: number): DeltaDirection {
+ if (delta === undefined || delta === 0) return "flat";
+ return delta > 0 ? "up" : "down";
+}
+
+function formatDelta(delta: number) {
+ const pct = Math.round(Math.abs(delta) * 100);
+ return `${pct}%`;
+}
+
+/**
+ * Stirling's standard KPI card. Used on Home, Sources, Documents, Audit and
+ * every Infrastructure tab — the prototype calls these the metric strip.
+ */
+export function MetricCard({
+ label,
+ value,
+ description,
+ delta,
+ deltaDirection,
+ emphasis = "default",
+ icon,
+ onClick,
+ className,
+}: MetricCardProps) {
+ const dir = deltaDirection ?? inferDirection(delta);
+ const interactive = !!onClick;
+
+ const classes = [
+ "sui-metric",
+ emphasis === "primary" ? "sui-metric--primary" : "",
+ interactive ? "sui-metric--interactive" : "",
+ className ?? "",
+ ]
+ .filter(Boolean)
+ .join(" ");
+
+ const body = (
+ <>
+
+ {label}
+ {icon && (
+
+ {icon}
+
+ )}
+
+ {value}
+ {(delta !== undefined || description) && (
+
+ {delta !== undefined && (
+
+
+ {dir === "up" ? "↑" : dir === "down" ? "↓" : "·"}
+
+ {formatDelta(delta)}
+
+ )}
+ {description && (
+ {description}
+ )}
+
+ )}
+ >
+ );
+
+ if (interactive) {
+ return (
+
+ {body}
+
+ );
+ }
+
+ return {body}
;
+}
diff --git a/frontend/shared/components/Modal.css b/frontend/shared/components/Modal.css
new file mode 100644
index 000000000..235f2f4cc
--- /dev/null
+++ b/frontend/shared/components/Modal.css
@@ -0,0 +1,99 @@
+.sui-modal__backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(15, 23, 42, 0.55);
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ padding: 5rem 1.5rem 1.5rem;
+ z-index: 100;
+ animation: fadeIn 0.18s ease both;
+ overscroll-behavior: contain;
+}
+
+.sui-modal {
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-xl);
+ box-shadow:
+ 0 1.25rem 3rem rgba(15, 23, 42, 0.35),
+ 0 0 0 1px var(--color-border-light);
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ max-height: calc(100vh - 6.5rem);
+ overflow: hidden;
+ animation: scaleIn 0.2s cubic-bezier(0.4, 0, 0.2, 1) both;
+}
+
+.sui-modal--sm {
+ max-width: 24rem;
+}
+.sui-modal--md {
+ max-width: 32rem;
+}
+.sui-modal--lg {
+ max-width: 48rem;
+}
+.sui-modal--xl {
+ max-width: 64rem;
+}
+
+.sui-modal__header {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.75rem;
+ padding: 1rem 1.125rem 0.75rem;
+ border-bottom: 1px solid var(--color-border-light);
+}
+
+.sui-modal__header-text {
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+.sui-modal__title {
+ font-size: 0.9375rem;
+ font-weight: 600;
+ color: var(--color-text-1);
+}
+
+.sui-modal__sub {
+ margin-top: 0.125rem;
+ font-size: 0.8125rem;
+ color: var(--color-text-4);
+}
+
+.sui-modal__close {
+ flex: 0 0 auto;
+ width: 1.75rem;
+ height: 1.75rem;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: var(--radius-sm);
+ color: var(--color-text-4);
+ transition:
+ background var(--motion-fast),
+ color var(--motion-fast);
+}
+
+.sui-modal__close:hover {
+ background: var(--color-bg-hover);
+ color: var(--color-text-1);
+}
+
+.sui-modal__body {
+ flex: 1 1 auto;
+ overflow-y: auto;
+ padding: 1rem 1.125rem 1.125rem;
+ color: var(--color-text-2);
+}
+
+.sui-modal__footer {
+ padding: 0.875rem 1.125rem;
+ border-top: 1px solid var(--color-border-light);
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.5rem;
+}
diff --git a/frontend/shared/components/Modal.tsx b/frontend/shared/components/Modal.tsx
new file mode 100644
index 000000000..f12985a15
--- /dev/null
+++ b/frontend/shared/components/Modal.tsx
@@ -0,0 +1,171 @@
+import { useEffect, useId, useRef, type ReactNode } from "react";
+import { createPortal } from "react-dom";
+import "@shared/components/Modal.css";
+
+export type ModalWidth = "sm" | "md" | "lg" | "xl";
+
+export interface ModalProps {
+ open: boolean;
+ onClose: () => void;
+ /** Optional heading slot rendered above the body. */
+ title?: ReactNode;
+ /** Optional sub-heading rendered under the title. */
+ subtitle?: ReactNode;
+ /** Optional footer slot rendered below the body. */
+ footer?: ReactNode;
+ /** Width preset. sm=24rem, md=32rem, lg=48rem, xl=64rem. Defaults to md. */
+ width?: ModalWidth;
+ /** Disable click-on-backdrop dismissal. Defaults to false. */
+ disableBackdropClose?: boolean;
+ /** Disable Escape-key dismissal. Defaults to false. */
+ disableEscapeClose?: boolean;
+ /** Accessible name when no visible `title` is provided. */
+ ariaLabel?: string;
+ className?: string;
+ children?: ReactNode;
+}
+
+const FOCUSABLE_SELECTOR =
+ 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
+
+/**
+ * Portal-rendered modal. Backdrop fades in, dialog scales in. ESC and
+ * click-on-backdrop close by default. Each instance is self-contained — the
+ * caller owns the open state and the close handler.
+ *
+ * Focus is trapped inside the dialog while open and returned to the
+ * previously-focused element on close.
+ */
+export function Modal({
+ open,
+ onClose,
+ title,
+ subtitle,
+ footer,
+ width = "md",
+ disableBackdropClose = false,
+ disableEscapeClose = false,
+ ariaLabel,
+ className,
+ children,
+}: ModalProps) {
+ const dialogRef = useRef(null);
+ const titleId = useId();
+
+ useEffect(() => {
+ if (!open || disableEscapeClose) return;
+ function onKey(e: KeyboardEvent) {
+ if (e.key === "Escape") onClose();
+ }
+ document.addEventListener("keydown", onKey);
+ return () => document.removeEventListener("keydown", onKey);
+ }, [open, disableEscapeClose, onClose]);
+
+ useEffect(() => {
+ if (!open) return;
+ const previous = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+ return () => {
+ document.body.style.overflow = previous;
+ };
+ }, [open]);
+
+ useEffect(() => {
+ if (!open) return;
+ const previouslyFocused = document.activeElement as HTMLElement | null;
+ const dialog = dialogRef.current;
+ const first = dialog?.querySelector(FOCUSABLE_SELECTOR);
+ first?.focus();
+
+ function onKey(e: KeyboardEvent) {
+ if (e.key !== "Tab" || !dialog) return;
+ const focusables =
+ dialog.querySelectorAll(FOCUSABLE_SELECTOR);
+ if (focusables.length === 0) {
+ e.preventDefault();
+ return;
+ }
+ const firstEl = focusables[0];
+ const lastEl = focusables[focusables.length - 1];
+ const active = document.activeElement;
+ if (e.shiftKey && active === firstEl) {
+ e.preventDefault();
+ lastEl.focus();
+ } else if (!e.shiftKey && active === lastEl) {
+ e.preventDefault();
+ firstEl.focus();
+ }
+ }
+
+ document.addEventListener("keydown", onKey);
+ return () => {
+ document.removeEventListener("keydown", onKey);
+ previouslyFocused?.focus?.();
+ };
+ }, [open]);
+
+ if (!open) return null;
+
+ function onBackdropClick() {
+ if (!disableBackdropClose) onClose();
+ }
+
+ const hasTitle = title !== undefined && title !== null;
+
+ return createPortal(
+
+
e.stopPropagation()}
+ role="dialog"
+ aria-modal="true"
+ aria-labelledby={hasTitle ? titleId : undefined}
+ aria-label={!hasTitle ? ariaLabel : undefined}
+ >
+ {(title || subtitle) && (
+
+
+ {title && (
+
+ {title}
+
+ )}
+ {subtitle &&
{subtitle}
}
+
+
+
+
+
+
+
+
+ )}
+
{children}
+ {footer &&
}
+
+
,
+ document.body,
+ );
+}
diff --git a/frontend/shared/components/NavItem.css b/frontend/shared/components/NavItem.css
new file mode 100644
index 000000000..d70c11378
--- /dev/null
+++ b/frontend/shared/components/NavItem.css
@@ -0,0 +1,47 @@
+.sui-navitem {
+ display: flex;
+ align-items: center;
+ gap: 0.625rem;
+ width: 100%;
+ padding: 0.4375rem 0.75rem;
+ margin: 0.0625rem 0.5rem;
+ border-radius: var(--radius-lg);
+ color: var(--color-nav-text);
+ font-size: 0.8125rem;
+ font-weight: 400;
+ transition:
+ background var(--motion-fast),
+ color var(--motion-fast);
+ text-align: left;
+}
+.sui-navitem:hover {
+ background: var(--color-nav-hover);
+ color: var(--color-nav-hover-text);
+}
+.sui-navitem.is-active {
+ background: var(--color-nav-active);
+ color: var(--color-nav-active-text);
+ font-weight: 500;
+}
+.sui-navitem.is-active:hover {
+ background: var(--color-nav-active);
+}
+.sui-navitem__icon {
+ width: 1rem;
+ height: 1rem;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+.sui-navitem__label {
+ flex: 1;
+}
+.sui-navitem__trailing {
+ display: inline-flex;
+ align-items: center;
+}
+.sui-navitem:focus-visible {
+ outline: 0.125rem solid var(--color-blue);
+ outline-offset: 0.125rem;
+}
diff --git a/frontend/shared/components/NavItem.stories.tsx b/frontend/shared/components/NavItem.stories.tsx
new file mode 100644
index 000000000..2cf5794a7
--- /dev/null
+++ b/frontend/shared/components/NavItem.stories.tsx
@@ -0,0 +1,106 @@
+import { useState } from "react";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { NavItem } from "@shared/components/NavItem";
+import { SectionDivider } from "@shared/components/SectionDivider";
+
+function Dot({ color = "var(--color-blue)" }: { color?: string }) {
+ return (
+
+ );
+}
+
+const meta: Meta = {
+ title: "Primitives/NavItem",
+ component: NavItem,
+ tags: ["autodocs"],
+ parameters: { layout: "padded" },
+ args: { id: "home", label: "Home", isActive: false },
+ argTypes: { isActive: { control: "boolean" } },
+ decorators: [
+ (S) => (
+
+
+
+ ),
+ ],
+};
+export default meta;
+type Story = StoryObj;
+
+/** Flip isActive / label / icon / trailing in controls. */
+export const Playground: Story = {
+ args: { icon: },
+};
+
+export const WithTrailingBadge: Story = {
+ args: {
+ icon: ,
+ trailing: (
+
+ 3
+
+ ),
+ },
+};
+
+export const InContext_SidebarGroup: Story = {
+ render: () => {
+ function Bound() {
+ const [active, setActive] = useState("pipelines");
+ const items = [
+ { id: "home", label: "Home" },
+ { id: "editor", label: "Editor" },
+ { id: "sources", label: "Sources" },
+ { id: "pipelines", label: "Pipelines" },
+ { id: "documents", label: "Documents" },
+ ];
+ return (
+
+ }
+ isActive={active === "home"}
+ onClick={setActive}
+ />
+
+ {items.slice(1).map((item) => (
+ }
+ isActive={active === item.id}
+ onClick={setActive}
+ />
+ ))}
+
+ );
+ }
+ return ;
+ },
+};
diff --git a/frontend/shared/components/NavItem.tsx b/frontend/shared/components/NavItem.tsx
new file mode 100644
index 000000000..fd328bc0f
--- /dev/null
+++ b/frontend/shared/components/NavItem.tsx
@@ -0,0 +1,51 @@
+import type { ReactNode } from "react";
+import "@shared/components/NavItem.css";
+
+export interface NavItemProps {
+ /** Stable view id passed to the click handler. */
+ id: string;
+ label: string;
+ icon?: ReactNode;
+ /** Show the active highlight (navActive background, navActiveText colour). */
+ isActive?: boolean;
+ /** Optional trailing badge (e.g. unread count, "new"). */
+ trailing?: ReactNode;
+ onClick?: (id: string) => void;
+ className?: string;
+}
+
+/**
+ * Sidebar navigation row matching the prototype's hover + active styling.
+ *
+ * Active styling: navActive background, navActiveText colour, weight 500.
+ * Hover styling: navHover background, navHoverText colour (only when not
+ * already active).
+ */
+export function NavItem({
+ id,
+ label,
+ icon,
+ isActive,
+ trailing,
+ onClick,
+ className,
+}: NavItemProps) {
+ return (
+ onClick?.(id)}
+ className={["sui-navitem", isActive ? "is-active" : "", className ?? ""]
+ .filter(Boolean)
+ .join(" ")}
+ aria-current={isActive ? "page" : undefined}
+ >
+ {icon && (
+
+ {icon}
+
+ )}
+ {label}
+ {trailing && {trailing} }
+
+ );
+}
diff --git a/frontend/shared/components/PanelHeader.css b/frontend/shared/components/PanelHeader.css
new file mode 100644
index 000000000..df537d9b8
--- /dev/null
+++ b/frontend/shared/components/PanelHeader.css
@@ -0,0 +1,48 @@
+.sui-panelhdr {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 1rem 1.25rem;
+ border-bottom: 1px solid var(--color-border);
+}
+.sui-panelhdr__left {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ min-width: 0;
+}
+.sui-panelhdr__back {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 2rem;
+ height: 2rem;
+ border-radius: var(--radius-md);
+ color: var(--color-text-3);
+ transition:
+ background var(--motion-fast),
+ color var(--motion-fast);
+}
+.sui-panelhdr__back:hover {
+ background: var(--color-bg-hover);
+ color: var(--color-text-1);
+}
+.sui-panelhdr__text {
+ min-width: 0;
+}
+.sui-panelhdr__title {
+ font-size: 1.0625rem;
+ font-weight: 600;
+ color: var(--color-text-1);
+}
+.sui-panelhdr__sub {
+ font-size: 0.8125rem;
+ color: var(--color-text-4);
+ margin-top: 0.125rem;
+}
+.sui-panelhdr__actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+}
diff --git a/frontend/shared/components/PanelHeader.stories.tsx b/frontend/shared/components/PanelHeader.stories.tsx
new file mode 100644
index 000000000..1aa37b955
--- /dev/null
+++ b/frontend/shared/components/PanelHeader.stories.tsx
@@ -0,0 +1,61 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { PanelHeader } from "@shared/components/PanelHeader";
+import { Button } from "@shared/components/Button";
+import { StatusBadge } from "@shared/components/StatusBadge";
+
+const meta: Meta = {
+ title: "Primitives/PanelHeader",
+ component: PanelHeader,
+ tags: ["autodocs"],
+ parameters: { layout: "padded" },
+ args: {
+ title: "Pipeline detail",
+ subtitle: "COI Compliance · us-east-1",
+ },
+ argTypes: { onBack: { action: "back" } },
+};
+export default meta;
+type Story = StoryObj;
+
+/** Toggle title / subtitle / onBack / actions in controls. */
+export const Playground: Story = {};
+
+export const WithActions: Story = {
+ args: {
+ subtitle: "Last deploy 14m ago · golden set 48/48",
+ actions: (
+ <>
+
+ Healthy
+
+
+ Edit composition
+
+
+ View runs
+
+ >
+ ),
+ },
+};
+
+export const Everything: Story = {
+ args: {
+ title: "Pipeline detail — COI Compliance",
+ subtitle: "Forked from Compliance Pack · 1,287 docs / 24h",
+ onBack: () => {},
+ actions: (
+ <>
+
+ Healthy
+
+
+ Edit composition
+
+
+ View runs
+
+ >
+ ),
+ },
+};
diff --git a/frontend/shared/components/PanelHeader.tsx b/frontend/shared/components/PanelHeader.tsx
new file mode 100644
index 000000000..031edbf1d
--- /dev/null
+++ b/frontend/shared/components/PanelHeader.tsx
@@ -0,0 +1,60 @@
+import type { ReactNode } from "react";
+import "@shared/components/PanelHeader.css";
+
+export interface PanelHeaderProps {
+ title: ReactNode;
+ /** Sub-heading below the title. */
+ subtitle?: ReactNode;
+ /** Show a back chevron and trigger this callback when clicked. */
+ onBack?: () => void;
+ /** Right-aligned action buttons / chips. */
+ actions?: ReactNode;
+ className?: string;
+}
+
+/**
+ * Header strip used by drill-down panels (admin tabs, agent detail, settings
+ * sub-pages). Back chevron renders only when `onBack` is supplied.
+ */
+export function PanelHeader({
+ title,
+ subtitle,
+ onBack,
+ actions,
+ className,
+}: PanelHeaderProps) {
+ return (
+
+
+ {onBack && (
+
+
+
+
+
+ )}
+
+
{title}
+ {subtitle &&
{subtitle}
}
+
+
+ {actions &&
{actions}
}
+
+ );
+}
diff --git a/frontend/shared/components/ProgressBar.css b/frontend/shared/components/ProgressBar.css
new file mode 100644
index 000000000..9bee3de24
--- /dev/null
+++ b/frontend/shared/components/ProgressBar.css
@@ -0,0 +1,13 @@
+.sui-progress {
+ width: 100%;
+ background: var(--color-usage-track);
+ border-radius: 62.4375rem;
+ overflow: hidden;
+}
+.sui-progress__fill {
+ height: 100%;
+ border-radius: 62.4375rem;
+ transition:
+ width 0.5s cubic-bezier(0.4, 0, 0.2, 1),
+ background var(--motion-base);
+}
diff --git a/frontend/shared/components/ProgressBar.stories.tsx b/frontend/shared/components/ProgressBar.stories.tsx
new file mode 100644
index 000000000..bc7db818e
--- /dev/null
+++ b/frontend/shared/components/ProgressBar.stories.tsx
@@ -0,0 +1,95 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { ProgressBar } from "@shared/components/ProgressBar";
+
+const meta: Meta = {
+ title: "Primitives/ProgressBar",
+ component: ProgressBar,
+ tags: ["autodocs"],
+ parameters: { layout: "padded" },
+ args: { value: 0.5, height: 6, thresholded: false },
+ argTypes: {
+ value: { control: { type: "range", min: 0, max: 1, step: 0.01 } },
+ height: { control: { type: "number" } },
+ thresholded: { control: "boolean" },
+ },
+ decorators: [
+ (S) => (
+
+
+
+ ),
+ ],
+};
+export default meta;
+type Story = StoryObj;
+
+/** Drag the value slider, toggle thresholded, change height in controls. */
+export const Playground: Story = {};
+
+export const ThresholdLadder: Story = {
+ render: () => (
+
+ {[0, 0.25, 0.5, 0.79, 0.85, 0.95, 0.98, 1].map((v) => (
+
+
+ {Math.round(v * 100)}%
+
+
+
+ ))}
+
+ ),
+};
+
+export const InContext_UsageMeter: Story = {
+ decorators: [
+ (S) => (
+
+
+
+ ),
+ ],
+ render: () => (
+
+
+ Docs processed
+
+ 412 / 500
+
+
+
+
+ Approaching the free-plan cap
+
+
+ ),
+};
diff --git a/frontend/shared/components/ProgressBar.tsx b/frontend/shared/components/ProgressBar.tsx
new file mode 100644
index 000000000..cf3a9b9c7
--- /dev/null
+++ b/frontend/shared/components/ProgressBar.tsx
@@ -0,0 +1,70 @@
+import "@shared/components/ProgressBar.css";
+
+export interface ProgressBarProps {
+ /** 0–1. Values outside the range are clamped. */
+ value: number;
+ /** Height in pixels. Defaults to 6. */
+ height?: number;
+ /** When set, colour shifts to amber at 80% and red at 96% — the prototype's usage-meter behaviour. */
+ thresholded?: boolean;
+ /** Optional override colour (CSS gradient or solid). Disables threshold behaviour. */
+ color?: string;
+ className?: string;
+ /** Accessible label for screen readers. */
+ label?: string;
+}
+
+function clamp01(n: number) {
+ return Math.max(0, Math.min(1, n));
+}
+
+/**
+ * Progress bar with optional threshold-based colouring.
+ *
+ * The prototype's sidebar usage meter uses `thresholded` so the bar turns
+ * amber at 80% and red at 96% — a small visual hint that drives upgrade
+ * conversion. Pipeline progress / storage bars typically pass a fixed colour.
+ */
+export function ProgressBar({
+ value,
+ height = 6,
+ thresholded = false,
+ color,
+ className,
+ label,
+}: ProgressBarProps) {
+ const v = clamp01(value);
+ let fill = color;
+ if (!fill) {
+ // Second stop is a lighter mix of the same token so the gradient keeps its
+ // sheen in both themes (a hardcoded hex here flattened to a solid bar in
+ // dark mode, where the token already equalled that hex).
+ if (thresholded) {
+ fill =
+ v >= 0.96
+ ? "linear-gradient(90deg, var(--color-red), color-mix(in srgb, var(--color-red) 70%, white))"
+ : v >= 0.8
+ ? "linear-gradient(90deg, var(--color-amber), color-mix(in srgb, var(--color-amber) 70%, white))"
+ : "linear-gradient(90deg, var(--color-blue), color-mix(in srgb, var(--color-blue) 70%, white))";
+ } else {
+ fill =
+ "linear-gradient(90deg, var(--color-blue), color-mix(in srgb, var(--color-blue) 70%, white))";
+ }
+ }
+ return (
+
+ );
+}
diff --git a/frontend/shared/components/Radio.css b/frontend/shared/components/Radio.css
new file mode 100644
index 000000000..162d0a9d5
--- /dev/null
+++ b/frontend/shared/components/Radio.css
@@ -0,0 +1,77 @@
+.sui-radio-group {
+ display: flex;
+}
+.sui-radio-group--vertical {
+ flex-direction: column;
+ gap: var(--space-2);
+}
+.sui-radio-group--horizontal {
+ flex-direction: row;
+ gap: var(--space-4);
+ flex-wrap: wrap;
+}
+
+.sui-radio {
+ display: inline-flex;
+ align-items: flex-start;
+ gap: var(--space-2);
+ cursor: pointer;
+ font-size: 0.8125rem;
+ color: var(--color-text-2);
+}
+.sui-radio--disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.sui-radio__input {
+ position: absolute;
+ opacity: 0;
+ width: 0;
+ height: 0;
+}
+
+.sui-radio__dot {
+ flex: 0 0 auto;
+ width: 1rem;
+ height: 1rem;
+ margin-top: 0.0625rem;
+ border-radius: 50%;
+ border: 1.5px solid var(--color-border-hover);
+ background: var(--color-surface);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ transition: border-color var(--motion-fast);
+ position: relative;
+}
+
+.sui-radio__input:focus-visible + .sui-radio__dot {
+ outline: 2px solid var(--color-blue);
+ outline-offset: 2px;
+}
+
+.sui-radio__input:checked + .sui-radio__dot {
+ border-color: var(--color-blue);
+}
+
+.sui-radio__input:checked + .sui-radio__dot::after {
+ content: "";
+ width: 0.5rem;
+ height: 0.5rem;
+ border-radius: 50%;
+ background: var(--color-blue);
+}
+
+.sui-radio__text {
+ display: flex;
+ flex-direction: column;
+}
+.sui-radio__label {
+ color: var(--color-text-1);
+ font-weight: 500;
+}
+.sui-radio__desc {
+ font-size: 0.75rem;
+ color: var(--color-text-4);
+}
diff --git a/frontend/shared/components/Radio.tsx b/frontend/shared/components/Radio.tsx
new file mode 100644
index 000000000..64f2dfb3e
--- /dev/null
+++ b/frontend/shared/components/Radio.tsx
@@ -0,0 +1,88 @@
+import { type InputHTMLAttributes, type ReactNode } from "react";
+import "@shared/components/Radio.css";
+
+export interface RadioOption {
+ value: V;
+ label: ReactNode;
+ description?: ReactNode;
+ disabled?: boolean;
+}
+
+export interface RadioGroupProps {
+ name: string;
+ value: V;
+ onChange: (value: V) => void;
+ options: RadioOption[];
+ /** Layout. `vertical` stacks options; `horizontal` lays them inline. */
+ direction?: "vertical" | "horizontal";
+ className?: string;
+}
+
+/** Standalone single radio — usually consumed via {@link RadioGroup}. */
+export function Radio({
+ label,
+ description,
+ className,
+ ...rest
+}: Omit, "type"> & {
+ label?: ReactNode;
+ description?: ReactNode;
+}) {
+ return (
+
+
+
+ {(label || description) && (
+
+ {label && {label} }
+ {description && (
+ {description}
+ )}
+
+ )}
+
+ );
+}
+
+export function RadioGroup({
+ name,
+ value,
+ onChange,
+ options,
+ direction = "vertical",
+ className,
+}: RadioGroupProps) {
+ return (
+
+ {options.map((opt) => (
+ onChange(opt.value)}
+ disabled={opt.disabled}
+ label={opt.label}
+ description={opt.description}
+ />
+ ))}
+
+ );
+}
diff --git a/frontend/shared/components/SectionDivider.css b/frontend/shared/components/SectionDivider.css
new file mode 100644
index 000000000..8999f6f0a
--- /dev/null
+++ b/frontend/shared/components/SectionDivider.css
@@ -0,0 +1,5 @@
+.sui-divider {
+ height: 0.0625rem;
+ background: var(--color-sidebar-divider);
+ width: 100%;
+}
diff --git a/frontend/shared/components/SectionDivider.stories.tsx b/frontend/shared/components/SectionDivider.stories.tsx
new file mode 100644
index 000000000..b2b3f17ba
--- /dev/null
+++ b/frontend/shared/components/SectionDivider.stories.tsx
@@ -0,0 +1,46 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { SectionDivider } from "@shared/components/SectionDivider";
+
+const meta: Meta = {
+ title: "Primitives/SectionDivider",
+ component: SectionDivider,
+ tags: ["autodocs"],
+ parameters: { layout: "padded" },
+};
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: () => (
+
+
Section above
+
+
Section below
+
+ ),
+};
+
+export const InContext_SidebarGroups: Story = {
+ render: () => (
+
+
Home
+
+
Editor
+
Sources
+
Pipelines
+
+
Infrastructure
+
Usage
+
+ ),
+};
diff --git a/frontend/shared/components/SectionDivider.tsx b/frontend/shared/components/SectionDivider.tsx
new file mode 100644
index 000000000..6fe5556ed
--- /dev/null
+++ b/frontend/shared/components/SectionDivider.tsx
@@ -0,0 +1,25 @@
+import "@shared/components/SectionDivider.css";
+
+export interface SectionDividerProps {
+ /** Vertical margin in px. */
+ spacing?: number;
+ className?: string;
+}
+
+/**
+ * Hairline divider used between sidebar groups and inside settings panels.
+ * No section label — the prototype's deliberate Supabase pattern is to group
+ * via the divider alone, without forcing a category noun onto the surfaces.
+ */
+export function SectionDivider({
+ spacing = 12,
+ className,
+}: SectionDividerProps) {
+ return (
+
+ );
+}
diff --git a/frontend/shared/components/Select.css b/frontend/shared/components/Select.css
new file mode 100644
index 000000000..f97bf0f58
--- /dev/null
+++ b/frontend/shared/components/Select.css
@@ -0,0 +1,60 @@
+.sui-select {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ background: var(--color-surface);
+ border: 1px solid var(--color-border-input);
+ border-radius: var(--radius-md);
+ color: var(--color-text-1);
+ transition:
+ border-color var(--motion-fast),
+ box-shadow var(--motion-fast);
+}
+
+.sui-select:focus-within {
+ border-color: var(--color-blue);
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-blue) 16%, transparent);
+}
+
+.sui-select--sm {
+ min-height: 1.75rem;
+}
+.sui-select--md {
+ min-height: 2.25rem;
+}
+
+.sui-select--invalid {
+ border-color: var(--color-red);
+}
+.sui-select--disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.sui-select__el {
+ flex: 1 1 auto;
+ font: inherit;
+ font-size: 0.875rem;
+ background: transparent;
+ border: none;
+ color: inherit;
+ outline: none;
+ padding: 0 var(--space-6) 0 var(--space-3);
+ appearance: none;
+ -webkit-appearance: none;
+}
+
+.sui-select--sm .sui-select__el {
+ font-size: 0.8125rem;
+ padding-left: var(--space-2);
+ padding-right: var(--space-5);
+}
+
+.sui-select__caret {
+ position: absolute;
+ right: var(--space-2);
+ display: inline-flex;
+ align-items: center;
+ pointer-events: none;
+ color: var(--color-text-4);
+}
diff --git a/frontend/shared/components/Select.tsx b/frontend/shared/components/Select.tsx
new file mode 100644
index 000000000..432734221
--- /dev/null
+++ b/frontend/shared/components/Select.tsx
@@ -0,0 +1,69 @@
+import { forwardRef, type SelectHTMLAttributes } from "react";
+import "@shared/components/Select.css";
+
+export interface SelectOption {
+ value: string;
+ label: string;
+ disabled?: boolean;
+}
+
+export type SelectSize = "sm" | "md";
+
+export interface SelectProps extends Omit<
+ SelectHTMLAttributes,
+ "size"
+> {
+ inputSize?: SelectSize;
+ options: SelectOption[];
+ /** Optional placeholder rendered as a disabled first option. */
+ placeholder?: string;
+ invalid?: boolean;
+}
+
+export const Select = forwardRef(
+ function Select(
+ { inputSize = "md", options, placeholder, invalid, className, ...rest },
+ ref,
+ ) {
+ return (
+
+
+ {placeholder && (
+
+ {placeholder}
+
+ )}
+ {options.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+
+
+
+
+
+
+ );
+ },
+);
diff --git a/frontend/shared/components/Skeleton.css b/frontend/shared/components/Skeleton.css
new file mode 100644
index 000000000..98da3c7db
--- /dev/null
+++ b/frontend/shared/components/Skeleton.css
@@ -0,0 +1,30 @@
+.sui-skel {
+ display: inline-block;
+ background: linear-gradient(
+ 90deg,
+ var(--color-bg-muted) 0%,
+ var(--color-bg-hover) 50%,
+ var(--color-bg-muted) 100%
+ );
+ background-size: 200% 100%;
+ animation: shimmer 1.4s linear infinite;
+ vertical-align: middle;
+}
+
+.sui-skel--text {
+ border-radius: var(--radius-pill);
+}
+.sui-skel--rect {
+ border-radius: var(--radius-md);
+}
+.sui-skel--circle {
+ border-radius: 50%;
+ aspect-ratio: 1 / 1;
+}
+
+.sui-skel-lines {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+ width: 100%;
+}
diff --git a/frontend/shared/components/Skeleton.stories.tsx b/frontend/shared/components/Skeleton.stories.tsx
new file mode 100644
index 000000000..ed926004d
--- /dev/null
+++ b/frontend/shared/components/Skeleton.stories.tsx
@@ -0,0 +1,62 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { Skeleton } from "@shared/components/Skeleton";
+
+const meta: Meta = {
+ title: "Primitives/Skeleton",
+ component: Skeleton,
+ tags: ["autodocs"],
+ parameters: { layout: "padded" },
+ args: { width: "20rem", shape: "text", lines: 1 },
+ argTypes: {
+ shape: { control: "inline-radio", options: ["text", "rect", "circle"] },
+ lines: { control: { type: "number", min: 1, max: 8 } },
+ width: { control: "text" },
+ height: { control: "text" },
+ },
+};
+export default meta;
+type Story = StoryObj;
+
+/** Flip shape / lines / width / height in controls. */
+export const Playground: Story = {};
+
+export const InContext_TableRow: Story = {
+ render: () => (
+
+
+
+
+
+
+ ),
+};
+
+export const InContext_ActivityFeed: Story = {
+ render: () => (
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+ ),
+};
diff --git a/frontend/shared/components/Skeleton.tsx b/frontend/shared/components/Skeleton.tsx
new file mode 100644
index 000000000..b4fafc9b7
--- /dev/null
+++ b/frontend/shared/components/Skeleton.tsx
@@ -0,0 +1,67 @@
+import "@shared/components/Skeleton.css";
+
+export interface SkeletonProps {
+ /** Width as a CSS length (`100%`, `12rem`, …). Defaults to 100%. */
+ width?: string | number;
+ /** Height as a CSS length. Defaults to 0.75rem. */
+ height?: string | number;
+ /** Shape preset. `text` = pill-rounded line, `rect` = card / image area. */
+ shape?: "text" | "rect" | "circle";
+ /** For text shape, the number of stacked lines to render. */
+ lines?: number;
+ className?: string;
+}
+
+/**
+ * Shimmering placeholder for content that hasn't loaded yet. Replaces the
+ * ad-hoc `linear-gradient(...) shimmer 1.4s` blocks that were duplicated
+ * across the deployed-pipelines table, the activity feed, and the doc-type
+ * grid.
+ */
+export function Skeleton({
+ width = "100%",
+ height,
+ shape = "text",
+ lines = 1,
+ className,
+}: SkeletonProps) {
+ const dim = (v: string | number | undefined) =>
+ typeof v === "number" ? `${v}px` : v;
+
+ if (shape === "text" && lines > 1) {
+ return (
+
+ {Array.from({ length: lines }).map((_, i) => (
+
+ ))}
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/frontend/shared/components/Slider.css b/frontend/shared/components/Slider.css
new file mode 100644
index 000000000..005ca0cbb
--- /dev/null
+++ b/frontend/shared/components/Slider.css
@@ -0,0 +1,68 @@
+.sui-slider {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-3);
+ width: 100%;
+}
+
+.sui-slider--disabled {
+ opacity: 0.5;
+}
+
+.sui-slider__input {
+ flex: 1 1 auto;
+ appearance: none;
+ -webkit-appearance: none;
+ height: 0.5rem;
+ border-radius: var(--radius-pill);
+ background: linear-gradient(
+ 90deg,
+ var(--color-blue) 0%,
+ var(--color-blue) var(--slider-pct),
+ var(--color-bg-muted) var(--slider-pct),
+ var(--color-bg-muted) 100%
+ );
+ outline: none;
+}
+
+.sui-slider__input::-webkit-slider-thumb {
+ appearance: none;
+ -webkit-appearance: none;
+ width: 1rem;
+ height: 1rem;
+ border-radius: 50%;
+ background: #fff;
+ border: 2px solid var(--color-blue);
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
+ cursor: pointer;
+ transition: transform var(--motion-fast);
+}
+
+.sui-slider__input::-moz-range-thumb {
+ width: 1rem;
+ height: 1rem;
+ border-radius: 50%;
+ background: #fff;
+ border: 2px solid var(--color-blue);
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
+ cursor: pointer;
+}
+
+.sui-slider__input:focus-visible::-webkit-slider-thumb {
+ outline: 2px solid var(--color-blue);
+ outline-offset: 2px;
+}
+
+.sui-slider__input:hover::-webkit-slider-thumb {
+ transform: scale(1.08);
+}
+
+.sui-slider__value {
+ flex: 0 0 auto;
+ min-width: 3rem;
+ text-align: right;
+ font-family: var(--font-mono);
+ font-size: 0.75rem;
+ font-weight: 500;
+ color: var(--color-text-2);
+}
diff --git a/frontend/shared/components/Slider.tsx b/frontend/shared/components/Slider.tsx
new file mode 100644
index 000000000..2829cae94
--- /dev/null
+++ b/frontend/shared/components/Slider.tsx
@@ -0,0 +1,63 @@
+import { forwardRef, type InputHTMLAttributes } from "react";
+import "@shared/components/Slider.css";
+
+export interface SliderProps extends Omit<
+ InputHTMLAttributes,
+ "type" | "value" | "onChange"
+> {
+ value: number;
+ min?: number;
+ max?: number;
+ step?: number;
+ onChange: (value: number) => void;
+ /** Optional formatter for the value pill (e.g. "0.85", "30 days"). */
+ formatValue?: (value: number) => string;
+ /** Show the right-aligned value badge. Defaults to true. */
+ showValue?: boolean;
+}
+
+export const Slider = forwardRef(function Slider(
+ {
+ value,
+ min = 0,
+ max = 1,
+ step = 0.01,
+ onChange,
+ formatValue,
+ showValue = true,
+ className,
+ ...rest
+ },
+ ref,
+) {
+ const pct = ((value - min) / (max - min)) * 100;
+ return (
+
+ onChange(Number(e.target.value))}
+ className="sui-slider__input"
+ {...rest}
+ />
+ {showValue && (
+
+ {formatValue ? formatValue(value) : value.toString()}
+
+ )}
+
+ );
+});
diff --git a/frontend/shared/components/Spinner.css b/frontend/shared/components/Spinner.css
new file mode 100644
index 000000000..23a5f6fae
--- /dev/null
+++ b/frontend/shared/components/Spinner.css
@@ -0,0 +1,27 @@
+.sui-spinner {
+ display: inline-block;
+ border-radius: 50%;
+ border: 2px solid currentColor;
+ border-right-color: transparent;
+ animation: spin 0.7s linear infinite;
+ vertical-align: middle;
+}
+
+.sui-spinner--xs {
+ width: 0.625rem;
+ height: 0.625rem;
+ border-width: 1.5px;
+}
+.sui-spinner--sm {
+ width: 0.875rem;
+ height: 0.875rem;
+}
+.sui-spinner--md {
+ width: 1.25rem;
+ height: 1.25rem;
+}
+.sui-spinner--lg {
+ width: 1.75rem;
+ height: 1.75rem;
+ border-width: 2.5px;
+}
diff --git a/frontend/shared/components/Spinner.stories.tsx b/frontend/shared/components/Spinner.stories.tsx
new file mode 100644
index 000000000..68ca648f4
--- /dev/null
+++ b/frontend/shared/components/Spinner.stories.tsx
@@ -0,0 +1,45 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { Spinner } from "@shared/components/Spinner";
+
+const meta: Meta = {
+ title: "Primitives/Spinner",
+ component: Spinner,
+ tags: ["autodocs"],
+ parameters: { layout: "centered" },
+ args: { size: "md" },
+ argTypes: {
+ size: { control: "inline-radio", options: ["xs", "sm", "md", "lg"] },
+ },
+};
+export default meta;
+type Story = StoryObj;
+
+/** Flip size in controls. */
+export const Playground: Story = {};
+
+export const SizeRow: Story = {
+ render: () => (
+
+
+
+
+
+
+ ),
+};
+
+export const InheritsColor: Story = {
+ render: () => (
+
+
+
+
+
+
+
+
+
+
+
+ ),
+};
diff --git a/frontend/shared/components/Spinner.tsx b/frontend/shared/components/Spinner.tsx
new file mode 100644
index 000000000..d383e2cd0
--- /dev/null
+++ b/frontend/shared/components/Spinner.tsx
@@ -0,0 +1,28 @@
+import "@shared/components/Spinner.css";
+
+export type SpinnerSize = "xs" | "sm" | "md" | "lg";
+
+export interface SpinnerProps {
+ size?: SpinnerSize;
+ /** Optional accessible label for screen readers. */
+ label?: string;
+ className?: string;
+}
+
+/**
+ * Circular spinner. Inherits `currentColor` so it picks up the surrounding
+ * text colour — drop it into a Button, a banner, or a code header without
+ * any extra styling.
+ */
+export function Spinner({ size = "md", label, className }: SpinnerProps) {
+ return (
+
+ );
+}
diff --git a/frontend/shared/components/Stack.css b/frontend/shared/components/Stack.css
new file mode 100644
index 000000000..4a2e7bac9
--- /dev/null
+++ b/frontend/shared/components/Stack.css
@@ -0,0 +1,76 @@
+.sui-stack {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.sui-stack--gap-0 {
+ gap: var(--space-0);
+}
+.sui-stack--gap-0_5 {
+ gap: var(--space-0_5);
+}
+.sui-stack--gap-1 {
+ gap: var(--space-1);
+}
+.sui-stack--gap-1_5 {
+ gap: var(--space-1_5);
+}
+.sui-stack--gap-2 {
+ gap: var(--space-2);
+}
+.sui-stack--gap-3 {
+ gap: var(--space-3);
+}
+.sui-stack--gap-4 {
+ gap: var(--space-4);
+}
+.sui-stack--gap-5 {
+ gap: var(--space-5);
+}
+.sui-stack--gap-6 {
+ gap: var(--space-6);
+}
+.sui-stack--gap-8 {
+ gap: var(--space-8);
+}
+
+.sui-stack--align-stretch {
+ align-items: stretch;
+}
+.sui-stack--align-start {
+ align-items: flex-start;
+}
+.sui-stack--align-center {
+ align-items: center;
+}
+.sui-stack--align-end {
+ align-items: flex-end;
+}
+.sui-stack--align-baseline {
+ align-items: baseline;
+}
+
+.sui-stack--justify-start {
+ justify-content: flex-start;
+}
+.sui-stack--justify-center {
+ justify-content: center;
+}
+.sui-stack--justify-end {
+ justify-content: flex-end;
+}
+.sui-stack--justify-between {
+ justify-content: space-between;
+}
+.sui-stack--justify-around {
+ justify-content: space-around;
+}
+.sui-stack--justify-evenly {
+ justify-content: space-evenly;
+}
+
+.sui-stack--fill {
+ flex: 1 1 auto;
+ min-height: 0;
+}
diff --git a/frontend/shared/components/Stack.stories.tsx b/frontend/shared/components/Stack.stories.tsx
new file mode 100644
index 000000000..ef98c57ae
--- /dev/null
+++ b/frontend/shared/components/Stack.stories.tsx
@@ -0,0 +1,70 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { Stack } from "@shared/components/Stack";
+import { Inline } from "@shared/components/Inline";
+import { Card } from "@shared/components/Card";
+
+const meta: Meta = {
+ title: "Primitives/Layout/Stack",
+ component: Stack,
+ tags: ["autodocs"],
+ parameters: { layout: "padded" },
+};
+export default meta;
+type Story = StoryObj;
+
+function Box({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+export const Default: Story = {
+ render: () => (
+
+ One
+ Two
+ Three
+
+ ),
+};
+
+export const GapSizes: Story = {
+ render: () => (
+
+ {(["1", "2", "4", "6"] as const).map((gap) => (
+
+
+ gap {gap}
+
+ A
+ B
+ C
+
+ ))}
+
+ ),
+};
+
+export const InCard: Story = {
+ render: () => (
+
+
+ Card title
+
+ Stack is the default vertical container — it's how you compose every
+ card body, list, and form section.
+
+ Action row
+
+
+ ),
+};
diff --git a/frontend/shared/components/Stack.tsx b/frontend/shared/components/Stack.tsx
new file mode 100644
index 000000000..be02f7f23
--- /dev/null
+++ b/frontend/shared/components/Stack.tsx
@@ -0,0 +1,67 @@
+import type { ElementType, ReactNode, HTMLAttributes } from "react";
+import "@shared/components/Stack.css";
+
+export type StackGap =
+ | "0"
+ | "0_5"
+ | "1"
+ | "1_5"
+ | "2"
+ | "3"
+ | "4"
+ | "5"
+ | "6"
+ | "8";
+
+export type StackAlign = "stretch" | "start" | "center" | "end" | "baseline";
+export type StackJustify =
+ | "start"
+ | "center"
+ | "end"
+ | "between"
+ | "around"
+ | "evenly";
+
+export interface StackProps extends HTMLAttributes {
+ /** Token-aligned gap between children (maps to `--space-*`). */
+ gap?: StackGap;
+ align?: StackAlign;
+ justify?: StackJustify;
+ /** Stretch to fill parent height. */
+ fill?: boolean;
+ /** Render as a custom element (defaults to div). */
+ as?: ElementType;
+ children?: ReactNode;
+}
+
+/**
+ * Vertical flex column with token-aligned gap. Replaces inline
+ * `style={{ display: 'flex', flexDirection: 'column', gap: ... }}` everywhere.
+ */
+export function Stack({
+ gap = "2",
+ align,
+ justify,
+ fill,
+ as,
+ className,
+ children,
+ ...rest
+}: StackProps) {
+ const Tag: ElementType = as ?? "div";
+ const classes = [
+ "sui-stack",
+ `sui-stack--gap-${gap}`,
+ align ? `sui-stack--align-${align}` : "",
+ justify ? `sui-stack--justify-${justify}` : "",
+ fill ? "sui-stack--fill" : "",
+ className ?? "",
+ ]
+ .filter(Boolean)
+ .join(" ");
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/shared/components/StatusBadge.css b/frontend/shared/components/StatusBadge.css
new file mode 100644
index 000000000..a250f99d9
--- /dev/null
+++ b/frontend/shared/components/StatusBadge.css
@@ -0,0 +1,70 @@
+.sui-status {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.375rem;
+ border-radius: var(--radius-pill);
+ font-family: var(--font-sans);
+ font-weight: 500;
+ letter-spacing: 0.01em;
+ border: 1px solid transparent;
+ line-height: 1;
+}
+.sui-status--sm {
+ font-size: 0.6875rem;
+ padding: 0.125rem 0.5rem;
+}
+.sui-status--md {
+ font-size: 0.75rem;
+ padding: 0.1875rem 0.625rem;
+}
+.sui-status--lg {
+ font-size: 0.8125rem;
+ padding: 0.3125rem 0.75rem;
+}
+
+.sui-status__dot {
+ width: 0.375rem;
+ height: 0.375rem;
+ border-radius: 50%;
+ background: currentColor;
+ position: relative;
+}
+.sui-status__dot--pulse::after {
+ content: "";
+ position: absolute;
+ inset: -0.125rem;
+ border-radius: 50%;
+ border: 2px solid currentColor;
+ animation: pulseRing 1.4s ease-out infinite;
+}
+
+.sui-status--neutral {
+ color: var(--color-text-3);
+ background: var(--color-bg-muted);
+ border-color: var(--color-border-light);
+}
+.sui-status--success {
+ color: var(--color-green);
+ background: var(--color-green-light);
+ border-color: var(--color-green-border);
+}
+.sui-status--warning {
+ color: var(--color-amber-dark);
+ background: var(--color-amber-light);
+ border-color: var(--color-amber-border);
+}
+.sui-status--danger {
+ color: var(--color-red);
+ background: var(--color-red-light);
+ border-color: var(--color-red-border);
+}
+.sui-status--info {
+ color: var(--color-blue);
+ background: var(--color-blue-light);
+ border-color: var(--color-blue-border);
+}
+.sui-status--purple {
+ color: var(--color-purple);
+ background: var(--color-purple-light);
+ border-color: var(--color-purple-border);
+}
diff --git a/frontend/shared/components/StatusBadge.stories.tsx b/frontend/shared/components/StatusBadge.stories.tsx
new file mode 100644
index 000000000..4c8f069a0
--- /dev/null
+++ b/frontend/shared/components/StatusBadge.stories.tsx
@@ -0,0 +1,53 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { StatusBadge } from "@shared/components/StatusBadge";
+
+const meta: Meta = {
+ title: "Primitives/StatusBadge",
+ component: StatusBadge,
+ parameters: { layout: "centered" },
+ args: { children: "Healthy", tone: "success" },
+ argTypes: {
+ tone: {
+ control: "inline-radio",
+ options: ["neutral", "success", "warning", "danger", "info", "purple"],
+ },
+ size: { control: "inline-radio", options: ["sm", "md", "lg"] },
+ },
+};
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const AllTones: Story = {
+ render: () => (
+
+ Processed
+ Needs review
+ Escalated
+ In review
+ Resolved
+ Paused
+
+ ),
+};
+
+export const Live: Story = {
+ args: { tone: "success", pulse: true, children: "Live" },
+};
+
+export const Sizes: Story = {
+ render: () => (
+
+
+ Small
+
+
+ Medium
+
+
+ Large
+
+
+ ),
+};
diff --git a/frontend/shared/components/StatusBadge.tsx b/frontend/shared/components/StatusBadge.tsx
new file mode 100644
index 000000000..72207de98
--- /dev/null
+++ b/frontend/shared/components/StatusBadge.tsx
@@ -0,0 +1,56 @@
+import type { ReactNode } from "react";
+import "@shared/components/StatusBadge.css";
+
+export type StatusTone =
+ | "neutral"
+ | "success"
+ | "warning"
+ | "danger"
+ | "info"
+ | "purple";
+
+export type StatusSize = "sm" | "md" | "lg";
+
+export interface StatusBadgeProps {
+ tone?: StatusTone;
+ size?: StatusSize;
+ /** Show a leading coloured dot. */
+ showDot?: boolean;
+ /** Render the dot with a pulse animation (active / live indicator). */
+ pulse?: boolean;
+ children?: ReactNode;
+ className?: string;
+}
+
+/**
+ * Inline status pill used across surfaces — pipeline rows, document status,
+ * deployments, audit logs. Tone maps to semantic meaning, not raw colour.
+ */
+export function StatusBadge({
+ tone = "neutral",
+ size = "md",
+ showDot = true,
+ pulse = false,
+ children,
+ className,
+}: StatusBadgeProps) {
+ const cls = [
+ "sui-status",
+ `sui-status--${tone}`,
+ `sui-status--${size}`,
+ className ?? "",
+ ]
+ .filter(Boolean)
+ .join(" ");
+ return (
+
+ {showDot && (
+
+ )}
+ {children}
+
+ );
+}
diff --git a/frontend/shared/components/Tabs.css b/frontend/shared/components/Tabs.css
new file mode 100644
index 000000000..b9dd365fc
--- /dev/null
+++ b/frontend/shared/components/Tabs.css
@@ -0,0 +1,77 @@
+.sui-tabs {
+ display: flex;
+ gap: var(--space-1);
+ flex-wrap: wrap;
+}
+
+.sui-tabs--pill {
+}
+.sui-tabs--underline {
+ border-bottom: 1px solid var(--color-border-light);
+ padding-bottom: var(--space-1);
+ gap: var(--space-3);
+}
+
+.sui-tabs__tab {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1_5);
+ padding: var(--space-1_5) var(--space-3);
+ font-size: 0.8125rem;
+ color: var(--color-text-3);
+ background: transparent;
+ border: 1px solid transparent;
+ transition:
+ color var(--motion-fast),
+ background var(--motion-fast),
+ border-color var(--motion-fast);
+}
+
+.sui-tabs__tab:hover:not(.is-disabled) {
+ color: var(--color-text-1);
+ background: var(--color-bg-hover);
+}
+
+.sui-tabs__tab.is-disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
+
+/* Pill variant — rounded chip, filled tint when active. */
+.sui-tabs--pill .sui-tabs__tab {
+ border-radius: var(--radius-pill);
+}
+.sui-tabs--pill .sui-tabs__tab.is-active {
+ color: var(--sui-tab-accent, var(--color-blue));
+ background: var(--color-blue-light);
+ border-color: var(--sui-tab-accent, var(--color-blue-border));
+ font-weight: 500;
+}
+
+/* Underline variant — line-under, no background fill. */
+.sui-tabs--underline .sui-tabs__tab {
+ border-radius: 0;
+ border-bottom: 2px solid transparent;
+ padding-bottom: var(--space-2);
+ margin-bottom: -1px;
+}
+.sui-tabs--underline .sui-tabs__tab.is-active {
+ color: var(--sui-tab-accent, var(--color-blue));
+ border-bottom-color: var(--sui-tab-accent, var(--color-blue));
+ font-weight: 500;
+}
+
+.sui-tabs__dot {
+ width: 0.4375rem;
+ height: 0.4375rem;
+ border-radius: 50%;
+}
+
+.sui-tabs__count {
+ font-size: 0.6875rem;
+ color: var(--color-text-5);
+}
+
+.sui-tabs__tab.is-active .sui-tabs__count {
+ color: inherit;
+}
diff --git a/frontend/shared/components/Tabs.stories.tsx b/frontend/shared/components/Tabs.stories.tsx
new file mode 100644
index 000000000..ef0c71aca
--- /dev/null
+++ b/frontend/shared/components/Tabs.stories.tsx
@@ -0,0 +1,93 @@
+import { useState } from "react";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { Tabs, type TabItem } from "@shared/components/Tabs";
+
+const baseItems: TabItem[] = [
+ { key: "deployed", label: "Deployed", count: 6 },
+ { key: "templates", label: "Templates", count: 4 },
+ { key: "archive", label: "Archive", count: 0 },
+];
+
+function Bound({
+ items = baseItems,
+ variant = "pill" as const,
+}: {
+ items?: TabItem[];
+ variant?: "pill" | "underline";
+}) {
+ const [active, setActive] = useState(items[0].key);
+ return (
+
+ );
+}
+
+const meta: Meta = {
+ title: "Primitives/Tabs",
+ component: Tabs,
+ tags: ["autodocs"],
+ parameters: { layout: "padded" },
+ args: { variant: "pill" },
+ argTypes: {
+ variant: { control: "inline-radio", options: ["pill", "underline"] },
+ },
+ render: (args) => ,
+};
+export default meta;
+type Story = StoryObj;
+
+/** Flip variant in controls. */
+export const Playground: Story = {};
+
+export const WithDisabledTab: Story = {
+ render: () => (
+
+ ),
+};
+
+export const InContext_DocumentVerticals: Story = {
+ render: () => (
+
+ ),
+};
diff --git a/frontend/shared/components/Tabs.tsx b/frontend/shared/components/Tabs.tsx
new file mode 100644
index 000000000..0727c3ced
--- /dev/null
+++ b/frontend/shared/components/Tabs.tsx
@@ -0,0 +1,88 @@
+import type { ReactNode } from "react";
+import "@shared/components/Tabs.css";
+
+export interface TabItem {
+ /** Stable identifier for the tab. */
+ key: K;
+ label: ReactNode;
+ /** Optional count badge shown to the right of the label. */
+ count?: number;
+ /** Accent colour applied when this tab is active (override per-tab). */
+ accentColor?: string;
+ /** Leading dot before the label (used by Document type grid). */
+ dotColor?: string;
+ disabled?: boolean;
+}
+
+export interface TabsProps {
+ items: TabItem[];
+ activeKey: K;
+ onChange: (key: K) => void;
+ /** Visual treatment. `pill` is rounded-chip style; `underline` is line-under tabs. */
+ variant?: "pill" | "underline";
+ /** Accessible label for the tablist. */
+ ariaLabel?: string;
+ className?: string;
+}
+
+/**
+ * Single-row tab strip. Generic over the key type so callers get strongly
+ * typed `onChange` handlers without casting.
+ */
+export function Tabs({
+ items,
+ activeKey,
+ onChange,
+ variant = "pill",
+ ariaLabel,
+ className,
+}: TabsProps) {
+ return (
+ // role="group" + aria-pressed is the right pattern for a filter strip
+ // that does not have paired tabpanels. Switching to ARIA tab roles would
+ // require ArrowLeft / ArrowRight handling and per-tab tabpanel wiring per
+ // the APG — out of scope for what this primitive actually does.
+
+ {items.map((item) => {
+ const isActive = item.key === activeKey;
+ const styleVars =
+ isActive && item.accentColor
+ ? ({ "--sui-tab-accent": item.accentColor } as React.CSSProperties)
+ : undefined;
+ return (
+ onChange(item.key)}
+ >
+ {item.dotColor && (
+
+ )}
+ {item.label}
+ {item.count !== undefined && (
+ {item.count}
+ )}
+
+ );
+ })}
+
+ );
+}
diff --git a/frontend/shared/components/Toast.css b/frontend/shared/components/Toast.css
new file mode 100644
index 000000000..ab52d37d3
--- /dev/null
+++ b/frontend/shared/components/Toast.css
@@ -0,0 +1,70 @@
+.sui-toast-viewport {
+ position: fixed;
+ bottom: var(--space-6);
+ right: var(--space-6);
+ z-index: var(--z-toast);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ pointer-events: none;
+ max-width: 24rem;
+}
+
+.sui-toast {
+ pointer-events: auto;
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-3);
+ padding: var(--space-3) var(--space-4);
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-left-width: 3px;
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-lg);
+ animation: fadeInUp var(--motion-enter) both;
+}
+
+.sui-toast--info {
+ border-left-color: var(--color-blue);
+}
+.sui-toast--success {
+ border-left-color: var(--color-green);
+}
+.sui-toast--warning {
+ border-left-color: var(--color-amber);
+}
+.sui-toast--danger {
+ border-left-color: var(--color-red);
+}
+
+.sui-toast__body {
+ flex: 1 1 auto;
+ min-width: 0;
+ font-size: 0.8125rem;
+}
+.sui-toast__title {
+ font-weight: 600;
+ color: var(--color-text-1);
+}
+.sui-toast__desc {
+ color: var(--color-text-3);
+ margin-top: 0.125rem;
+ line-height: 1.45;
+}
+
+.sui-toast__close {
+ flex: 0 0 auto;
+ width: 1.25rem;
+ height: 1.25rem;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: var(--radius-sm);
+ color: var(--color-text-4);
+ font-size: 1rem;
+ line-height: 1;
+}
+.sui-toast__close:hover {
+ background: var(--color-bg-hover);
+ color: var(--color-text-1);
+}
diff --git a/frontend/shared/components/Toast.stories.tsx b/frontend/shared/components/Toast.stories.tsx
new file mode 100644
index 000000000..4c599bc9f
--- /dev/null
+++ b/frontend/shared/components/Toast.stories.tsx
@@ -0,0 +1,71 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { ToastProvider, useToast } from "@shared/components/Toast";
+import { Button } from "@shared/components/Button";
+
+function Trigger() {
+ const { toast } = useToast();
+ return (
+
+
+ toast({
+ tone: "info",
+ title: "Info",
+ description: "Just so you know.",
+ })
+ }
+ >
+ Info
+
+
+ toast({
+ tone: "success",
+ title: "Deployed",
+ description: "Prior Auth v3.1.0",
+ })
+ }
+ >
+ Success
+
+
+ toast({
+ tone: "warning",
+ title: "Approaching cap",
+ description: "389k / 500k",
+ })
+ }
+ >
+ Warning
+
+
+ toast({
+ tone: "danger",
+ title: "Run failed",
+ description: "Schema mismatch",
+ })
+ }
+ >
+ Danger
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Primitives/Toast",
+ parameters: { layout: "padded" },
+ decorators: [
+ (S) => (
+
+
+
+ ),
+ ],
+};
+export default meta;
+type Story = StoryObj;
+
+export const Triggers: Story = { render: () => };
diff --git a/frontend/shared/components/Toast.tsx b/frontend/shared/components/Toast.tsx
new file mode 100644
index 000000000..9dd9a00ff
--- /dev/null
+++ b/frontend/shared/components/Toast.tsx
@@ -0,0 +1,141 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+import { createPortal } from "react-dom";
+import "@shared/components/Toast.css";
+
+export type ToastTone = "info" | "success" | "warning" | "danger";
+
+export interface ToastOptions {
+ tone?: ToastTone;
+ title?: ReactNode;
+ description?: ReactNode;
+ /** Auto-dismiss timeout in ms. Defaults to 4000. Pass 0 to keep open. */
+ durationMs?: number;
+}
+
+interface ToastEntry extends ToastOptions {
+ id: number;
+}
+
+interface ToastContextValue {
+ toast: (options: ToastOptions) => number;
+ dismiss: (id: number) => void;
+}
+
+const ToastContext = createContext(null);
+
+export function useToast(): ToastContextValue {
+ const ctx = useContext(ToastContext);
+ if (!ctx) throw new Error("useToast must be used inside ");
+ return ctx;
+}
+
+export function ToastProvider({ children }: { children: ReactNode }) {
+ const [entries, setEntries] = useState([]);
+ const nextIdRef = useRef(1);
+
+ const dismiss = useCallback((id: number) => {
+ setEntries((prev) => prev.filter((e) => e.id !== id));
+ }, []);
+
+ const toast = useCallback(
+ (options: ToastOptions) => {
+ const id = nextIdRef.current++;
+ const entry: ToastEntry = {
+ tone: "info",
+ durationMs: 4000,
+ ...options,
+ id,
+ };
+ setEntries((prev) => [...prev, entry]);
+ if ((entry.durationMs ?? 0) > 0) {
+ window.setTimeout(() => dismiss(id), entry.durationMs);
+ }
+ return id;
+ },
+ [dismiss],
+ );
+
+ const value = useMemo(
+ () => ({ toast, dismiss }),
+ [toast, dismiss],
+ );
+
+ return (
+
+ {children}
+
+
+ );
+}
+
+function ToastViewport({
+ entries,
+ onDismiss,
+}: {
+ entries: ToastEntry[];
+ onDismiss: (id: number) => void;
+}) {
+ // Render through a portal so toasts always sit above any in-flow stacking
+ // context. SSR-safe by checking for document existence.
+ if (typeof document === "undefined") return null;
+ return createPortal(
+
+ {entries.map((entry) => (
+
+ ))}
+
,
+ document.body,
+ );
+}
+
+function ToastItem({
+ entry,
+ onDismiss,
+}: {
+ entry: ToastEntry;
+ onDismiss: (id: number) => void;
+}) {
+ useEffect(() => {
+ // Allow Escape to dismiss the most recently focused toast.
+ function onKey(e: KeyboardEvent) {
+ if (e.key === "Escape") onDismiss(entry.id);
+ }
+ document.addEventListener("keydown", onKey);
+ return () => document.removeEventListener("keydown", onKey);
+ }, [entry.id, onDismiss]);
+
+ return (
+
+
+ {entry.title &&
{entry.title}
}
+ {entry.description && (
+
{entry.description}
+ )}
+
+
onDismiss(entry.id)}
+ aria-label="Dismiss"
+ >
+ ×
+
+
+ );
+}
diff --git a/frontend/shared/components/ToggleSwitch.css b/frontend/shared/components/ToggleSwitch.css
new file mode 100644
index 000000000..f5025ebfb
--- /dev/null
+++ b/frontend/shared/components/ToggleSwitch.css
@@ -0,0 +1,78 @@
+.sui-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.625rem;
+ cursor: pointer;
+ user-select: none;
+}
+.sui-toggle input {
+ position: absolute;
+ opacity: 0;
+ width: 0.0625rem;
+ height: 0.0625rem;
+}
+
+.sui-toggle__track {
+ display: inline-block;
+ width: 2.25rem;
+ height: 1.25rem;
+ background: var(--color-toggle-off);
+ border-radius: 0.75rem;
+ position: relative;
+ transition: background var(--motion-base);
+ flex-shrink: 0;
+}
+.sui-toggle__thumb {
+ position: absolute;
+ top: 0.125rem;
+ left: 0.125rem;
+ width: 1rem;
+ height: 1rem;
+ background: #fff;
+ border-radius: 50%;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
+ transition: transform var(--motion-base);
+}
+.sui-toggle input:checked + .sui-toggle__track {
+ background: var(--color-blue);
+}
+.sui-toggle input:checked + .sui-toggle__track .sui-toggle__thumb {
+ transform: translateX(1rem);
+}
+
+.sui-toggle--sm .sui-toggle__track {
+ width: 1.875rem;
+ height: 1rem;
+}
+.sui-toggle--sm .sui-toggle__thumb {
+ width: 0.75rem;
+ height: 0.75rem;
+}
+.sui-toggle--sm input:checked + .sui-toggle__track .sui-toggle__thumb {
+ transform: translateX(0.875rem);
+}
+
+.sui-toggle.is-disabled {
+ cursor: not-allowed;
+ opacity: 0.5;
+}
+
+.sui-toggle__text {
+ display: inline-flex;
+ flex-direction: column;
+ gap: 0.125rem;
+}
+.sui-toggle__label {
+ font-size: 0.8125rem;
+ font-weight: 500;
+ color: var(--color-text-2);
+}
+.sui-toggle__desc {
+ font-size: 0.75rem;
+ color: var(--color-text-4);
+}
+
+.sui-toggle input:focus-visible + .sui-toggle__track {
+ outline: 2px solid var(--color-blue);
+ outline-offset: 2px;
+}
diff --git a/frontend/shared/components/ToggleSwitch.stories.tsx b/frontend/shared/components/ToggleSwitch.stories.tsx
new file mode 100644
index 000000000..9dc2f0abd
--- /dev/null
+++ b/frontend/shared/components/ToggleSwitch.stories.tsx
@@ -0,0 +1,89 @@
+import { useState } from "react";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { ToggleSwitch } from "@shared/components/ToggleSwitch";
+
+const meta: Meta = {
+ title: "Primitives/ToggleSwitch",
+ component: ToggleSwitch,
+ tags: ["autodocs"],
+ parameters: { layout: "centered" },
+ args: {
+ checked: true,
+ label: "Encryption at rest",
+ size: "md",
+ disabled: false,
+ },
+ argTypes: {
+ size: { control: "inline-radio", options: ["sm", "md"] },
+ disabled: { control: "boolean" },
+ checked: { control: "boolean" },
+ },
+ render: (args) => {
+ function Bound() {
+ const [on, setOn] = useState(args.checked);
+ return ;
+ }
+ return ;
+ },
+};
+export default meta;
+type Story = StoryObj;
+
+/** Flip size / checked / disabled / label / description in controls. */
+export const Playground: Story = {};
+
+export const WithDescription: Story = {
+ args: {
+ label: "Auto-promote on golden set pass",
+ description:
+ "When the eval set passes, the new version is promoted automatically.",
+ },
+};
+
+export const InContext_SettingsRows: Story = {
+ parameters: { layout: "padded" },
+ render: () => {
+ function Bound() {
+ const [a, setA] = useState(true);
+ const [b, setB] = useState(true);
+ const [c, setC] = useState(false);
+ const [d, setD] = useState(false);
+ return (
+
+
+
+
+
+
+ );
+ }
+ return ;
+ },
+};
diff --git a/frontend/shared/components/ToggleSwitch.tsx b/frontend/shared/components/ToggleSwitch.tsx
new file mode 100644
index 000000000..a921e763c
--- /dev/null
+++ b/frontend/shared/components/ToggleSwitch.tsx
@@ -0,0 +1,59 @@
+import { useId } from "react";
+import "@shared/components/ToggleSwitch.css";
+
+export interface ToggleSwitchProps {
+ checked: boolean;
+ onChange: (checked: boolean) => void;
+ /** Accessible label associated to the control. */
+ label?: string;
+ /** Optional helper text rendered next to the label. */
+ description?: string;
+ disabled?: boolean;
+ size?: "sm" | "md";
+ id?: string;
+}
+
+/**
+ * 36×20 pill toggle, matched to the prototype's SettingsRow control.
+ *
+ * Pair with the {@link SettingsRow} component (or a custom row) when you need
+ * a label + description layout. Standalone, this is just the switch itself.
+ */
+export function ToggleSwitch({
+ checked,
+ onChange,
+ label,
+ description,
+ disabled,
+ size = "md",
+ id,
+}: ToggleSwitchProps) {
+ const autoId = useId();
+ const controlId = id ?? autoId;
+ return (
+
+ onChange(e.target.checked)}
+ />
+
+
+
+ {(label || description) && (
+
+ {label && {label} }
+ {description && (
+ {description}
+ )}
+
+ )}
+
+ );
+}
diff --git a/frontend/shared/components/index.ts b/frontend/shared/components/index.ts
new file mode 100644
index 000000000..1e1a26b30
--- /dev/null
+++ b/frontend/shared/components/index.ts
@@ -0,0 +1,38 @@
+export * from "@shared/components/Button";
+export * from "@shared/components/StatusBadge";
+export * from "@shared/components/MethodBadge";
+export * from "@shared/components/ToggleSwitch";
+export * from "@shared/components/ProgressBar";
+export * from "@shared/components/MetricCard";
+export * from "@shared/components/NavItem";
+export * from "@shared/components/PanelHeader";
+export * from "@shared/components/CodeBlock";
+export * from "@shared/components/SectionDivider";
+export * from "@shared/components/Card";
+export * from "@shared/components/Modal";
+
+// Layout
+export * from "@shared/components/Stack";
+export * from "@shared/components/Inline";
+
+// Feedback
+export * from "@shared/components/Spinner";
+export * from "@shared/components/Skeleton";
+export * from "@shared/components/Avatar";
+export * from "@shared/components/Chip";
+export * from "@shared/components/EmptyState";
+export * from "@shared/components/Banner";
+export * from "@shared/components/Toast";
+
+// Compound
+export * from "@shared/components/Tabs";
+export * from "@shared/components/Dropdown";
+export * from "@shared/components/Drawer";
+
+// Forms
+export * from "@shared/components/FormField";
+export * from "@shared/components/Input";
+export * from "@shared/components/Select";
+export * from "@shared/components/Checkbox";
+export * from "@shared/components/Radio";
+export * from "@shared/components/Slider";
diff --git a/frontend/shared/data/Endpoints.stories.tsx b/frontend/shared/data/Endpoints.stories.tsx
new file mode 100644
index 000000000..53d64816a
--- /dev/null
+++ b/frontend/shared/data/Endpoints.stories.tsx
@@ -0,0 +1,116 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { VERTICALS, ALL_ENDPOINTS } from "@shared/data/endpoints";
+import { MethodBadge } from "@shared/components/MethodBadge";
+import { StatusBadge } from "@shared/components/StatusBadge";
+
+const meta: Meta = {
+ title: "Data/Endpoint catalogue",
+ parameters: {
+ layout: "padded",
+ docs: {
+ description: {
+ component:
+ "Live preview of the typed endpoint catalogue in src/data/endpoints.ts. Verticals come from VERTICALS, the flat list from ALL_ENDPOINTS.",
+ },
+ },
+ },
+};
+export default meta;
+type Story = StoryObj;
+
+export const ByVertical: Story = {
+ render: () => (
+
+
+ {ALL_ENDPOINTS.length} endpoints across {VERTICALS.length} verticals
+
+ {VERTICALS.map((v) => (
+
+
+
+
+ {v.label}
+
+
+ {v.endpoints.length} endpoints
+
+
+
+ {v.endpoints.map((e) => (
+
+
+
+ {e.endpoint}
+
+
+
+ {e.name}
+
+
+ {e.desc}
+
+
+
+ {e.tier === 0 ? "free" : e.tier === 1 ? "paid" : "enterprise"}
+
+
+ ))}
+
+
+ ))}
+
+ ),
+};
diff --git a/frontend/shared/data/Ops.stories.tsx b/frontend/shared/data/Ops.stories.tsx
new file mode 100644
index 000000000..5be51159d
--- /dev/null
+++ b/frontend/shared/data/Ops.stories.tsx
@@ -0,0 +1,306 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import {
+ PIPELINE_OPS,
+ LIBRARY_OPS,
+ OP_CATEGORIES,
+ PIPELINE_AGENTS,
+ SOURCE_OPTIONS,
+ DESTINATION_OPTIONS,
+ type OpKind,
+} from "@shared/data/ops";
+
+const meta: Meta = {
+ title: "Data/Ops library",
+ parameters: { layout: "padded" },
+};
+export default meta;
+type Story = StoryObj;
+
+const STAGE_ORDER: OpKind[] = [
+ "ingest",
+ "validate",
+ "modify",
+ "secure",
+ "store",
+ "alert",
+];
+const STAGE_COLOUR: Record = {
+ ingest: "var(--color-green)",
+ validate: "var(--color-blue)",
+ modify: "#F97316",
+ secure: "var(--color-red)",
+ store: "var(--color-purple)",
+ alert: "var(--color-amber)",
+};
+
+export const PipelineOps: Story = {
+ render: () => (
+
+ {STAGE_ORDER.map((stage) => (
+
+
+
+
+ {stage}
+
+
+ {PIPELINE_OPS[stage].length} ops
+
+
+
+ {PIPELINE_OPS[stage].map((op) => (
+
+ {op.label}
+ {op.defaultOn && (
+
+ · default
+
+ )}
+
+ ))}
+
+
+ ))}
+
+ ),
+};
+
+export const LibraryByCategory: Story = {
+ render: () => (
+
+
+ {LIBRARY_OPS.length} library ops across {OP_CATEGORIES.length}{" "}
+ categories
+
+ {OP_CATEGORIES.map((cat) => {
+ const ops = LIBRARY_OPS.filter((op) => op.category === cat.name);
+ return (
+
+
+
+
+ {cat.name}
+
+
+ {cat.blurb}
+
+
+ {ops.length}
+
+
+
+ {ops.map((op) => (
+
+ {op.label}
+ {op.provider && (
+
+ · {op.provider}
+
+ )}
+
+ ))}
+
+
+ );
+ })}
+
+ ),
+};
+
+export const Agents: Story = {
+ render: () => (
+
+ {PIPELINE_AGENTS.map((a) => (
+
+
+
+ {a.label}
+
+
+
+ {a.desc}
+
+
+ {a.ops.map((op) => (
+
+ {op}
+
+ ))}
+
+
+ ))}
+
+ ),
+};
+
+export const SourcesAndDestinations: Story = {
+ render: () => (
+
+
+
Sources
+
+ {SOURCE_OPTIONS.map((s) => (
+
+
{s.label}
+
+ {s.desc}
+
+
+ ))}
+
+
+
+
Destinations
+
+ {DESTINATION_OPTIONS.map((d) => (
+
+
{d.label}
+
+ {d.desc}
+
+
+ ))}
+
+
+
+ ),
+};
diff --git a/frontend/shared/data/endpoints.ts b/frontend/shared/data/endpoints.ts
new file mode 100644
index 000000000..80380225d
--- /dev/null
+++ b/frontend/shared/data/endpoints.ts
@@ -0,0 +1,1270 @@
+/**
+ * The Stirling endpoint catalogue.
+ *
+ * Document types are grouped by vertical (Insurance, Finance, Legal, …).
+ * Each entry carries the route path, tier gate, JSON-shape schema, supported
+ * regions, and a category accent. The catalogue is the spine the Docs view,
+ * the Pipelines composer, and the Document type grid on Home all read from.
+ *
+ * Ported faithfully from the prototype's DOCUMENT_TYPES. Schema field shapes
+ * are kept as the prototype-style "type-or-list-of-type" string for now; a
+ * future revision should convert them to Zod schemas so they double as
+ * runtime validators.
+ */
+
+import type { Tier } from "@shared/tokens/tokens";
+
+/** Canonical vertical keys — these map 1:1 to category-accent CSS variables. */
+export type VerticalKey =
+ | "insurance"
+ | "compliance"
+ | "finance"
+ | "legal"
+ | "healthcare"
+ | "government"
+ | "operations"
+ | "hr"
+ | "realestate"
+ | "energy";
+
+/**
+ * Numeric tier requirement on an endpoint.
+ * 0 = free
+ * 1 = paid (pro / pay-as-you-go and above)
+ * 2 = enterprise only
+ *
+ * Use `isEndpointAvailable(endpoint, currentTier)` for runtime checks rather
+ * than comparing numbers directly.
+ */
+export type EndpointTierGate = 0 | 1 | 2;
+
+export interface EndpointSchema {
+ /**
+ * Prototype-style field shape. Strings like "string", "number", "date",
+ * "boolean", "string?" (optional), "[string]" (array), or an inline shape
+ * "{start, end}". Replace with a Zod schema when wiring up real requests.
+ */
+ [field: string]: string;
+}
+
+export interface Endpoint {
+ /** Display name shown in cards and docs. */
+ name: string;
+ /** Route path mounted under the Stirling API. */
+ endpoint: string;
+ /** Numeric tier gate, see {@link EndpointTierGate}. */
+ tier: EndpointTierGate;
+ /** One-line description used in cards and docs index. */
+ desc: string;
+ /** Vertical this endpoint belongs to. */
+ vertical: VerticalKey;
+ /** Region availability list. "+22" tail means "and N more regions". */
+ regions: readonly string[];
+ /** Endpoint payload shape. */
+ schema: EndpointSchema;
+}
+
+export interface Vertical {
+ key: VerticalKey;
+ label: string;
+ /** CSS-variable reference for the category accent. */
+ color: string;
+ endpoints: readonly Endpoint[];
+}
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* Catalogue */
+/* ──────────────────────────────────────────────────────────────────────── */
+
+const insurance: readonly Endpoint[] = [
+ {
+ name: "Certificates of Insurance",
+ endpoint: "/v1/coi",
+ tier: 1,
+ vertical: "insurance",
+ desc: "Live compliance dashboard with coverage gaps, renewal alerts, and AI Q&A for policy verification",
+ regions: ["US", "UK", "CA", "AU", "+22"],
+ schema: {
+ holder: "string",
+ insurer: "string",
+ policy_number: "string",
+ coverage_types: "[string]",
+ general_aggregate: "number",
+ expiry_date: "date",
+ additional_insured: "boolean",
+ },
+ },
+ {
+ name: "Loss Run Reports",
+ endpoint: "/v1/loss-run",
+ tier: 1,
+ vertical: "insurance",
+ desc: "Claims timeline with loss ratio trends, carrier comparisons, and underwriting AI chat",
+ regions: ["US", "UK", "CA"],
+ schema: {
+ carrier: "string",
+ policy_period: "string",
+ total_claims: "number",
+ total_incurred: "number",
+ loss_ratio: "number",
+ claims: "[{date, type, incurred, status}]",
+ },
+ },
+ {
+ name: "ACORD Forms",
+ endpoint: "/v1/acord",
+ tier: 1,
+ vertical: "insurance",
+ desc: "Multi-carrier coverage view with ACORD compliance badges and reconciliation actions",
+ regions: ["US"],
+ schema: {
+ form_number: "string",
+ producer: "string",
+ insured: "string",
+ carriers: "[{name, naic, policy}]",
+ coverages: "[{type, limit, deductible}]",
+ effective_date: "date",
+ },
+ },
+ {
+ name: "Declarations Pages",
+ endpoint: "/v1/dec-page",
+ tier: 1,
+ vertical: "insurance",
+ desc: "Coverage summary with gap highlights, endorsement changes, and premium calculator",
+ regions: ["US", "UK", "CA", "AU"],
+ schema: {
+ named_insured: "string",
+ policy_number: "string",
+ effective: "date",
+ expiry: "date",
+ coverages: "[{type, limit, deductible, premium}]",
+ total_premium: "number",
+ endorsements: "[string]",
+ },
+ },
+ {
+ name: "Explanation of Benefits",
+ endpoint: "/v1/eob",
+ tier: 1,
+ vertical: "insurance",
+ desc: "Benefit breakdown with denial codes, patient cost visibility, and billing action buttons",
+ regions: ["US"],
+ schema: {
+ patient: "string",
+ provider: "string",
+ service_date: "date",
+ services: "[{code, description, billed, allowed, paid, patient_resp}]",
+ denial_codes: "[string]?",
+ total_patient_owed: "number",
+ },
+ },
+ {
+ name: "Subrogation Notices",
+ endpoint: "/v1/subrogation",
+ tier: 1,
+ vertical: "insurance",
+ desc: "Recovery opportunity tracker with status updates, lien tracking, and settlement AI",
+ regions: ["US", "UK"],
+ schema: {
+ claim_number: "string",
+ claimant: "string",
+ liable_party: "string",
+ loss_date: "date",
+ recovery_sought: "number",
+ lien_amount: "number?",
+ status: "string",
+ },
+ },
+ {
+ name: "Surety Bonds",
+ endpoint: "/v1/surety-bond",
+ tier: 1,
+ vertical: "insurance",
+ desc: "Bond status dashboard showing obligee requirements, renewal dates, and compliance checklist",
+ regions: ["US", "UK", "CA", "AU", "+14"],
+ schema: {
+ bond_number: "string",
+ principal: "string",
+ obligee: "string",
+ surety: "string",
+ bond_amount: "number",
+ effective_date: "date",
+ expiry_date: "date",
+ bond_type: "string",
+ },
+ },
+];
+
+const compliance: readonly Endpoint[] = [
+ {
+ name: "SOC 2 Reports",
+ endpoint: "/v1/soc2",
+ tier: 1,
+ vertical: "compliance",
+ desc: "Control assessment view with exception severity ratings, trust criteria status, and remediation notes",
+ regions: ["US", "Global"],
+ schema: {
+ report_type: "string",
+ service_org: "string",
+ auditor: "string",
+ period: "{start, end}",
+ opinion: "string",
+ exceptions: "[{control, description}]",
+ trust_criteria: "[string]",
+ },
+ },
+ {
+ name: "Regulatory Filings",
+ endpoint: "/v1/regulatory-filing",
+ tier: 1,
+ vertical: "compliance",
+ desc: "Multi-jurisdiction filing calendar with deadline alerts, status tracking, and submission buttons",
+ regions: ["US", "UK", "EU", "SG", "HK", "+31"],
+ schema: {
+ filing_type: "string",
+ jurisdiction: "string",
+ entity: "string",
+ filing_date: "date",
+ deadline: "date",
+ status: "string",
+ reference_number: "string?",
+ },
+ },
+ {
+ name: "Consent Orders",
+ endpoint: "/v1/consent-order",
+ tier: 1,
+ vertical: "compliance",
+ desc: "Violation timeline with remediation progress bars, deadline countdowns, and compliance history",
+ regions: ["US", "UK", "EU"],
+ schema: {
+ regulatory_body: "string",
+ respondent: "string",
+ order_date: "date",
+ violations: "[{statute, description}]",
+ penalties: "number",
+ remediation: "[string]",
+ compliance_deadline: "date",
+ },
+ },
+ {
+ name: "GDPR Processing Agreements",
+ endpoint: "/v1/dpa",
+ tier: 1,
+ vertical: "compliance",
+ desc: "Data processing overview with transfer mechanism validation, sub-processor registry, and approval flow",
+ regions: ["EU", "UK", "CH", "Global"],
+ schema: {
+ controller: "string",
+ processor: "string",
+ data_categories: "[string]",
+ purposes: "[string]",
+ sub_processors: "[{name, location}]",
+ transfer_mechanism: "string",
+ retention_period: "string",
+ },
+ },
+ {
+ name: "Sanctions Screening Results",
+ endpoint: "/v1/sanctions-screen",
+ tier: 1,
+ vertical: "compliance",
+ desc: "Real-time screening results with match confidence scores and one-click disposition",
+ regions: ["US", "UK", "EU", "Global"],
+ schema: {
+ screened_entity: "string",
+ lists_checked: "[string]",
+ matches: "[{list, name, score, type}]",
+ overall_risk: "string",
+ disposition: "string",
+ screened_date: "date",
+ },
+ },
+ {
+ name: "ISO Audit Reports",
+ endpoint: "/v1/iso-audit",
+ tier: 1,
+ vertical: "compliance",
+ desc: "Audit findings organized by severity with remediation timelines and compliance tracking",
+ regions: ["Global"],
+ schema: {
+ standard: "string",
+ audit_type: "string",
+ auditor: "string",
+ organization: "string",
+ nonconformities: "[{clause, severity, finding}]",
+ observations: "[string]",
+ recommendation: "string",
+ },
+ },
+ {
+ name: "Incident Reports",
+ endpoint: "/v1/incident-report",
+ tier: 1,
+ vertical: "compliance",
+ desc: "Incident severity dashboard with root cause analysis, remediation tracking, and escalation status",
+ regions: ["Global"],
+ schema: {
+ incident_id: "string",
+ date: "date",
+ severity: "string",
+ category: "string",
+ description: "string",
+ root_cause: "string?",
+ remediation: "string?",
+ reported_by: "string",
+ },
+ },
+];
+
+const finance: readonly Endpoint[] = [
+ {
+ name: "Letters of Credit",
+ endpoint: "/v1/loc",
+ tier: 1,
+ vertical: "finance",
+ desc: "LC tracker with condition verification, expiry alerts, and cash call risk assessment",
+ regions: ["US", "UK", "HK", "SG", "+34"],
+ schema: {
+ lc_number: "string",
+ issuing_bank: "string",
+ applicant: "string",
+ beneficiary: "string",
+ amount: "number",
+ currency: "string",
+ conditions: "[string]",
+ expiry_date: "date",
+ type: "string",
+ },
+ },
+ {
+ name: "Trade Confirmations",
+ endpoint: "/v1/trade-confirm",
+ tier: 1,
+ vertical: "finance",
+ desc: "Trade confirmation with counterparty validation, settlement date verification, and matching status",
+ regions: ["US", "UK", "EU", "HK", "JP", "+18"],
+ schema: {
+ trade_date: "date",
+ settlement_date: "date",
+ security: "string",
+ identifier: "string",
+ quantity: "number",
+ price: "number",
+ counterparty: "string",
+ account: "string",
+ },
+ },
+ {
+ name: "Fund Prospectuses",
+ endpoint: "/v1/prospectus",
+ tier: 1,
+ vertical: "finance",
+ desc: "Fund profile with fee breakdown, risk level compliance checks, and mandate alignment flags",
+ regions: ["US", "UK", "EU", "LU", "+12"],
+ schema: {
+ fund_name: "string",
+ fund_type: "string",
+ objective: "string",
+ expense_ratio: "number",
+ risk_level: "string",
+ min_investment: "number",
+ performance: "[{period, return}]",
+ manager: "string",
+ },
+ },
+ {
+ name: "K-1 Schedules",
+ endpoint: "/v1/k1",
+ tier: 1,
+ vertical: "finance",
+ desc: "Partnership income view with equity reconciliation, distribution tracking, and tax line mapping",
+ regions: ["US"],
+ schema: {
+ partnership: "string",
+ partner: "string",
+ tax_year: "number",
+ ordinary_income: "number",
+ rental_income: "number?",
+ interest_income: "number?",
+ capital_gains: "number?",
+ distributions: "number",
+ capital_account: "number",
+ },
+ },
+ {
+ name: "10-K / Annual Reports",
+ endpoint: "/v1/10k",
+ tier: 1,
+ vertical: "finance",
+ desc: "Financial summary with risk factor highlights, year-over-year comparisons, and segment performance",
+ regions: ["US", "UK", "EU", "JP", "+8"],
+ schema: {
+ entity: "string",
+ fiscal_year: "number",
+ revenue: "number",
+ net_income: "number",
+ total_assets: "number",
+ risk_factors: "[string]",
+ segments: "[{name, revenue}]",
+ auditor: "string",
+ },
+ },
+ {
+ name: "Bank Statements",
+ endpoint: "/v1/bank-statement",
+ tier: 1,
+ vertical: "finance",
+ desc: "Transaction timeline with anomaly detection, balance reconciliation, and cash flow insights",
+ regions: ["US", "UK", "EU", "CA", "AU", "+41"],
+ schema: {
+ institution: "string",
+ account_last_4: "string",
+ period: "{start, end}",
+ opening_balance: "number",
+ closing_balance: "number",
+ transactions: "[{date, desc, amount, balance}]",
+ },
+ },
+ {
+ name: "Invoices",
+ endpoint: "/v1/invoice",
+ tier: 1,
+ vertical: "finance",
+ desc: "Three-way match status, approval actions, and AI chat for line-item disputes",
+ regions: ["US", "UK", "EU", "DE", "JP", "BR", "+52"],
+ schema: {
+ vendor_name: "string",
+ invoice_number: "string",
+ date: "date",
+ line_items: "[{desc, qty, unit_price, total}]",
+ subtotal: "number",
+ tax: "number",
+ total: "number",
+ payment_terms: "string",
+ },
+ },
+];
+
+const legal: readonly Endpoint[] = [
+ {
+ name: "Contracts",
+ endpoint: "/v1/contract",
+ tier: 1,
+ vertical: "legal",
+ desc: "Clause-by-clause risk highlights, renewal countdown, and negotiation AI",
+ regions: ["Global"],
+ schema: {
+ parties: "[{name, role}]",
+ effective_date: "date",
+ term_months: "number",
+ renewal: "string",
+ governing_law: "string",
+ signature_status: "string",
+ },
+ },
+ {
+ name: "Court Filings",
+ endpoint: "/v1/court-filing",
+ tier: 1,
+ vertical: "legal",
+ desc: "Docket event timeline with deadline alerts, motion status, and counsel response tracking",
+ regions: ["US", "UK", "CA", "AU", "+16"],
+ schema: {
+ case_number: "string",
+ court: "string",
+ parties: "[{name, role}]",
+ filing_date: "date",
+ document_class: "string",
+ docket_entries: "[{date, description}]",
+ },
+ },
+ {
+ name: "Corporate Formation Docs",
+ endpoint: "/v1/corp-formation",
+ tier: 1,
+ vertical: "legal",
+ desc: "Entity status dashboard with good standing checks, officer compliance, and filing tracker",
+ regions: ["US", "UK", "DE", "SG", "HK", "+28"],
+ schema: {
+ entity_name: "string",
+ entity_type: "string",
+ jurisdiction: "string",
+ formation_date: "date",
+ officers: "[{name, title}]",
+ registered_agent: "string",
+ authorized_shares: "number?",
+ },
+ },
+ {
+ name: "UCC Filings",
+ endpoint: "/v1/ucc",
+ tier: 1,
+ vertical: "legal",
+ desc: "UCC status view with lapse countdown, priority verification, and collateral tracking",
+ regions: ["US"],
+ schema: {
+ filing_number: "string",
+ filing_date: "date",
+ secured_party: "string",
+ debtor: "string",
+ collateral: "string",
+ filing_office: "string",
+ status: "string",
+ lapse_date: "date",
+ },
+ },
+ {
+ name: "Powers of Attorney",
+ endpoint: "/v1/poa",
+ tier: 1,
+ vertical: "legal",
+ desc: "Agent authority scope with durable status verification, witness records, and action permissions",
+ regions: ["US", "UK", "EU", "Global"],
+ schema: {
+ principal: "string",
+ agent: "string",
+ scope: "[string]",
+ effective_date: "date",
+ durable: "boolean",
+ expiry_date: "date?",
+ witnesses: "[string]",
+ },
+ },
+ {
+ name: "Patent Applications",
+ endpoint: "/v1/patent",
+ tier: 1,
+ vertical: "legal",
+ desc: "Patent prosecution timeline with office action tracking, fee deadlines, and claim status",
+ regions: ["US", "EU", "JP", "CN", "KR", "+8"],
+ schema: {
+ application_number: "string",
+ title: "string",
+ inventors: "[string]",
+ assignee: "string",
+ priority_date: "date",
+ classifications: "[string]",
+ claims_count: "number",
+ status: "string",
+ },
+ },
+];
+
+const healthcare: readonly Endpoint[] = [
+ {
+ name: "Prior Authorizations",
+ endpoint: "/v1/prior-auth",
+ tier: 1,
+ vertical: "healthcare",
+ desc: "Decision tracking, medical necessity AI, and automatic payer follow-up notifications",
+ regions: ["US"],
+ schema: {
+ patient_id: "string",
+ provider_npi: "string",
+ payer: "string",
+ procedure_codes: "[string]",
+ diagnosis_codes: "[string]",
+ medical_necessity: "string",
+ decision: "string",
+ auth_number: "string?",
+ },
+ },
+ {
+ name: "Discharge Summaries",
+ endpoint: "/v1/discharge",
+ tier: 1,
+ vertical: "healthcare",
+ desc: "Discharge plan with follow-up order checklist, medication list, and continuity of care actions",
+ regions: ["US", "UK", "CA", "AU"],
+ schema: {
+ patient_id: "string",
+ admission_date: "date",
+ discharge_date: "date",
+ diagnoses: "[{code, description}]",
+ procedures: "[string]",
+ medications: "[{name, dose, frequency}]",
+ follow_up: "[string]",
+ },
+ },
+ {
+ name: "Clinical Trial Documents",
+ endpoint: "/v1/clinical-trial",
+ tier: 1,
+ vertical: "healthcare",
+ desc: "Trial progress view with enrollment metrics, site performance, and endpoint tracking",
+ regions: ["US", "EU", "UK", "JP", "+12"],
+ schema: {
+ trial_id: "string",
+ phase: "string",
+ sponsor: "string",
+ indication: "string",
+ primary_endpoint: "string",
+ enrollment: "number",
+ sites: "number",
+ status: "string",
+ },
+ },
+ {
+ name: "FDA Submissions",
+ endpoint: "/v1/fda-submission",
+ tier: 1,
+ vertical: "healthcare",
+ desc: "Submission status timeline with decision tracking, reviewer feedback, and compliance alerts",
+ regions: ["US"],
+ schema: {
+ submission_type: "string",
+ submission_number: "string",
+ applicant: "string",
+ product_name: "string",
+ classification: "string",
+ predicate_device: "string?",
+ decision: "string",
+ decision_date: "date?",
+ },
+ },
+ {
+ name: "Pathology Reports",
+ endpoint: "/v1/pathology",
+ tier: 1,
+ vertical: "healthcare",
+ desc: "Pathology summary with diagnostic alerts, biomarker results, and tumor board routing",
+ regions: ["US", "UK", "EU", "Global"],
+ schema: {
+ patient_id: "string",
+ specimen_type: "string",
+ diagnosis: "string",
+ staging: "string?",
+ margins: "string",
+ biomarkers: "[{marker, result}]",
+ pathologist: "string",
+ },
+ },
+ {
+ name: "Patient Intake Forms",
+ endpoint: "/v1/patient-intake",
+ tier: 1,
+ vertical: "healthcare",
+ desc: "Patient profile with allergy alerts, medication conflict detection, and insurance verification",
+ regions: ["US", "UK", "CA", "AU", "+18"],
+ schema: {
+ patient_name: "string",
+ dob: "date",
+ insurance_provider: "string",
+ policy_number: "string",
+ allergies: "[string]",
+ medications: "[string]",
+ chief_complaint: "string",
+ },
+ },
+];
+
+const government: readonly Endpoint[] = [
+ {
+ name: "Tax Forms",
+ endpoint: "/v1/tax-form",
+ tier: 1,
+ vertical: "government",
+ desc: "Tax liability view with filing deadline alerts, jurisdiction tracker, and e-file actions",
+ regions: ["US", "UK", "DE", "FR", "BR", "JP", "IN", "+43"],
+ schema: {
+ form_type: "string",
+ jurisdiction: "string",
+ tax_year: "number",
+ entity: "string",
+ gross_income: "number",
+ tax_withheld: "number",
+ filing_status: "string",
+ },
+ },
+ {
+ name: "Permits & Licenses",
+ endpoint: "/v1/permit",
+ tier: 1,
+ vertical: "government",
+ desc: "Permit dashboard showing status, conditions, renewal dates, and compliance checklist",
+ regions: ["US", "UK", "EU", "CA", "AU", "+32"],
+ schema: {
+ permit_type: "string",
+ permit_number: "string",
+ issuing_authority: "string",
+ holder: "string",
+ issue_date: "date",
+ expiry_date: "date",
+ conditions: "[string]",
+ status: "string",
+ },
+ },
+ {
+ name: "Grant Applications",
+ endpoint: "/v1/grant",
+ tier: 1,
+ vertical: "government",
+ desc: "Grant status with approval workflow, funding milestones, and compliance audit tracking",
+ regions: ["US", "UK", "EU"],
+ schema: {
+ applicant: "string",
+ program: "string",
+ amount_requested: "number",
+ project_title: "string",
+ period: "{start, end}",
+ budget_categories: "[{category, amount}]",
+ status: "string",
+ },
+ },
+ {
+ name: "FOIA Responses",
+ endpoint: "/v1/foia",
+ tier: 1,
+ vertical: "government",
+ desc: "Request status with exemption review, redaction tracker, and disclosure timeline",
+ regions: ["US", "UK", "CA", "AU"],
+ schema: {
+ request_number: "string",
+ agency: "string",
+ requester: "string",
+ responsive_pages: "number",
+ redacted_pages: "number",
+ exemptions_cited: "[string]",
+ disposition: "string",
+ },
+ },
+ {
+ name: "Municipal Filings",
+ endpoint: "/v1/municipal-filing",
+ tier: 1,
+ vertical: "government",
+ desc: "Filing timeline with hearing date alerts, public comment deadline, and status updates",
+ regions: ["US", "UK", "CA", "+18"],
+ schema: {
+ filing_type: "string",
+ municipality: "string",
+ applicant: "string",
+ property_address: "string?",
+ date: "date",
+ status: "string",
+ hearing_date: "date?",
+ },
+ },
+ {
+ name: "Customs Declarations",
+ endpoint: "/v1/customs",
+ tier: 1,
+ vertical: "government",
+ desc: "Customs entry view with HS code mapping, duty calculation, and tariff classification AI",
+ regions: ["US", "UK", "EU", "CN", "JP", "SG", "+48"],
+ schema: {
+ entry_number: "string",
+ importer: "string",
+ country_origin: "string",
+ items: "[{hs_code, description, value, duty}]",
+ total_value: "number",
+ total_duty: "number",
+ port: "string",
+ },
+ },
+];
+
+const operations: readonly Endpoint[] = [
+ {
+ name: "Bills of Lading",
+ endpoint: "/v1/bol",
+ tier: 1,
+ vertical: "operations",
+ desc: "Shipping document view with incoterm validation, port tracking, and cargo checklist",
+ regions: ["Global"],
+ schema: {
+ bol_number: "string",
+ shipper: "string",
+ consignee: "string",
+ carrier: "string",
+ vessel: "string?",
+ port_loading: "string",
+ port_discharge: "string",
+ goods: "[{description, weight, packages}]",
+ },
+ },
+ {
+ name: "Certificates of Origin",
+ endpoint: "/v1/cert-origin",
+ tier: 1,
+ vertical: "operations",
+ desc: "Certificate details with tariff eligibility verification, rules of origin check, and compliance badges",
+ regions: ["US", "EU", "UK", "MX", "CA", "+38"],
+ schema: {
+ certificate_number: "string",
+ exporter: "string",
+ importer: "string",
+ country_origin: "string",
+ goods: "[{description, hs_code, value}]",
+ trade_agreement: "string?",
+ issued_by: "string",
+ },
+ },
+ {
+ name: "Warehouse Receipts",
+ endpoint: "/v1/warehouse-receipt",
+ tier: 1,
+ vertical: "operations",
+ desc: "Warehouse inventory view with lien holder tracking, withdrawal requests, and storage timeline",
+ regions: ["US", "UK", "EU", "+22"],
+ schema: {
+ receipt_number: "string",
+ warehouse: "string",
+ depositor: "string",
+ goods: "[{description, quantity, unit}]",
+ storage_date: "date",
+ location: "string",
+ lien_status: "string",
+ },
+ },
+ {
+ name: "Inspection Reports",
+ endpoint: "/v1/inspection",
+ tier: 1,
+ vertical: "operations",
+ desc: "Inspection findings with remediation actions, re-audit scheduling, and compliance status",
+ regions: ["Global"],
+ schema: {
+ inspector: "string",
+ date: "date",
+ location: "string",
+ findings: "[{item, status, note}]",
+ overall_status: "string",
+ next_inspection: "date?",
+ },
+ },
+ {
+ name: "Purchase Orders",
+ endpoint: "/v1/purchase-order",
+ tier: 1,
+ vertical: "operations",
+ desc: "PO status with approval workflow, invoice matching, and delivery tracking buttons",
+ regions: ["Global"],
+ schema: {
+ po_number: "string",
+ vendor: "string",
+ date: "date",
+ line_items: "[{desc, qty, unit_price, total}]",
+ total: "number",
+ delivery_terms: "string",
+ approved_by: "string?",
+ },
+ },
+ {
+ name: "Packing Lists",
+ endpoint: "/v1/packing-list",
+ tier: 1,
+ vertical: "operations",
+ desc: "Shipment manifest with PO reconciliation, receiving report actions, and discrepancy alerts",
+ regions: ["Global"],
+ schema: {
+ shipment_id: "string",
+ shipper: "string",
+ packages: "[{package_id, contents, weight, dimensions}]",
+ total_packages: "number",
+ total_weight: "string",
+ po_reference: "string?",
+ },
+ },
+];
+
+const hr: readonly Endpoint[] = [
+ {
+ name: "I-9 / Right to Work",
+ endpoint: "/v1/i9",
+ tier: 1,
+ vertical: "hr",
+ desc: "Verification status with document validity tracking, expiry alerts, and re-verification scheduler",
+ regions: ["US", "UK", "EU", "AU", "CA"],
+ schema: {
+ employee_name: "string",
+ document_type: "string",
+ document_number: "string",
+ expiration_date: "date?",
+ issuing_authority: "string",
+ verified: "boolean",
+ },
+ },
+ {
+ name: "Employment Verification Letters",
+ endpoint: "/v1/employment-verification",
+ tier: 1,
+ vertical: "hr",
+ desc: "Employment history view with title verification, salary details, and underwriting status",
+ regions: ["US", "UK", "CA", "AU", "+14"],
+ schema: {
+ employer: "string",
+ employee: "string",
+ start_date: "date",
+ end_date: "date?",
+ title: "string",
+ salary: "number?",
+ employment_status: "string",
+ },
+ },
+ {
+ name: "Workers Comp Claims",
+ endpoint: "/v1/workers-comp",
+ tier: 1,
+ vertical: "hr",
+ desc: "Claim status with injury timeline, return-to-work plan, and case management actions",
+ regions: ["US", "UK", "CA", "AU"],
+ schema: {
+ claim_number: "string",
+ employee: "string",
+ injury_date: "date",
+ injury_type: "string",
+ body_part: "string",
+ provider: "string",
+ lost_days: "number",
+ claim_status: "string",
+ },
+ },
+ {
+ name: "Benefits Enrollment",
+ endpoint: "/v1/benefits-enrollment",
+ tier: 1,
+ vertical: "hr",
+ desc: "Enrollment status with dependent tracking, plan comparison, and open enrollment countdown",
+ regions: ["US", "UK", "CA"],
+ schema: {
+ employee: "string",
+ plan_type: "string",
+ coverage_tier: "string",
+ dependents: "[{name, relationship}]",
+ effective_date: "date",
+ employee_contribution: "number",
+ },
+ },
+ {
+ name: "Separation Agreements",
+ endpoint: "/v1/separation",
+ tier: 1,
+ vertical: "hr",
+ desc: "Separation terms dashboard with non-compete tracking, severance payment status, and sign-off",
+ regions: ["US", "UK", "EU", "Global"],
+ schema: {
+ employee: "string",
+ employer: "string",
+ separation_date: "date",
+ severance_amount: "number",
+ non_compete_months: "number?",
+ release_scope: "string",
+ consideration_period_days: "number",
+ },
+ },
+ {
+ name: "Resumes / CVs",
+ endpoint: "/v1/resume",
+ tier: 1,
+ vertical: "hr",
+ desc: "Candidate profile with skill matching, experience summary, and hiring endpoint status",
+ regions: ["Global"],
+ schema: {
+ name: "string",
+ email: "string",
+ phone: "string?",
+ experience: "[{company, title, start, end}]",
+ education: "[{school, degree, year}]",
+ skills: "[string]",
+ },
+ },
+];
+
+const realestate: readonly Endpoint[] = [
+ {
+ name: "Title Reports",
+ endpoint: "/v1/title-report",
+ tier: 1,
+ vertical: "realestate",
+ desc: "Title status with defect highlights, lien tracker, and insurance coverage verification",
+ regions: ["US", "UK", "CA", "AU"],
+ schema: {
+ property_address: "string",
+ owner: "string",
+ legal_description: "string",
+ liens: "[{type, holder, amount}]",
+ easements: "[string]",
+ exceptions: "[string]",
+ effective_date: "date",
+ },
+ },
+ {
+ name: "Appraisals",
+ endpoint: "/v1/appraisal",
+ tier: 1,
+ vertical: "realestate",
+ desc: "Property valuation with comparable analysis, condition assessment, and underwriting routing",
+ regions: ["US", "UK", "CA", "AU", "+8"],
+ schema: {
+ property_address: "string",
+ appraised_value: "number",
+ approach: "string",
+ comparables: "[{address, sale_price, adjustments}]",
+ condition: "string",
+ appraiser: "string",
+ date: "date",
+ },
+ },
+ {
+ name: "Environmental Assessments",
+ endpoint: "/v1/environmental",
+ tier: 1,
+ vertical: "realestate",
+ desc: "Phase assessment with liability findings, remediation recommendations, and insurance buttons",
+ regions: ["US", "UK", "EU", "CA"],
+ schema: {
+ property_address: "string",
+ phase: "string",
+ findings: "[{condition, severity, description}]",
+ recs: "[{condition, severity}]",
+ consultant: "string",
+ date: "date",
+ clean: "boolean",
+ },
+ },
+ {
+ name: "Lease Abstracts",
+ endpoint: "/v1/lease-abstract",
+ tier: 1,
+ vertical: "realestate",
+ desc: "Lease timeline with expiration alerts, renewal option tracking, and rent escalation calculator",
+ regions: ["US", "UK", "EU", "AU", "+12"],
+ schema: {
+ tenant: "string",
+ landlord: "string",
+ premises: "string",
+ commencement: "date",
+ expiry: "date",
+ base_rent: "number",
+ escalation: "string",
+ renewal_options: "[{term, notice_period}]",
+ },
+ },
+ {
+ name: "Closing Disclosures",
+ endpoint: "/v1/closing-disclosure",
+ tier: 1,
+ vertical: "realestate",
+ desc: "Closing costs view with TRID compliance check, fee comparison, and settlement team routing",
+ regions: ["US"],
+ schema: {
+ borrower: "string",
+ property: "string",
+ loan_amount: "number",
+ interest_rate: "number",
+ monthly_payment: "number",
+ closing_costs: "number",
+ cash_to_close: "number",
+ closing_date: "date",
+ },
+ },
+ {
+ name: "Zoning Documents",
+ endpoint: "/v1/zoning",
+ tier: 1,
+ vertical: "realestate",
+ desc: "Zoning summary with use restrictions, setback requirements, and variance application buttons",
+ regions: ["US", "UK", "CA"],
+ schema: {
+ property_address: "string",
+ zone_class: "string",
+ permitted_uses: "[string]",
+ setbacks: "{front, side, rear}",
+ far: "number",
+ max_height: "string",
+ variances: "[string]?",
+ },
+ },
+];
+
+const energy: readonly Endpoint[] = [
+ {
+ name: "Environmental Impact Statements",
+ endpoint: "/v1/eis",
+ tier: 1,
+ vertical: "energy",
+ desc: "Impact assessment with alternatives comparison, mitigation tracking, and public comment dashboard",
+ regions: ["US", "UK", "EU", "CA", "AU", "+14"],
+ schema: {
+ project: "string",
+ lead_agency: "string",
+ alternatives: "[{name, description}]",
+ impacts: "[{resource, severity, mitigation}]",
+ public_comments: "number",
+ record_of_decision: "string?",
+ },
+ },
+ {
+ name: "Pipeline Permits",
+ endpoint: "/v1/pipeline-permit",
+ tier: 1,
+ vertical: "energy",
+ desc: "Permit status with compliance conditions, route visualization, and segment monitoring tools",
+ regions: ["US", "UK", "CA", "AU"],
+ schema: {
+ permit_number: "string",
+ operator: "string",
+ route: "string",
+ capacity: "string",
+ material: "string",
+ length_miles: "number",
+ conditions: "[string]",
+ expiry_date: "date",
+ },
+ },
+ {
+ name: "NERC Compliance",
+ endpoint: "/v1/nerc",
+ tier: 1,
+ vertical: "energy",
+ desc: "Violation dashboard organized by severity, remediation plan tracking, and completion status",
+ regions: ["US", "CA"],
+ schema: {
+ entity: "string",
+ standard: "string",
+ finding: "string",
+ severity: "string",
+ violation_date: "date",
+ mitigation_plan: "string",
+ completion_date: "date?",
+ },
+ },
+ {
+ name: "Interconnection Agreements",
+ endpoint: "/v1/interconnection",
+ tier: 1,
+ vertical: "energy",
+ desc: "Generator connection agreement with network upgrade checklist and commercial operation date tracker",
+ regions: ["US", "UK", "EU", "AU"],
+ schema: {
+ generator: "string",
+ utility: "string",
+ capacity_mw: "number",
+ poi: "string",
+ voltage: "string",
+ commercial_operation_date: "date",
+ term_years: "number",
+ network_upgrades: "[string]",
+ },
+ },
+ {
+ name: "Well Logs",
+ endpoint: "/v1/well-log",
+ tier: 1,
+ vertical: "energy",
+ desc: "Well section with formation breakdown, depth tracking, and development planning actions",
+ regions: ["US", "CA", "UK", "AU", "+12"],
+ schema: {
+ well_name: "string",
+ api_number: "string",
+ operator: "string",
+ spud_date: "date",
+ total_depth: "number",
+ formations: "[{name, top, bottom, lithology}]",
+ status: "string",
+ },
+ },
+ {
+ name: "Decommissioning Reports",
+ endpoint: "/v1/decommissioning",
+ tier: 1,
+ vertical: "energy",
+ desc: "Decommission budget dashboard with remediation items, timeline, and regulatory approval flow",
+ regions: ["US", "UK", "EU", "CA"],
+ schema: {
+ facility: "string",
+ operator: "string",
+ asset_count: "number",
+ estimated_cost: "number",
+ timeline: "{start, end}",
+ remediation_items: "[{item, status}]",
+ regulatory_approval: "string",
+ },
+ },
+];
+
+export const VERTICALS: readonly Vertical[] = [
+ {
+ key: "insurance",
+ label: "Insurance",
+ color: "var(--color-cat-insurance)",
+ endpoints: insurance,
+ },
+ {
+ key: "compliance",
+ label: "Compliance",
+ color: "var(--color-cat-compliance)",
+ endpoints: compliance,
+ },
+ {
+ key: "finance",
+ label: "Finance",
+ color: "var(--color-cat-finance)",
+ endpoints: finance,
+ },
+ {
+ key: "legal",
+ label: "Legal",
+ color: "var(--color-cat-legal)",
+ endpoints: legal,
+ },
+ {
+ key: "healthcare",
+ label: "Healthcare",
+ color: "var(--color-cat-healthcare)",
+ endpoints: healthcare,
+ },
+ {
+ key: "government",
+ label: "Government",
+ color: "var(--color-cat-government)",
+ endpoints: government,
+ },
+ {
+ key: "operations",
+ label: "Supply Chain",
+ color: "var(--color-cat-operations)",
+ endpoints: operations,
+ },
+ { key: "hr", label: "HR", color: "var(--color-cat-hr)", endpoints: hr },
+ {
+ key: "realestate",
+ label: "Real Estate",
+ color: "var(--color-cat-realestate)",
+ endpoints: realestate,
+ },
+ {
+ key: "energy",
+ label: "Energy & Infrastructure",
+ color: "var(--color-cat-energy)",
+ endpoints: energy,
+ },
+];
+
+/** Flat list of every endpoint across all verticals. */
+export const ALL_ENDPOINTS: readonly Endpoint[] = VERTICALS.flatMap(
+ (v) => v.endpoints,
+);
+
+/** Index by path, e.g. ENDPOINTS_BY_PATH['/v1/coi']. */
+export const ENDPOINTS_BY_PATH: Record = Object.fromEntries(
+ ALL_ENDPOINTS.map((e) => [e.endpoint, e]),
+);
+
+/** Lookup a vertical by its key. */
+export const lookupVertical = (key: VerticalKey): Vertical | null =>
+ VERTICALS.find((v) => v.key === key) ?? null;
+
+/** Lookup an endpoint by its route path. */
+export const lookupEndpoint = (path: string): Endpoint | null =>
+ ENDPOINTS_BY_PATH[path] ?? null;
+
+/** Tier-availability gate used by the docs / cards / picker. */
+export function isEndpointAvailable(endpoint: Endpoint, tier: Tier): boolean {
+ if (endpoint.tier === 0) return true;
+ if (endpoint.tier === 1) return tier === "pro" || tier === "enterprise";
+ return tier === "enterprise";
+}
diff --git a/frontend/shared/data/ops.ts b/frontend/shared/data/ops.ts
new file mode 100644
index 000000000..0cb4fa6b0
--- /dev/null
+++ b/frontend/shared/data/ops.ts
@@ -0,0 +1,1308 @@
+/**
+ * The Stirling operation library.
+ *
+ * Three layers, ported faithfully from the prototype:
+ *
+ * 1. PIPELINE_OPS — the canonical pipeline-stage taxonomy. Ops are grouped
+ * by `OpKind` (ingest / validate / modify / secure / store / alert) which
+ * drives accent colours in chip rendering.
+ *
+ * 2. LIBRARY_OPS — the broader catalogue surfaced in the composer's
+ * Operations picker. Includes everything in PIPELINE_OPS plus the wider
+ * PDF tool set (formatting, extraction, AI-powered ops, schema-aware
+ * ops). Each library op also carries an `OpCategory` for finer-grained
+ * grouping in the picker UI.
+ *
+ * 3. PIPELINE_AGENTS — one-click bundles of related ops (PII Sweep, Trust &
+ * Verify, Compliance Pack, Format Prep). Selecting an agent expands its
+ * `ops` list into the chip row.
+ *
+ * SOURCE_OPTIONS and DESTINATION_OPTIONS sit alongside as the structural
+ * rails — every pipeline has exactly one source and one destination.
+ */
+
+/** The pipeline-stage taxonomy a chip-row uses for accent colours. */
+export type OpKind =
+ | "ingest"
+ | "validate"
+ | "modify"
+ | "secure"
+ | "store"
+ | "alert";
+
+/** Finer-grained groupings shown in the Operations picker. */
+export type OpCategory =
+ | "Document Security"
+ | "Validation"
+ | "Classification"
+ | "Document Review"
+ | "Signing"
+ | "Page Formatting"
+ | "Extraction"
+ | "Removal"
+ | "Automation"
+ | "Advanced Formatting"
+ | "Developer Tools";
+
+export interface PipelineOp {
+ id: string;
+ label: string;
+ icon: string;
+ desc: string;
+ kind: OpKind;
+ /** Ship in the default chip row when this op's stage is on. */
+ defaultOn?: boolean;
+ /** Only configurable in the pipeline composer (not as an ad-hoc op). */
+ pipelineOnly?: boolean;
+}
+
+export interface LibraryOp extends Omit<
+ PipelineOp,
+ "defaultOn" | "pipelineOnly"
+> {
+ category: OpCategory;
+ provider?: "claude" | "stirling";
+}
+
+export interface PipelineAgent {
+ id: string;
+ label: string;
+ icon: string;
+ desc: string;
+ /** Op IDs from PIPELINE_OPS that this agent expands into. */
+ ops: string[];
+}
+
+export interface SourceOption {
+ id: "upload" | "webhook" | "s3" | "email" | "scheduled";
+ label: string;
+ icon: string;
+ desc: string;
+}
+
+export interface DestinationOption {
+ id: "vault" | "s3" | "webhook" | "pipeline" | "database" | "sftp";
+ label: string;
+ icon: string;
+ desc: string;
+}
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* PIPELINE_OPS — canonical stages */
+/* ──────────────────────────────────────────────────────────────────────── */
+
+export const PIPELINE_OPS: Record = {
+ ingest: [
+ {
+ id: "ocr",
+ label: "OCR",
+ icon: "eye",
+ kind: "ingest",
+ desc: "Text-recognize scanned or image-based pages",
+ },
+ {
+ id: "parse",
+ label: "Parse",
+ icon: "layout",
+ kind: "ingest",
+ desc: "Reconstruct reading order and layout structure",
+ },
+ {
+ id: "classify",
+ label: "Classify",
+ icon: "layers",
+ kind: "ingest",
+ desc: "Identify the document type and return a confidence score",
+ },
+ {
+ id: "bundle-split",
+ label: "Bundle split",
+ icon: "grid",
+ kind: "ingest",
+ desc: "Split a multi-document upload into component documents",
+ },
+ {
+ id: "extract",
+ label: "Extract",
+ icon: "sparkles",
+ kind: "ingest",
+ desc: "Pull structured fields from the document into a typed schema",
+ },
+ ],
+ validate: [
+ {
+ id: "validate",
+ label: "Schema validate",
+ icon: "shield",
+ kind: "validate",
+ desc: "Check required fields, business rules, and coverage gaps against the typed schema",
+ },
+ {
+ id: "authenticity",
+ label: "Authenticity",
+ icon: "check",
+ kind: "validate",
+ desc: "Verify the document is genuine (signature, issuer, watermark checks)",
+ },
+ {
+ id: "tamper-check",
+ label: "Tamper check",
+ icon: "alertTriangle",
+ kind: "validate",
+ desc: "Detect modifications since signing or last-known-good state",
+ },
+ {
+ id: "source-auth",
+ label: "Source auth",
+ icon: "lock",
+ kind: "validate",
+ desc: "Confirm the document came from an authenticated source",
+ },
+ {
+ id: "counterparty-match",
+ label: "Counterparty match",
+ icon: "userPlus",
+ kind: "validate",
+ desc: "Match the document counterparty against expected identity",
+ },
+ {
+ id: "confidence-check",
+ label: "Confidence bounds",
+ icon: "activity",
+ kind: "validate",
+ desc: "Gate downstream ops on extraction-confidence thresholds",
+ },
+ ],
+ modify: [
+ {
+ id: "merge",
+ label: "Merge",
+ icon: "layers",
+ kind: "modify",
+ desc: "Combine multiple PDFs into a single document",
+ },
+ {
+ id: "split",
+ label: "Split",
+ icon: "grid",
+ kind: "modify",
+ desc: "Split a PDF into pages, sections, or by document boundary",
+ },
+ {
+ id: "convert",
+ label: "Convert",
+ icon: "fileText",
+ kind: "modify",
+ desc: "Convert between PDF, Word, Excel, image, HTML, Markdown",
+ },
+ {
+ id: "compress",
+ label: "Compress",
+ icon: "package",
+ kind: "modify",
+ desc: "Reduce file size while preserving fidelity",
+ },
+ {
+ id: "rotate",
+ label: "Rotate",
+ icon: "arrowRight",
+ kind: "modify",
+ desc: "Rotate pages or correct page orientation",
+ },
+ {
+ id: "crop",
+ label: "Crop",
+ icon: "layout",
+ kind: "modify",
+ desc: "Crop pages to a region or trim margins",
+ },
+ ],
+ secure: [
+ {
+ id: "redact",
+ label: "Redact PII",
+ icon: "eye",
+ kind: "secure",
+ defaultOn: true,
+ desc: "Remove or mask PII before the document is stored or released downstream",
+ },
+ {
+ id: "pii-enforce",
+ label: "PII/PHI enforcement",
+ icon: "shield",
+ kind: "secure",
+ desc: "Policy check that outputs do not leak protected data beyond declared scope",
+ },
+ {
+ id: "watermark",
+ label: "Confidentiality mark",
+ icon: "penTool",
+ kind: "secure",
+ desc: "Stamp a visible confidentiality / classification watermark onto pages",
+ },
+ {
+ id: "attribution-watermark",
+ label: "Attribution watermark",
+ icon: "penTool",
+ kind: "secure",
+ desc: "Invisible per-recipient watermarking for leak tracing",
+ },
+ {
+ id: "flatten",
+ label: "Flatten + lock",
+ icon: "layout",
+ kind: "secure",
+ desc: "Flatten forms and annotations to harden the document against tampering",
+ },
+ {
+ id: "sign-output",
+ label: "Signed outputs",
+ icon: "penTool",
+ kind: "secure",
+ desc: "Tamper-evident signatures covering the artifact and run metadata",
+ },
+ {
+ id: "encrypt-rest",
+ label: "Encryption at rest",
+ icon: "lock",
+ kind: "secure",
+ defaultOn: true,
+ desc: "AES-256 on stored artifacts. Stirling-managed, customer KMS, or BYOK",
+ },
+ {
+ id: "retention",
+ label: "Retention policy",
+ icon: "fileText",
+ kind: "secure",
+ defaultOn: true,
+ pipelineOnly: true,
+ desc: "How long Stirling retains the artifact, run record, and audit trail",
+ },
+ {
+ id: "residency",
+ label: "Regional residency",
+ icon: "globe",
+ kind: "secure",
+ pipelineOnly: true,
+ desc: "Where the artifact is stored and processed. Region-pin or air-gap",
+ },
+ {
+ id: "access-policy",
+ label: "Access policy",
+ icon: "key",
+ kind: "secure",
+ defaultOn: true,
+ pipelineOnly: true,
+ desc: "Who can fetch the sealed artifact (signed URL, IdP-gated, public)",
+ },
+ ],
+ store: [
+ {
+ id: "store-primary",
+ label: "Primary store",
+ icon: "fileText",
+ kind: "store",
+ defaultOn: true,
+ pipelineOnly: true,
+ desc: "Write the sealed artifact and its run record into the Documents centralized view",
+ },
+ {
+ id: "mirror-bucket",
+ label: "Mirror to bucket",
+ icon: "globe",
+ kind: "store",
+ pipelineOnly: true,
+ desc: "Copy the secured artifact to the customer’s S3 / GCS / Azure Blob for compliance archival",
+ },
+ {
+ id: "mirror-archive",
+ label: "Compliance archive",
+ icon: "lock",
+ kind: "store",
+ pipelineOnly: true,
+ desc: "Mirror to SharePoint / M365 Records / WORM-locked archive for regulated retention",
+ },
+ {
+ id: "emit-manifest",
+ label: "Processing manifest",
+ icon: "penTool",
+ kind: "store",
+ defaultOn: true,
+ pipelineOnly: true,
+ desc: "Emit a signed manifest describing every stage the document passed through",
+ },
+ ],
+ alert: [
+ {
+ id: "review",
+ label: "Human review",
+ icon: "userPlus",
+ kind: "alert",
+ desc: "Route the document to the review queue when rules fail",
+ },
+ {
+ id: "flag",
+ label: "Flag",
+ icon: "alertTriangle",
+ kind: "alert",
+ desc: "Raise an in-app flag on low-confidence or out-of-policy docs",
+ },
+ {
+ id: "notify",
+ label: "Notify",
+ icon: "bell",
+ kind: "alert",
+ desc: "Fire a webhook or email when the pipeline finishes or trips a rule",
+ },
+ ],
+};
+
+/** Flat lookup across every stage bucket. */
+export const PIPELINE_OPS_INDEX: Record = (() => {
+ const idx: Record = {};
+ for (const kind of Object.keys(PIPELINE_OPS) as OpKind[]) {
+ for (const op of PIPELINE_OPS[kind]) idx[op.id] = op;
+ }
+ return idx;
+})();
+
+export const lookupOp = (id: string): PipelineOp | null =>
+ PIPELINE_OPS_INDEX[id] ?? null;
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* PIPELINE_AGENTS — one-click op bundles */
+/* ──────────────────────────────────────────────────────────────────────── */
+
+export const PIPELINE_AGENTS: readonly PipelineAgent[] = [
+ {
+ id: "agent-pii-sweep",
+ label: "PII Sweep",
+ icon: "shield",
+ desc: "Redact PII categories + encrypt the sealed artifact",
+ ops: ["redact", "encrypt-rest"],
+ },
+ {
+ id: "agent-trust-verify",
+ label: "Trust & Verify",
+ icon: "check",
+ desc: "Authenticity, tamper, and confidence checks before downstream",
+ ops: ["authenticity", "tamper-check", "confidence-check"],
+ },
+ {
+ id: "agent-compliance-pack",
+ label: "Compliance Pack",
+ icon: "lock",
+ desc: "Redact + watermark + sign + retention policy",
+ ops: ["redact", "watermark", "sign-output", "retention"],
+ },
+ {
+ id: "agent-format-prep",
+ label: "Format Prep",
+ icon: "package",
+ desc: "Compress and flatten before downstream consumption",
+ ops: ["compress", "flatten"],
+ },
+];
+
+export const lookupAgent = (id: string): PipelineAgent | null =>
+ PIPELINE_AGENTS.find((a) => a.id === id) ?? null;
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* OP_CATEGORIES — picker section metadata */
+/* ──────────────────────────────────────────────────────────────────────── */
+
+export interface OpCategoryMeta {
+ name: OpCategory;
+ color: string;
+ blurb: string;
+}
+
+export const OP_CATEGORIES: readonly OpCategoryMeta[] = [
+ {
+ name: "Document Security",
+ color: "var(--color-red)",
+ blurb: "PII, encryption, watermarks, policy seal",
+ },
+ {
+ name: "Validation",
+ color: "var(--color-blue)",
+ blurb: "Schema checks, trust gates, filters",
+ },
+ {
+ name: "Classification",
+ color: "var(--color-purple)",
+ blurb: "Type inference, routing, boundary detection",
+ },
+ {
+ name: "Document Review",
+ color: "var(--color-cat-compliance)",
+ blurb: "Inspection, AI review, certification",
+ },
+ {
+ name: "Signing",
+ color: "var(--color-green)",
+ blurb: "Digital signatures, cert sign / remove",
+ },
+ {
+ name: "Page Formatting",
+ color: "var(--color-cat-energy)",
+ blurb: "Rotate, crop, layout, page-level edits",
+ },
+ {
+ name: "Extraction",
+ color: "var(--color-cat-extraction)",
+ blurb: "OCR, format conversion, content pull",
+ },
+ {
+ name: "Removal",
+ color: "var(--color-amber)",
+ blurb: "Drop pages, blanks, images, partition splits",
+ },
+ {
+ name: "Automation",
+ color: "var(--color-cat-operations)",
+ blurb: "AI-driven generation, translation, forms",
+ },
+ {
+ name: "Advanced Formatting",
+ color: "var(--color-cat-healthcare)",
+ blurb: "TOC, overlays, format-to-PDF, PDF/A",
+ },
+ {
+ name: "Developer Tools",
+ color: "var(--color-text-5)",
+ blurb: "Repair, compress, metadata",
+ },
+];
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* LIBRARY_OPS — full atomic operation catalogue */
+/* ──────────────────────────────────────────────────────────────────────── */
+
+export const LIBRARY_OPS: readonly LibraryOp[] = [
+ // Validation / Document Review
+ {
+ id: "validate",
+ label: "Schema validate",
+ icon: "shield",
+ kind: "validate",
+ category: "Validation",
+ desc: "Check required fields, business rules, and coverage gaps against the typed schema",
+ },
+ {
+ id: "authenticity",
+ label: "Authenticity",
+ icon: "check",
+ kind: "validate",
+ category: "Validation",
+ desc: "Verify the document is genuine (signature, issuer, watermark checks)",
+ },
+ {
+ id: "tamper-check",
+ label: "Tamper check",
+ icon: "alertTriangle",
+ kind: "validate",
+ category: "Validation",
+ desc: "Detect modifications since signing or last-known-good state",
+ },
+ {
+ id: "source-auth",
+ label: "Source auth",
+ icon: "lock",
+ kind: "validate",
+ category: "Validation",
+ desc: "Confirm the document came from an authenticated source",
+ },
+ {
+ id: "counterparty-match",
+ label: "Counterparty match",
+ icon: "userPlus",
+ kind: "validate",
+ category: "Validation",
+ desc: "Match the document counterparty against expected identity",
+ },
+ {
+ id: "confidence-check",
+ label: "Confidence bounds",
+ icon: "activity",
+ kind: "validate",
+ category: "Validation",
+ desc: "Gate downstream ops on extraction-confidence thresholds",
+ },
+ {
+ id: "inspect-basic",
+ label: "Basic info",
+ icon: "fileText",
+ kind: "validate",
+ category: "Document Review",
+ desc: "Get document metadata (title, author, page count, dates)",
+ },
+ {
+ id: "inspect-pages",
+ label: "Page count",
+ icon: "fileText",
+ kind: "validate",
+ category: "Document Review",
+ desc: "Return the page count of the document",
+ },
+ {
+ id: "inspect-fonts",
+ label: "Font info",
+ icon: "fileText",
+ kind: "validate",
+ category: "Document Review",
+ desc: "List fonts embedded in the document",
+ },
+ {
+ id: "inspect-security",
+ label: "Security info",
+ icon: "lock",
+ kind: "validate",
+ category: "Document Review",
+ desc: "Read security configuration (encryption, signatures, permissions)",
+ },
+ {
+ id: "inspect-form",
+ label: "Form fields",
+ icon: "fileText",
+ kind: "validate",
+ category: "Document Review",
+ desc: "Enumerate form fields and their values",
+ },
+ {
+ id: "filter-text",
+ label: "Filter by text",
+ icon: "shield",
+ kind: "validate",
+ category: "Validation",
+ desc: "Pass only docs containing specific text",
+ },
+ {
+ id: "filter-image",
+ label: "Filter by image",
+ icon: "shield",
+ kind: "validate",
+ category: "Validation",
+ desc: "Pass only docs containing images",
+ },
+ {
+ id: "filter-page-count",
+ label: "Filter by page count",
+ icon: "shield",
+ kind: "validate",
+ category: "Validation",
+ desc: "Pass only docs within a page-count range",
+ },
+ {
+ id: "filter-page-size",
+ label: "Filter by page size",
+ icon: "shield",
+ kind: "validate",
+ category: "Validation",
+ desc: "Pass only docs matching a page-dimension constraint",
+ },
+ {
+ id: "filter-file-size",
+ label: "Filter by file size",
+ icon: "shield",
+ kind: "validate",
+ category: "Validation",
+ desc: "Pass only docs within a file-size range",
+ },
+
+ // Modify / Page Formatting / Removal / Extraction / Advanced Formatting / Developer Tools
+ {
+ id: "merge",
+ label: "Merge",
+ icon: "layers",
+ kind: "modify",
+ category: "Page Formatting",
+ desc: "Combine multiple PDFs into a single document",
+ },
+ {
+ id: "split",
+ label: "Split",
+ icon: "grid",
+ kind: "modify",
+ category: "Removal",
+ desc: "Split a PDF into pages, sections, or by document boundary",
+ },
+ {
+ id: "split-chapters",
+ label: "Split by chapters",
+ icon: "grid",
+ kind: "modify",
+ category: "Removal",
+ desc: "Split a PDF along bookmark or chapter boundaries",
+ },
+ {
+ id: "split-size",
+ label: "Split by size",
+ icon: "grid",
+ kind: "modify",
+ category: "Removal",
+ desc: "Split a PDF when it exceeds a size or page-count threshold",
+ },
+ {
+ id: "auto-split",
+ label: "Auto split",
+ icon: "grid",
+ kind: "modify",
+ category: "Classification",
+ desc: "Detect document boundaries and split automatically",
+ },
+ {
+ id: "remove-pages",
+ label: "Remove pages",
+ icon: "grid",
+ kind: "modify",
+ category: "Removal",
+ desc: "Drop specified pages from the document",
+ },
+ {
+ id: "remove-blanks",
+ label: "Remove blank pages",
+ icon: "grid",
+ kind: "modify",
+ category: "Removal",
+ desc: "Detect and drop blank pages",
+ },
+ {
+ id: "rearrange-pages",
+ label: "Rearrange pages",
+ icon: "grid",
+ kind: "modify",
+ category: "Page Formatting",
+ desc: "Reorder pages by a specified sequence",
+ },
+ {
+ id: "rotate",
+ label: "Rotate",
+ icon: "arrowRight",
+ kind: "modify",
+ category: "Page Formatting",
+ desc: "Rotate pages or correct page orientation",
+ },
+ {
+ id: "crop",
+ label: "Crop",
+ icon: "layout",
+ kind: "modify",
+ category: "Page Formatting",
+ desc: "Crop pages to a region or trim margins",
+ },
+ {
+ id: "compress",
+ label: "Compress",
+ icon: "package",
+ kind: "modify",
+ category: "Developer Tools",
+ desc: "Reduce file size while preserving fidelity",
+ },
+ {
+ id: "flatten",
+ label: "Flatten",
+ icon: "layout",
+ kind: "modify",
+ category: "Page Formatting",
+ desc: "Flatten forms and annotations into the page content",
+ },
+ {
+ id: "repair",
+ label: "Repair",
+ icon: "package",
+ kind: "modify",
+ category: "Developer Tools",
+ desc: "Repair a corrupted or malformed PDF",
+ },
+ {
+ id: "remove-images",
+ label: "Remove images",
+ icon: "eye",
+ kind: "modify",
+ category: "Removal",
+ desc: "Strip images to reduce file size",
+ },
+ {
+ id: "update-metadata",
+ label: "Update metadata",
+ icon: "fileText",
+ kind: "modify",
+ category: "Developer Tools",
+ desc: "Edit PDF metadata fields (title, author, keywords)",
+ },
+ {
+ id: "auto-rename",
+ label: "Auto rename",
+ icon: "fileText",
+ kind: "modify",
+ category: "Classification",
+ desc: "Rename based on extracted document content",
+ },
+ {
+ id: "add-image",
+ label: "Add image",
+ icon: "penTool",
+ kind: "modify",
+ category: "Page Formatting",
+ desc: "Insert an image onto pages",
+ },
+ {
+ id: "add-stamp",
+ label: "Add stamp",
+ icon: "penTool",
+ kind: "modify",
+ category: "Page Formatting",
+ desc: "Apply a stamp to pages",
+ },
+ {
+ id: "add-page-numbers",
+ label: "Page numbers",
+ icon: "penTool",
+ kind: "modify",
+ category: "Page Formatting",
+ desc: "Add page numbers to the document",
+ },
+ {
+ id: "add-attachments",
+ label: "Add attachments",
+ icon: "penTool",
+ kind: "modify",
+ category: "Advanced Formatting",
+ desc: "Embed file attachments in the PDF",
+ },
+ {
+ id: "overlay",
+ label: "Overlay PDFs",
+ icon: "layers",
+ kind: "modify",
+ category: "Advanced Formatting",
+ desc: "Overlay one PDF on top of another",
+ },
+ {
+ id: "multi-page-layout",
+ label: "Multi-page layout",
+ icon: "grid",
+ kind: "modify",
+ category: "Advanced Formatting",
+ desc: "Combine multiple pages into a single page (n-up)",
+ },
+ {
+ id: "scale-pages",
+ label: "Scale pages",
+ icon: "layout",
+ kind: "modify",
+ category: "Page Formatting",
+ desc: "Resize pages to a target dimension",
+ },
+ {
+ id: "edit-toc",
+ label: "Edit TOC",
+ icon: "fileText",
+ kind: "modify",
+ category: "Advanced Formatting",
+ desc: "Edit the table of contents / bookmarks",
+ },
+ {
+ id: "scanner-effect",
+ label: "Scanner effect",
+ icon: "penTool",
+ kind: "modify",
+ category: "Page Formatting",
+ desc: "Apply a scanned-document visual effect",
+ },
+ {
+ id: "invert-colors",
+ label: "Invert colors",
+ icon: "penTool",
+ kind: "modify",
+ category: "Page Formatting",
+ desc: "Invert page colors or replace specific colors",
+ },
+ {
+ id: "convert-image",
+ label: "PDF → image",
+ icon: "fileText",
+ kind: "modify",
+ category: "Extraction",
+ desc: "Convert PDF pages to image files",
+ },
+ {
+ id: "image-to-pdf",
+ label: "Image → PDF",
+ icon: "fileText",
+ kind: "modify",
+ category: "Advanced Formatting",
+ desc: "Convert images into a PDF",
+ },
+ {
+ id: "convert-word",
+ label: "PDF → Word",
+ icon: "fileText",
+ kind: "modify",
+ category: "Extraction",
+ desc: "Convert PDF to a DOCX document",
+ },
+ {
+ id: "convert-pptx",
+ label: "PDF → presentation",
+ icon: "fileText",
+ kind: "modify",
+ category: "Extraction",
+ desc: "Convert PDF to a PPTX deck",
+ },
+ {
+ id: "convert-text",
+ label: "PDF → text",
+ icon: "fileText",
+ kind: "modify",
+ category: "Extraction",
+ desc: "Extract plain text from the PDF",
+ },
+ {
+ id: "convert-html",
+ label: "PDF → HTML",
+ icon: "fileText",
+ kind: "modify",
+ category: "Extraction",
+ desc: "Convert PDF to HTML",
+ },
+ {
+ id: "convert-xml",
+ label: "PDF → XML",
+ icon: "fileText",
+ kind: "modify",
+ category: "Extraction",
+ desc: "Convert PDF to structured XML",
+ },
+ {
+ id: "convert-csv",
+ label: "PDF → CSV",
+ icon: "fileText",
+ kind: "modify",
+ category: "Extraction",
+ desc: "Extract tables from the PDF as CSV",
+ },
+ {
+ id: "convert-markdown",
+ label: "PDF → Markdown",
+ icon: "fileText",
+ kind: "modify",
+ category: "Extraction",
+ desc: "Convert PDF to Markdown",
+ },
+ {
+ id: "convert-pdfa",
+ label: "PDF → PDF/A",
+ icon: "fileText",
+ kind: "modify",
+ category: "Advanced Formatting",
+ desc: "Convert to the PDF/A archival format",
+ },
+ {
+ id: "html-to-pdf",
+ label: "HTML → PDF",
+ icon: "fileText",
+ kind: "modify",
+ category: "Advanced Formatting",
+ desc: "Render HTML as a PDF",
+ },
+ {
+ id: "markdown-to-pdf",
+ label: "Markdown → PDF",
+ icon: "fileText",
+ kind: "modify",
+ category: "Advanced Formatting",
+ desc: "Render Markdown as a PDF",
+ },
+ {
+ id: "url-to-pdf",
+ label: "URL → PDF",
+ icon: "fileText",
+ kind: "modify",
+ category: "Advanced Formatting",
+ desc: "Render a web page as a PDF",
+ },
+ {
+ id: "eml-to-pdf",
+ label: "Email → PDF",
+ icon: "fileText",
+ kind: "modify",
+ category: "Advanced Formatting",
+ desc: "Convert an email file (.eml) to a PDF",
+ },
+ {
+ id: "ocr",
+ label: "OCR",
+ icon: "eye",
+ kind: "modify",
+ category: "Extraction",
+ desc: "Optical character recognition on scanned pages",
+ },
+ {
+ id: "extract-images",
+ label: "Extract images",
+ icon: "sparkles",
+ kind: "modify",
+ category: "Extraction",
+ desc: "Extract embedded images from the PDF",
+ },
+ {
+ id: "extract-bookmarks",
+ label: "Extract bookmarks",
+ icon: "sparkles",
+ kind: "modify",
+ category: "Extraction",
+ desc: "Extract the bookmark / outline tree",
+ },
+ {
+ id: "extract-scans",
+ label: "Extract scans",
+ icon: "sparkles",
+ kind: "modify",
+ category: "Extraction",
+ desc: "Detect and extract scanned image regions",
+ },
+
+ // Secure
+ {
+ id: "redact",
+ label: "Redact PII",
+ icon: "eye",
+ kind: "secure",
+ category: "Document Security",
+ desc: "Remove or mask PII before the document is stored or released",
+ },
+ {
+ id: "pii-enforce",
+ label: "PII / PHI enforcement",
+ icon: "shield",
+ kind: "secure",
+ category: "Document Security",
+ desc: "Policy check that outputs do not leak protected data beyond declared scope",
+ },
+ {
+ id: "auto-redact",
+ label: "Auto-redact",
+ icon: "eye",
+ kind: "secure",
+ category: "Document Security",
+ desc: "Automatically redact sensitive content based on policy",
+ },
+ {
+ id: "watermark",
+ label: "Confidentiality mark",
+ icon: "penTool",
+ kind: "secure",
+ category: "Document Security",
+ desc: "Apply a visible confidentiality watermark to pages",
+ },
+ {
+ id: "attribution-watermark",
+ label: "Attribution watermark",
+ icon: "penTool",
+ kind: "secure",
+ category: "Document Security",
+ desc: "Embed a per-recipient invisible watermark for leak tracing",
+ },
+ {
+ id: "sanitize",
+ label: "Sanitize",
+ icon: "shield",
+ kind: "secure",
+ category: "Document Security",
+ desc: "Strip hidden data, JavaScript, and metadata from the PDF",
+ },
+ {
+ id: "sign-output",
+ label: "Sign outputs",
+ icon: "penTool",
+ kind: "secure",
+ category: "Signing",
+ desc: "Apply a tamper-evident signature covering the artifact and run metadata",
+ },
+ {
+ id: "cert-sign",
+ label: "Certificate sign",
+ icon: "penTool",
+ kind: "secure",
+ category: "Signing",
+ desc: "Sign with a digital certificate",
+ },
+ {
+ id: "add-password",
+ label: "Add password",
+ icon: "lock",
+ kind: "secure",
+ category: "Document Security",
+ desc: "Password-protect the output PDF",
+ },
+ {
+ id: "encrypt-rest",
+ label: "Encryption at rest",
+ icon: "lock",
+ kind: "secure",
+ category: "Document Security",
+ desc: "AES-256 encryption on stored artifacts (Stirling-managed, BYOK, or HYOK)",
+ },
+ {
+ id: "flatten-secure",
+ label: "Flatten + lock",
+ icon: "layout",
+ kind: "secure",
+ category: "Document Security",
+ desc: "Flatten forms and lock the document against tampering",
+ },
+ {
+ id: "retention",
+ label: "Retention policy",
+ icon: "fileText",
+ kind: "secure",
+ category: "Document Security",
+ desc: "How long Stirling retains the artifact, run record, and audit trail",
+ },
+ {
+ id: "residency",
+ label: "Regional residency",
+ icon: "globe",
+ kind: "secure",
+ category: "Document Security",
+ desc: "Where the artifact is stored and processed (region-pin or air-gap)",
+ },
+ {
+ id: "access-policy",
+ label: "Access policy",
+ icon: "key",
+ kind: "secure",
+ category: "Document Security",
+ desc: "Who can fetch the sealed artifact (signed URL, IdP-gated, public)",
+ },
+ {
+ id: "remove-cert-sign",
+ label: "Remove signature",
+ icon: "penTool",
+ kind: "secure",
+ category: "Signing",
+ desc: "Strip a digital signature from the document",
+ },
+
+ // AI-powered ops
+ {
+ id: "summarize",
+ label: "Document summarizer",
+ icon: "fileText",
+ kind: "modify",
+ category: "Document Review",
+ provider: "claude",
+ desc: "Generate executive summaries and key insights",
+ },
+ {
+ id: "translate",
+ label: "Document translator",
+ icon: "globe",
+ kind: "modify",
+ category: "Automation",
+ provider: "claude",
+ desc: "Translate documents while preserving formatting",
+ },
+ {
+ id: "contract-analyze",
+ label: "Contract analyzer",
+ icon: "shield",
+ kind: "validate",
+ category: "Document Review",
+ provider: "claude",
+ desc: "Review contracts for risks, obligations, and key terms",
+ },
+ {
+ id: "compliance-audit",
+ label: "Compliance auditor",
+ icon: "check",
+ kind: "validate",
+ category: "Document Review",
+ provider: "stirling",
+ desc: "Check documents against regulatory requirements",
+ },
+ {
+ id: "generate",
+ label: "Document generation",
+ icon: "penTool",
+ kind: "modify",
+ category: "Automation",
+ provider: "claude",
+ desc: "Create contracts, reports, and business documents from prompts",
+ },
+ {
+ id: "certify",
+ label: "Document certification",
+ icon: "check",
+ kind: "validate",
+ category: "Document Review",
+ provider: "claude",
+ desc: "Review documents against criteria and certify approval",
+ },
+ {
+ id: "intelligent-forms",
+ label: "Intelligent forms",
+ icon: "fileText",
+ kind: "modify",
+ category: "Automation",
+ provider: "stirling",
+ desc: "Auto-fill forms and extract submissions",
+ },
+
+ // Schema-aware ops (D35)
+ {
+ id: "schema-extract",
+ label: "Schema-aware extract",
+ icon: "sparkles",
+ kind: "modify",
+ category: "Classification",
+ desc: "Extract the typed fields the inferred schema declares — not heuristic regexes. Confidence scored per field",
+ },
+ {
+ id: "schema-validate",
+ label: "Schema validate",
+ icon: "shield",
+ kind: "validate",
+ category: "Validation",
+ desc: "Validate the document against its inferred schema. Required-field coverage, type-correctness, business rules",
+ },
+ {
+ id: "schema-route",
+ label: "Conditional routing",
+ icon: "arrowRight",
+ kind: "validate",
+ category: "Classification",
+ desc: "Fork to a downstream pipeline based on the inferred schema (Invoice → AP, Loan Closing → Compliance, etc.)",
+ },
+ {
+ id: "drift-check",
+ label: "Schema drift check",
+ icon: "alertTriangle",
+ kind: "validate",
+ category: "Validation",
+ desc: "Flag when a doc claims to be a known type but its field shape diverges from prior examples",
+ },
+ {
+ id: "smart-redact",
+ label: "Field-aware redact",
+ icon: "eye",
+ kind: "secure",
+ category: "Document Security",
+ desc: "Schema-aware PII redaction — targets fields the schema declares as PII, not regex fishing",
+ },
+ {
+ id: "field-confidence",
+ label: "Field confidence",
+ icon: "activity",
+ kind: "validate",
+ category: "Validation",
+ desc: "Per-field confidence scoring backed by the typed schema",
+ },
+ {
+ id: "schema-split",
+ label: "Schema-aware split",
+ icon: "grid",
+ kind: "modify",
+ category: "Classification",
+ desc: "Split a multi-doc bundle along schema boundaries",
+ },
+ {
+ id: "smart-watermark",
+ label: "Smart watermark",
+ icon: "penTool",
+ kind: "secure",
+ category: "Document Security",
+ desc: "Watermark text incorporates extracted schema fields",
+ },
+];
+
+export const LIBRARY_OPS_INDEX: Record = Object.fromEntries(
+ LIBRARY_OPS.map((op) => [op.id, op]),
+);
+
+export function lookupLibraryOp(id: string): LibraryOp | PipelineOp | null {
+ return LIBRARY_OPS_INDEX[id] ?? lookupOp(id);
+}
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* SOURCES + DESTINATIONS — the rails */
+/* ──────────────────────────────────────────────────────────────────────── */
+
+export const SOURCE_OPTIONS: readonly SourceOption[] = [
+ {
+ id: "upload",
+ label: "Upload API",
+ icon: "upload",
+ desc: "POST documents to a Stirling endpoint",
+ },
+ {
+ id: "webhook",
+ label: "Inbound webhook",
+ icon: "plug",
+ desc: "Receive documents from another system via webhook",
+ },
+ {
+ id: "s3",
+ label: "S3 bucket watch",
+ icon: "server",
+ desc: "Poll an S3 bucket for new files",
+ },
+ {
+ id: "email",
+ label: "Email intake",
+ icon: "fileText",
+ desc: "Route an inbox to this pipeline",
+ },
+ {
+ id: "scheduled",
+ label: "Scheduled import",
+ icon: "activity",
+ desc: "Fetch from SFTP/URL on a schedule",
+ },
+];
+
+export const DESTINATION_OPTIONS: readonly DestinationOption[] = [
+ {
+ id: "vault",
+ label: "Stirling vault",
+ icon: "shield",
+ desc: "Store the processed document and extracted data in Stirling",
+ },
+ {
+ id: "s3",
+ label: "S3 bucket",
+ icon: "server",
+ desc: "Write results to an S3 bucket",
+ },
+ {
+ id: "webhook",
+ label: "Outbound webhook",
+ icon: "externalLink",
+ desc: "POST results to a URL you control",
+ },
+ {
+ id: "pipeline",
+ label: "Another pipeline",
+ icon: "arrowRight",
+ desc: "Chain to a second pipeline",
+ },
+ {
+ id: "database",
+ label: "Database",
+ icon: "database",
+ desc: "Insert extracted fields into Postgres/Snowflake/BigQuery",
+ },
+ {
+ id: "sftp",
+ label: "SFTP",
+ icon: "fileText",
+ desc: "Drop output files on an SFTP server",
+ },
+];
+
+export const lookupSource = (id: string) =>
+ SOURCE_OPTIONS.find((s) => s.id === id) ?? null;
+export const lookupDestination = (id: string) =>
+ DESTINATION_OPTIONS.find((d) => d.id === id) ?? null;
diff --git a/frontend/shared/tokens/Tokens.stories.tsx b/frontend/shared/tokens/Tokens.stories.tsx
new file mode 100644
index 000000000..f83c93517
--- /dev/null
+++ b/frontend/shared/tokens/Tokens.stories.tsx
@@ -0,0 +1,262 @@
+import type { Meta, StoryObj } from "@storybook/react";
+
+const meta: Meta = {
+ title: "Foundations/Design Tokens",
+ parameters: { layout: "padded" },
+};
+export default meta;
+type Story = StoryObj;
+
+interface Swatch {
+ label: string;
+ varName: string;
+}
+
+function Group({ heading, swatches }: { heading: string; swatches: Swatch[] }) {
+ return (
+
+
+ {heading}
+
+
+ {swatches.map((s) => (
+
+
+
+
+ {s.label}
+
+
+ {s.varName}
+
+
+
+ ))}
+
+
+ );
+}
+
+export const Colours: Story = {
+ render: () => (
+
+
+
+
+
+
+ ),
+};
+
+export const Typography: Story = {
+ render: () => (
+
+
+
+ Brand wordmark (Alumni Sans)
+
+
+ Stirling
+
+
+
+
+ Page title (24/700)
+
+
Pipelines
+
+
+
+ Section title (13/600)
+
+
+ Configured primitives
+
+
+
+
+ Body (13/400)
+
+
+ Drop a sample to see what just this op produces.
+
+
+
+
+ Code (12 mono)
+
+
+ POST /v1/coi
+
+
+
+ ),
+};
+
+export const Motion: Story = {
+ render: () => (
+
+
+ fadeInUp — standard view transition
+
+
+
+ ),
+};
diff --git a/frontend/shared/tokens/base.css b/frontend/shared/tokens/base.css
new file mode 100644
index 000000000..b9b40e0c2
--- /dev/null
+++ b/frontend/shared/tokens/base.css
@@ -0,0 +1,39 @@
+/* Minimal global reset + base typography. */
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+html,
+body,
+#root {
+ margin: 0;
+ padding: 0;
+ background: var(--color-bg);
+ color: var(--color-text-2);
+ font-family: var(--font-sans);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ transition:
+ background var(--motion-slow),
+ color var(--motion-slow);
+}
+
+button {
+ font: inherit;
+ color: inherit;
+ background: none;
+ border: none;
+ padding: 0;
+ cursor: pointer;
+}
+
+a {
+ color: var(--color-blue);
+ text-decoration: none;
+}
+a:hover {
+ text-decoration: underline;
+}
diff --git a/frontend/shared/tokens/tokens.css b/frontend/shared/tokens/tokens.css
new file mode 100644
index 000000000..c8ba2171a
--- /dev/null
+++ b/frontend/shared/tokens/tokens.css
@@ -0,0 +1,419 @@
+/*
+ * Stirling design tokens — CSS custom properties.
+ *
+ * This is the single source of truth for the design tokens. Components
+ * reference them via var(--color-...); there is no JS palette to keep in sync.
+ * Theme switching flips `data-theme="dark"` on and these variables
+ * cascade. (tokens.ts holds only the Tier type, not the palette.)
+ *
+ * Category accents (insurance, finance, legal, …) are not theme-switched —
+ * the document types they label are stable, theme-agnostic concepts.
+ */
+
+:root,
+[data-theme="light"] {
+ /* Text hierarchy */
+ --color-text-1: #0f172a;
+ --color-text-2: #1a202c;
+ --color-text-3: #475569;
+ --color-text-4: #64748b;
+ /* text-5 was #94a3b8 (≈2.85:1 on white — fails WCAG AA). Bumped to #64748b
+ so meta/timestamp copy at small sizes still passes contrast. The visual
+ hierarchy now collapses text-4 and text-5 into the same shade — use weight
+ or size to differentiate in components instead. */
+ --color-text-5: #64748b;
+ --color-text-6: #6b7280;
+ --color-text-muted: #8b92a1;
+ --color-text-placeholder: #9ca3af;
+
+ /* Body text colour for use on coloured / gradient backgrounds (blue buttons,
+ assistant header, onboarding "done" badges). Theme-stable: even in dark
+ mode the underlying surface is the brand gradient, so the text is white. */
+ --color-text-on-accent: #ffffff;
+
+ /* Brand / status */
+ --color-blue: #3b82f6;
+ --color-blue-dark: #2563eb;
+ --color-blue-light: #eff6ff;
+ --color-blue-border: #bfdbfe;
+ --color-purple: #8b5cf6;
+ --color-purple-light: #f5f3ff;
+ --color-purple-border: #ddd6fe;
+ --color-purple-dark: #7c3aed;
+ --color-green: #10b981;
+ --color-green-light: #ecfdf5;
+ --color-green-border: #a7f3d0;
+ --color-green-dark: #16a34a;
+ --color-red: #ef4444;
+ --color-red-light: #fee2e2;
+ --color-red-border: #fecaca;
+ --color-red-dark: #dc2626;
+ --color-amber: #f59e0b;
+ --color-amber-light: #fef3c7;
+ --color-amber-border: #fde68a;
+ --color-amber-dark: #92400e;
+
+ /* Category accents (theme-stable) */
+ --color-cat-insurance: #0ea5e9;
+ --color-cat-compliance: #6366f1;
+ --color-cat-finance: #10b981;
+ --color-cat-legal: #3b82f6;
+ --color-cat-healthcare: #8b5cf6;
+ --color-cat-government: #dc2626;
+ --color-cat-operations: #ec4899;
+ --color-cat-hr: #f59e0b;
+ --color-cat-realestate: #84cc16;
+ --color-cat-energy: #f97316;
+ --color-cat-extraction: #14b8a6;
+
+ /* Surfaces */
+ --color-bg: #f8f9fb;
+ --color-bg-alt: #f6f8fa;
+ --color-surface: #ffffff;
+ --color-surface-alt: #ffffff;
+ --color-border: #e3e8ee;
+ --color-border-light: #eef0f2;
+ --color-border-input: #e2e8f0;
+ --color-border-hover: #cbd5e1;
+ --color-divider: #f0f0f0;
+ --color-bg-subtle: #f9fafb;
+ --color-bg-hover: #f8fafc;
+ --color-bg-muted: #f3f4f6;
+ --color-bg-code: #f1f5f9;
+
+ /* Dropdowns / tooltips */
+ --color-dropdown-bg: #ffffff;
+ --color-dropdown-border: #e5e7eb;
+ --color-dropdown-hover: #f9fafb;
+ --color-tooltip-bg: #1e293b;
+ --color-tooltip-text: #f8fafc;
+
+ /* Navigation */
+ --color-nav-active: #eff6ff;
+ --color-nav-active-text: #3b82f6;
+ --color-nav-text: #4b5563;
+ --color-nav-hover: #f6f8fa;
+ --color-nav-hover-text: #1a202c;
+ --color-section-label: #9ca3af;
+ --color-logo-text: #111827;
+
+ /* Header */
+ --color-header-bg: #ffffff;
+ --color-header-border: #f0f0f0;
+ --color-header-text: #111827;
+ --color-search-bg: #f9fafb;
+ --color-search-border: #e5e7eb;
+ --color-search-border-hover: #d1d5db;
+ --color-search-text: #9ca3af;
+
+ /* Sidebar */
+ --color-sidebar-bg: #ffffff;
+ --color-sidebar-border: #e5e7eb;
+ --color-sidebar-divider: #f0f0f0;
+ --color-usage-text: #6b7280;
+ --color-usage-value: #374151;
+ --color-usage-track: #f3f4f6;
+
+ --color-toggle-off: #cbd5e1;
+ --color-badge-red: #ef4444;
+
+ /* Shadows */
+ --shadow-sm: inset 0 0 0 1px #e3e8ee;
+ --shadow-md: inset 0 0 0 1px #e3e8ee, 0 1px 2px rgba(15, 23, 42, 0.04);
+ --shadow-lg: inset 0 0 0 1px #e3e8ee, 0 4px 12px rgba(15, 23, 42, 0.06);
+ --shadow-blue:
+ 0 1px 2px rgba(59, 130, 246, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.2);
+ --shadow-blue-hover:
+ 0 2px 6px rgba(59, 130, 246, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.25);
+
+ /* Gradients */
+ --grad-blue-btn: linear-gradient(
+ 180deg,
+ #5b9bf7 0%,
+ #4c8bf5 50%,
+ #3a7be8 100%
+ );
+ --grad-purple-btn: linear-gradient(
+ 180deg,
+ #a78bfa 0%,
+ #8b5cf6 50%,
+ #7c3aed 100%
+ );
+ --grad-green-btn: linear-gradient(
+ 180deg,
+ #34d399 0%,
+ #10b981 50%,
+ #059669 100%
+ );
+ --grad-amber-btn: linear-gradient(
+ 180deg,
+ #fbbf24 0%,
+ #f59e0b 50%,
+ #d97706 100%
+ );
+ --grad-red-btn: linear-gradient(
+ 180deg,
+ #f87171 0%,
+ #ef4444 50%,
+ #dc2626 100%
+ );
+ --grad-banner: linear-gradient(135deg, #eef2ff 0%, #ddd6fe 50%, #fce7f3 100%);
+}
+
+[data-theme="dark"] {
+ --color-text-1: #f1f5f9;
+ --color-text-2: #e2e8f0;
+ --color-text-3: #94a3b8;
+ --color-text-4: #64748b;
+ /* Bumped from #475569 (fails AA) — see light-theme note. */
+ --color-text-5: #7c869a;
+ --color-text-6: #94a3b8;
+ --color-text-muted: #64748b;
+ --color-text-placeholder: #475569;
+
+ --color-blue: #60a5fa;
+ --color-blue-dark: #3b82f6;
+ --color-blue-light: #1e293b;
+ --color-blue-border: #1e3a5f;
+ --color-purple: #a78bfa;
+ --color-purple-light: #1e1b2e;
+ --color-purple-border: #2e2650;
+ --color-purple-dark: #8b5cf6;
+ --color-green: #34d399;
+ --color-green-light: #0d2818;
+ --color-green-border: #065f46;
+ --color-green-dark: #22c55e;
+ --color-red: #f87171;
+ --color-red-light: #2d1215;
+ --color-red-border: #7f1d1d;
+ --color-red-dark: #ef4444;
+ --color-amber: #fbbf24;
+ --color-amber-light: #2d2006;
+ --color-amber-border: #78350f;
+ /* Deeper than the base so it stays distinct (hover shade + amber text on
+ dark surfaces). ~7.4:1 on --color-amber-light, passes WCAG AA. Was
+ #fbbf24 — identical to the base — which left Mantine's amber hover a
+ no-op in dark mode. */
+ --color-amber-dark: #f59e0b;
+
+ --color-bg: #090c14;
+ --color-bg-alt: #0d1120;
+ --color-surface: #151c2e;
+ --color-surface-alt: #1c2640;
+ --color-border: #283248;
+ --color-border-light: #1e2840;
+ --color-border-input: #2e3c55;
+ --color-border-hover: #3d4f6a;
+ --color-divider: #1e2840;
+ --color-bg-subtle: #111827;
+ --color-bg-hover: #1c2640;
+ --color-bg-muted: #243044;
+ --color-bg-code: #0b0f1a;
+
+ --color-dropdown-bg: #151c2e;
+ --color-dropdown-border: #2e3c55;
+ --color-dropdown-hover: #1c2640;
+ --color-tooltip-bg: #e2e8f0;
+ --color-tooltip-text: #0f172a;
+
+ --color-nav-active: #172044;
+ --color-nav-active-text: #60a5fa;
+ --color-nav-text: #94a3b8;
+ --color-nav-hover: #1c2640;
+ --color-nav-hover-text: #e2e8f0;
+ --color-section-label: #475569;
+ --color-logo-text: #f1f5f9;
+
+ --color-header-bg: #0d1120;
+ --color-header-border: #1e2840;
+ --color-header-text: #f1f5f9;
+ --color-search-bg: #151c2e;
+ --color-search-border: #2e3c55;
+ --color-search-border-hover: #3d4f6a;
+ --color-search-text: #64748b;
+
+ --color-sidebar-bg: #0d1120;
+ --color-sidebar-border: #1e2840;
+ --color-sidebar-divider: #1e2840;
+ --color-usage-text: #64748b;
+ --color-usage-value: #94a3b8;
+ --color-usage-track: #1e2840;
+
+ --color-toggle-off: #3d4f6a;
+
+ --shadow-sm: inset 0 0 0 1px #283248;
+ --shadow-md: inset 0 0 0 1px #283248, 0 1px 3px rgba(0, 0, 0, 0.3);
+ --shadow-lg: inset 0 0 0 1px #283248, 0 8px 24px rgba(0, 0, 0, 0.4);
+ --shadow-blue:
+ 0 1px 3px rgba(59, 130, 246, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.1);
+ --shadow-blue-hover:
+ 0 2px 8px rgba(59, 130, 246, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.15);
+
+ --grad-blue-btn: linear-gradient(
+ 180deg,
+ #3b82f6 0%,
+ #2563eb 50%,
+ #1d4ed8 100%
+ );
+ --grad-banner: linear-gradient(135deg, #0f172a 0%, #111827 50%, #1a1535 100%);
+}
+
+/* Always-dark code palette. Not theme-switched. */
+:root {
+ --code-bg: #0f172a;
+ --code-bg-alt: #1e293b;
+ --code-bg-header: #1a2332;
+ --code-text: #e2e8f0;
+ --code-dim: #64748b;
+ --code-muted: #94a3b8;
+ --code-keyword: #60a5fa;
+ --code-string: #fbbf24;
+ --code-number: #c084fc;
+ --code-fn: #34d399;
+ --code-type: #f472b6;
+ --code-property: #93c5fd;
+ --code-comment: #475569;
+ --code-border: #1e293b;
+ /* Window-chrome traffic-light dots in the code-block header. */
+ --code-dot: #475569;
+}
+
+/* Radii / typography / motion / spacing / z-index — theme-stable */
+:root {
+ --radius-xs: 0.1875rem;
+ --radius-sm: 0.25rem;
+ --radius-md: 0.375rem;
+ --radius-lg: 0.5rem;
+ --radius-xl: 0.75rem;
+ --radius-pill: 0.625rem;
+
+ --font-sans:
+ "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial,
+ sans-serif;
+ --font-mono:
+ "SF Mono", "Fira Code", Menlo, Consolas, "DejaVu Sans Mono", monospace;
+ --font-brand: "Alumni Sans", "Inter", sans-serif;
+
+ --motion-fast: 0.15s ease;
+ --motion-base: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
+ --motion-slow: 0.3s ease;
+ --motion-enter: 0.22s cubic-bezier(0.4, 0, 0.2, 1);
+
+ /* 4-px grid spacing scale (rem so it tracks font-size changes). Use these
+ in layout primitives + component CSS instead of bare 0.5rem / 0.75rem. */
+ --space-0: 0;
+ --space-0_5: 0.125rem;
+ --space-1: 0.25rem;
+ --space-1_5: 0.375rem;
+ --space-2: 0.5rem;
+ --space-3: 0.75rem;
+ --space-4: 1rem;
+ --space-5: 1.25rem;
+ --space-6: 1.5rem;
+ --space-8: 2rem;
+ --space-10: 2.5rem;
+ --space-12: 3rem;
+
+ /* Z-index hierarchy. Pick the highest tier that still puts you below the
+ thing that's supposed to win. Modals beat drawers beat dropdowns. */
+ --z-base: 0;
+ --z-sticky: 10;
+ --z-dropdown: 25;
+ --z-fixed-overlay: 35;
+ --z-drawer: 50;
+ --z-modal: 100;
+ --z-toast: 200;
+}
+
+/* Keyframes used across the app. */
+@keyframes fadeInUp {
+ from {
+ opacity: 0;
+ transform: translateY(0.375rem);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+@keyframes scaleIn {
+ from {
+ opacity: 0;
+ transform: scale(0.97);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+@keyframes slideInRight {
+ from {
+ transform: translateX(100%);
+ }
+ to {
+ transform: translateX(0);
+ }
+}
+@keyframes pulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.5;
+ }
+}
+@keyframes pulseRing {
+ 0% {
+ transform: scale(0.8);
+ opacity: 1;
+ }
+ 100% {
+ transform: scale(2.2);
+ opacity: 0;
+ }
+}
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+@keyframes shimmer {
+ 0% {
+ background-position: -200% 0;
+ }
+ 100% {
+ background-position: 200% 0;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.001ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.001ms !important;
+ }
+}
+
+/* Visually-hidden utility for content meant only for screen readers. */
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
diff --git a/frontend/shared/tokens/tokens.ts b/frontend/shared/tokens/tokens.ts
new file mode 100644
index 000000000..c20128268
--- /dev/null
+++ b/frontend/shared/tokens/tokens.ts
@@ -0,0 +1,14 @@
+/**
+ * Subscription tier identifier.
+ *
+ * Defined here rather than in the portal because shared/data/endpoints.ts needs
+ * it for tier-availability gating, and the shared layer can't import from the
+ * portal. The portal UI keeps its own matching `Tier` in
+ * contexts/TierContext.tsx.
+ *
+ * The design tokens themselves live in tokens.css as CSS custom properties —
+ * that is the single runtime source of truth. A parallel TS mirror of the
+ * palette used to live here but was removed: nothing consumed it and it had
+ * silently drifted from the CSS.
+ */
+export type Tier = "free" | "pro" | "enterprise";
diff --git a/frontend/shared/tsconfig.json b/frontend/shared/tsconfig.json
new file mode 100644
index 000000000..bb042a488
--- /dev/null
+++ b/frontend/shared/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "target": "es2022",
+ "jsx": "react-jsx",
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "baseUrl": "./",
+ "paths": {
+ "@shared/*": ["./*"]
+ },
+ "resolveJsonModule": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "strict": true,
+ "skipLibCheck": true
+ },
+ "include": ["**/*.ts", "**/*.tsx"]
+}