# Description of Changes

## What & why

This PR introduces the **Stirling developer portal** — a new
control-plane frontend that sits alongside the existing PDF editor —
plus the shared design system and workspace structure needed to host
both apps in one frontend.

The portal is the parent product surface: where users connect sources,
compose pipelines, wire agents, and manage usage / billing /
infrastructure, with the PDF editor as one capability inside it. This PR
lays the **foundation** — workspace reshape, design system, app shell,
navigation, and a mock-driven home — rather than wiring real backends
(those surfaces are placeholders for follow-up phases).

## What's in this PR

**1. Frontend repo reshape (`frontend/src/` → `frontend/editor/`)**
The existing editor app moved under `frontend/editor/`, so `editor`,
`portal`, and `shared` are siblings in one workspace. All references
were updated accordingly: `LICENSE`, `.dockerignore`, `.gitignore`,
build/sign shell scripts, the GH language-check script, the Taskfile,
and Docker config. **No editor source logic changed — path references
only.**

**2. New shared design system (`frontend/shared/`)**
- **Design tokens** in `tokens.css` as the single runtime source of
truth (light/dark, category accents, gradients). `tokens.ts` now holds
only the `Tier` type — the old JS palette mirror was removed (nothing
consumed it and it had drifted).
- ~30 framework-light **components** (Card, Button, Input, Select, Tabs,
Modal, Drawer, Toast, MetricCard, StatusBadge, Skeleton, EmptyState, …)
with Storybook stories.
- **Typed data catalogues**: `endpoints.ts` (10 verticals / 64
endpoints) and `ops.ts`.

**3. New developer portal app (`frontend/portal/`)**
- App shell: `Header`, `Sidebar`, `AssistantPanel`, search modal,
notifications, tier switcher, theme toggle, MSW toggle.
- **Tier-aware** home (free / pay-as-you-go / enterprise): KPI strip,
30-day usage chart, onboarding checklist, quick actions, recent
activity, region health, product grid, and a curated **"Popular use
cases"** teaser.
- **Documents** view hosting the full, tab-filterable endpoint
catalogue.
- Placeholder views for Sources / Pipelines / Agents / Editor /
Infrastructure / Usage & Billing / Developer Docs / Settings (follow-up
phases).
- **MSW-mocked** API layer: `api/*` issues real `fetch`, intercepted by
mocks in dev/Storybook; pointing at a real backend is just a matter of
not registering MSW. `react-router` URLs; Tier / View / UI contexts.

**4. Tooling & guardrails**
- ESLint extended to `portal` + `shared`, with **layering-boundary
rules**: `shared/` may depend only on third-party packages and itself
(no `@app` / `@portal` / `@core` / `@proprietary` / Tauri), so it stays
cleanly extractable into a standalone package later.
- `dpdm` circular-dependency check now walks editor + portal + shared
(the old glob matched only 2 files).
- New **devDependencies only** — Storybook (+ a11y/docs/themes addons),
MSW. No runtime dependencies added.
- New tasks: `frontend:dev:portal`, `frontend:build:portal`.

## Testing done locally

- `tsc` for both `portal` and `shared` projects — clean
- `eslint --max-warnings=0` across the whole frontend — clean
- `dpdm` circular-dependency check — no cycles
- Editor builds clean: `vite build editor --mode core` (✓ built, only
the pre-existing >500 kB chunk-size advisory)
- Editor runs in dev (core mode) with **zero console errors**; portal
runs in dev across all three tiers

## Notes for reviewers

- The change is overwhelmingly **additive**: `shared/` and `portal/` are
brand-new; the existing editor is path-reference changes only.
- The portal is intentionally **mock-driven** at this stage — real
backends and the remaining views land in follow-up phases.

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [x] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Reece Browne
2026-06-02 16:08:24 +00:00
committed by GitHub
co-authored by Claude Opus 4.8
parent b355ccec9e
commit 919f0ade99
201 changed files with 19912 additions and 43 deletions
+3 -2
View File
@@ -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/
+2 -2
View File
@@ -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/
+44 -1
View File
@@ -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
+10 -8
View File
@@ -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.
+1 -1
View File
@@ -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
+2
View File
@@ -11,6 +11,8 @@
# production
/build
/dist
/dist-portal
/storybook-static
/editor/build
/editor/dist
+2
View File
@@ -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
+42
View File
@@ -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;
+142
View File
@@ -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 <TierProvider initialTier={tier}>{children}</TierProvider>;
}
/** Forces the TierProvider to re-mount whenever the toolbar tier changes. */
function TierKey({
tier,
children,
}: {
tier: Tier;
children: React.ReactNode;
}) {
return (
<TierBridge key={tier} tier={tier}>
{children}
</TierBridge>
);
}
/** Keeps useTheme() and the data-theme attribute in sync. */
function ThemeWatcher() {
useEffect(() => {
// The addon-themes decorator already sets data-theme on <html>.
// 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 (
<MemoryRouter initialEntries={["/"]}>
<ThemeProvider>
<MantineProvider theme={mantineTheme} forceColorScheme={colorScheme}>
<TierKey tier={tier}>
<UIProvider>
<ThemeWatcher />
<Story />
</UIProvider>
</TierKey>
</MantineProvider>
</ThemeProvider>
</MemoryRouter>
);
};
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;
+1 -2
View File
@@ -5,6 +5,5 @@
"moduleResolution": "node16",
"noEmit": true
},
"include": ["./**/*.ts", "./**/*.mts"],
"exclude": []
"include": ["./**/*.ts", "./**/*.mts"]
}
+2 -1
View File
@@ -3,7 +3,8 @@
"compilerOptions": {
"baseUrl": "../../",
"paths": {
"@app/*": ["src/core/*"]
"@app/*": ["src/core/*"],
"@shared/*": ["../shared/*"]
}
},
"include": ["../global.d.ts", "../*.js", "../*.ts", "../*.tsx", "."]
+2 -1
View File
@@ -5,7 +5,8 @@
"paths": {
"@app/*": ["src/desktop/*", "src/proprietary/*", "src/core/*"],
"@proprietary/*": ["src/proprietary/*"],
"@core/*": ["src/core/*"]
"@core/*": ["src/core/*"],
"@shared/*": ["../shared/*"]
}
},
"include": [
@@ -4,7 +4,8 @@
"baseUrl": "../../",
"paths": {
"@app/*": ["src/proprietary/*", "src/core/*"],
"@core/*": ["src/core/*"]
"@core/*": ["src/core/*"],
"@shared/*": ["../shared/*"]
}
},
"include": [
+2 -1
View File
@@ -5,7 +5,8 @@
"paths": {
"@app/*": ["src/prototypes/*", "src/proprietary/*", "src/core/*"],
"@proprietary/*": ["src/proprietary/*"],
"@core/*": ["src/core/*"]
"@core/*": ["src/core/*"],
"@shared/*": ["../shared/*"]
}
},
"include": [
+2 -1
View File
@@ -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", "."]
+3 -2
View File
@@ -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"]
}
+3 -3
View File
@@ -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*"
]
}
+2 -1
View File
@@ -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'. */
@@ -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"]
}
@@ -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"
]
}
+3 -3
View File
@@ -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"
]
}
+56 -3
View File
@@ -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: {
+1715
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -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"
]
}
}
+51
View File
@@ -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.
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en-GB">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0f172a" />
<meta name="description" content="Stirling developer portal" />
<title>Stirling — Developer Portal</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
+34
View File
@@ -0,0 +1,34 @@
/// <reference types="vite/client" />
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<void> {
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(
<StrictMode>
<App />
</StrictMode>,
);
}
void bootstrap();
+349
View File
@@ -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<Client | undefined>}
*/
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<Response>}
*/
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<Transferable>} transferrables
* @returns {Promise<any>}
*/
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,
};
}
+75
View File
@@ -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 <ThemeProvider> to read useTheme().
*/
function PortalMantineProvider({ children }: { children: ReactNode }) {
const { theme } = useTheme();
return (
<MantineProvider theme={mantineTheme} forceColorScheme={theme}>
{children}
</MantineProvider>
);
}
/**
* 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 (
<ThemeProvider>
<PortalMantineProvider>
<TierProvider initialTier="pro">
<BrowserRouter>
<UIProvider>
<GlobalShortcuts />
<AppShell>
<ViewRouter />
</AppShell>
<AssistantButton />
<AssistantPanel />
<SearchModal />
</UIProvider>
</BrowserRouter>
</TierProvider>
</PortalMantineProvider>
</ThemeProvider>
);
}
+67
View File
@@ -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<Record<ViewId, string>> = {
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 (
<Routes>
<Route path={VIEW_PATHS.home} element={<Home />} />
<Route
path={VIEW_PATHS.pipelines}
element={
<Placeholder view="pipelines" phase={PLACEHOLDER_PHASES.pipelines} />
}
/>
<Route
path={VIEW_PATHS.editor}
element={
<Placeholder view="editor" phase={PLACEHOLDER_PHASES.editor} />
}
/>
<Route
path={VIEW_PATHS.sources}
element={
<Placeholder view="sources" phase={PLACEHOLDER_PHASES.sources} />
}
/>
<Route path={VIEW_PATHS.documents} element={<Documents />} />
<Route
path={VIEW_PATHS.infrastructure}
element={
<Placeholder
view="infrastructure"
phase={PLACEHOLDER_PHASES.infrastructure}
/>
}
/>
<Route
path={VIEW_PATHS.usage}
element={<Placeholder view="usage" phase={PLACEHOLDER_PHASES.usage} />}
/>
<Route
path={VIEW_PATHS.docs}
element={<Placeholder view="docs" phase={PLACEHOLDER_PHASES.docs} />}
/>
<Route
path={VIEW_PATHS.settings}
element={
<Placeholder view="settings" phase={PLACEHOLDER_PHASES.settings} />
}
/>
{/* Unknown paths land on Home. */}
<Route path="*" element={<Navigate to={VIEW_PATHS.home} replace />} />
</Routes>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { httpJson } from "@portal/api/http";
/** GET /v1/assistant/suggestions */
export async function fetchAssistantSuggestions(): Promise<readonly string[]> {
return httpJson<readonly string[]>("/v1/assistant/suggestions");
}
/** POST /v1/assistant/messages */
export async function getAssistantReply(input: string): Promise<string> {
const res = await httpJson<{ reply: string }>("/v1/assistant/messages", {
method: "POST",
body: { input },
});
return res.reply;
}
+15
View File
@@ -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<Vertical[]> {
return httpJson<Vertical[]>("/v1/endpoints");
}
+44
View File
@@ -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<UsageSeriesResponse> {
return httpJson<UsageSeriesResponse>("/v1/analytics/usage?window=30d");
}
/** GET /v1/activity?limit=8 */
export async function fetchRecentActivity(): Promise<ActivityEvent[]> {
return httpJson<ActivityEvent[]>("/v1/activity?limit=8");
}
/** GET /v1/home/kpis?tier=… */
export async function fetchHomeKpis(tier: Tier): Promise<KpiEntry[]> {
return httpJson<KpiEntry[]>(`/v1/home/kpis?tier=${encodeURIComponent(tier)}`);
}
/** GET /v1/regions/health (Enterprise) */
export async function fetchRegionHealth(): Promise<RegionHealth[]> {
return httpJson<RegionHealth[]>("/v1/regions/health");
}
/** GET /v1/onboarding (Free) */
export async function fetchOnboarding(): Promise<OnboardingStep[]> {
return httpJson<OnboardingStep[]>("/v1/onboarding");
}
+59
View File
@@ -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<string, string>;
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<T>(
path: string,
options: HttpRequestOptions = {},
): Promise<T> {
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;
}
+19
View File
@@ -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<Notification[]> {
return httpJson<Notification[]>("/v1/notifications");
}
/** POST /v1/notifications/mark-all-read */
export async function markAllNotificationsRead(): Promise<void> {
await httpJson<{ ok: true }>("/v1/notifications/mark-all-read", {
method: "POST",
});
}
+37
View File
@@ -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<FeaturedOp[]> {
return httpJson<FeaturedOp[]>("/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;
}
}
+9
View File
@@ -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<QuickAction[]> {
return httpJson<QuickAction[]>("/v1/search/quick-actions");
}
@@ -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;
}
@@ -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 (
<div className="portal-shell">
<Sidebar />
<div className="portal-shell__main">
<Header />
<main className="portal-shell__view">{children}</main>
</div>
</div>
);
}
@@ -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);
}
@@ -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 (
<button
type="button"
className={"portal-assistant-btn" + (assistantOpen ? " is-active" : "")}
onClick={toggleAssistant}
aria-label={assistantOpen ? "Close assistant" : "Open assistant"}
aria-expanded={assistantOpen}
title="Assistant"
>
{assistantOpen ? (
<svg
viewBox="0 0 24 24"
width="20"
height="20"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
) : (
<SparklesIcon size={22} />
)}
</button>
);
}
@@ -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;
}
@@ -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<typeof AssistantPanel> = {
title: "Portal/Assistant/AssistantPanel",
component: AssistantPanel,
parameters: { layout: "fullscreen" },
decorators: [
(S) => (
<div style={{ minHeight: "100vh", background: "var(--color-bg)" }}>
<ForceOpen />
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof AssistantPanel>;
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 }),
),
],
},
},
};
@@ -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<readonly string[]>(
() => fetchAssistantSuggestions(),
[],
);
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [typing, setTyping] = useState(false);
const messagesRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(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 (
<aside className="portal-assistant" role="dialog" aria-label="Assistant">
<header className="portal-assistant__header">
<div className="portal-assistant__header-left">
<SparklesIcon size={16} />
<span className="portal-assistant__title">Assistant</span>
</div>
<button
type="button"
className="portal-assistant__close"
onClick={closeAssistant}
aria-label="Close assistant"
>
<CloseIcon size={16} />
</button>
</header>
<div className="portal-assistant__messages" ref={messagesRef}>
{messages.length === 0 && suggestions && (
<div className="portal-assistant__suggestions">
<div className="portal-assistant__suggestions-eyebrow">
Try asking
</div>
<div className="portal-assistant__suggestions-list">
{suggestions.map((s) => (
<button
key={s}
type="button"
className="portal-assistant__suggestion"
onClick={() => send(s)}
disabled={typing}
>
{s}
</button>
))}
</div>
</div>
)}
{messages.map((m) => (
<div
key={m.id}
className={
"portal-assistant__bubble portal-assistant__bubble--" + m.role
}
>
{m.text}
</div>
))}
{typing && (
<div className="portal-assistant__bubble portal-assistant__bubble--assistant">
<span className="portal-assistant__typing" aria-label="Typing">
<span />
<span />
<span />
</span>
</div>
)}
</div>
<form
className="portal-assistant__input-row"
onSubmit={(e) => {
e.preventDefault();
send(input);
}}
>
<input
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask about Stirling…"
aria-label="Ask the assistant"
className="portal-assistant__input"
disabled={typing}
/>
<button
type="submit"
className="portal-assistant__send"
disabled={!input.trim() || typing}
aria-label="Send"
>
<SendIcon size={14} />
</button>
</form>
</aside>
);
}
@@ -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);
}
@@ -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<typeof DocumentTypeGrid> = {
title: "Portal/Home/DocumentTypeGrid",
component: DocumentTypeGrid,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "72rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof DocumentTypeGrid>;
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([]))],
},
},
};
@@ -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 (
<StatusBadge tone={TIER_TONE[tier]} size="sm">
{TIER_LABEL[tier]}
</StatusBadge>
);
}
function EndpointCard({
endpoint,
vertical,
}: {
endpoint: Endpoint;
vertical: Vertical;
}) {
const visibleRegions = endpoint.regions.slice(0, 2);
const extraRegions = endpoint.regions.length - visibleRegions.length;
return (
<article className="portal-doctype__card">
<div
className="portal-doctype__card-accent"
style={{ background: vertical.color }}
aria-hidden
/>
<div className="portal-doctype__card-body">
<div className="portal-doctype__card-eyebrow">
{vertical.label.toUpperCase()}
</div>
<h3 className="portal-doctype__card-title">{endpoint.name}</h3>
<p className="portal-doctype__card-desc">{endpoint.desc}</p>
<div className="portal-doctype__card-meta">
<span className="portal-doctype__regions">
{visibleRegions.join(" · ")}
{extraRegions > 0 && (
<span className="portal-doctype__regions-more">
{" "}
+{extraRegions} more
</span>
)}
</span>
{tierBadge(endpoint.tier)}
</div>
<a
className="portal-doctype__card-cta"
style={{ color: vertical.color }}
href={`#${endpoint.endpoint}`}
onClick={(e) => e.preventDefault()}
>
Explore <span aria-hidden></span>
</a>
</div>
</article>
);
}
function GridSkeleton() {
return (
<div className="portal-doctype__groups" aria-hidden>
{Array.from({ length: 2 }).map((_, gi) => (
<div key={gi} className="portal-doctype__group">
<div className="portal-doctype__group-head">
<Skeleton shape="circle" width="0.4375rem" height="0.4375rem" />
<Skeleton width="6rem" />
</div>
<div className="portal-doctype__scroller">
{Array.from({ length: 4 }).map((_, ci) => (
<Skeleton key={ci} shape="rect" width="17rem" height="8rem" />
))}
</div>
</div>
))}
</div>
);
}
export function DocumentTypeGrid() {
const [tab, setTab] = useState<ActiveTab>("all");
const state = useAsync<Vertical[]>(() => fetchVerticals(), []);
const { data: verticals } = state;
const { isLoading, isEmpty } = useSectionFlags(state);
const hasVerticals = verticals !== null && verticals.length > 0;
const tabItems: TabItem<ActiveTab>[] = hasVerticals
? [
{ key: "all", label: "All" },
...verticals.map<TabItem<ActiveTab>>((v) => ({
key: v.key,
label: v.label,
count: v.endpoints.length,
accentColor: v.color,
dotColor: v.color,
})),
]
: [];
return (
<section className="portal-doctype" aria-label="Document types">
<header className="portal-doctype__head">
<h2 className="portal-doctype__title">Document types</h2>
<p className="portal-doctype__sub">
Typed endpoints across every supported vertical each carries a
schema, region availability and tier gate.
</p>
</header>
{hasVerticals && (
<Tabs<ActiveTab>
items={tabItems}
activeKey={tab}
onChange={setTab}
ariaLabel="Document type verticals"
/>
)}
{isLoading && <GridSkeleton />}
{isEmpty && (
<EmptyState
title="No document types yet"
description="When endpoints are registered they'll appear in this catalogue."
/>
)}
{hasVerticals && (
<div className="portal-doctype__groups">
{(tab === "all"
? verticals
: verticals.filter((v) => v.key === tab)
).map((v) => (
<div key={v.key} className="portal-doctype__group">
{tab === "all" && (
<div className="portal-doctype__group-head">
<span
className="portal-doctype__tab-dot"
style={{ background: v.color }}
aria-hidden
/>
<h3 className="portal-doctype__group-title">{v.label}</h3>
<span className="portal-doctype__group-count">
{v.endpoints.length} endpoints
</span>
</div>
)}
<div className="portal-doctype__scroller">
{v.endpoints.map((endpoint) => (
<EndpointCard
key={endpoint.endpoint}
endpoint={endpoint}
vertical={v}
/>
))}
</div>
</div>
))}
</div>
)}
</section>
);
}
+182
View File
@@ -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;
}
@@ -0,0 +1,26 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Header } from "@portal/components/Header";
const meta: Meta<typeof Header> = {
title: "Portal/Shell/Header",
component: Header,
parameters: { layout: "fullscreen" },
decorators: [
(S) => (
<div style={{ background: "var(--color-bg)" }}>
<S />
<div style={{ padding: "1.5rem", color: "var(--color-text-3)" }}>
Page body would render below the header.
</div>
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof Header>;
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" } };
+104
View File
@@ -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 (
<button
type="button"
className="portal-header__icon-btn"
onClick={toggle}
aria-label={
theme === "light" ? "Switch to dark theme" : "Switch to light theme"
}
title={theme === "light" ? "Dark mode" : "Light mode"}
>
{theme === "light" ? <MoonIcon size={16} /> : <SunIcon size={16} />}
</button>
);
}
function TierSwitcher() {
const { tier, setTier } = useTier();
const info = TIER_INFO[tier];
return (
<Dropdown.Root align="end">
<Dropdown.Trigger>
<button type="button" className="portal-header__tier-btn">
<span
className="portal-header__tier-dot"
style={{ background: info.dotColor }}
aria-hidden
/>
<span className="portal-header__tier-label">{info.label}</span>
<ChevronDownIcon size={14} />
</button>
</Dropdown.Trigger>
<Dropdown.Menu width="12rem">
{(Object.keys(TIER_INFO) as Tier[]).map((id) => (
<Dropdown.Item
key={id}
active={tier === id}
onSelect={() => setTier(id)}
leading={
<span
className="portal-header__tier-dot"
style={{ background: TIER_INFO[id].dotColor }}
aria-hidden
/>
}
>
{TIER_INFO[id].label}
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown.Root>
);
}
export function Header() {
const { activeView } = useView();
const { openSearch } = useUI();
return (
<header className="portal-header">
<div className="portal-header__left">
<span className="portal-header__breadcrumb">
{VIEW_LABELS[activeView]}
</span>
</div>
<button
type="button"
className="portal-header__search"
aria-label="Search"
onClick={openSearch}
>
<SearchIcon size={14} />
<span className="portal-header__search-placeholder">Search</span>
<span className="portal-header__search-kbd" aria-hidden>
K
</span>
</button>
<div className="portal-header__right">
<MocksToggle />
<ThemeToggle />
<NotificationsDropdown />
<TierSwitcher />
<Avatar name="Reece" size="md" tone="blue" />
</div>
</header>
);
}
@@ -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;
}
@@ -0,0 +1,54 @@
/// <reference types="vite/client" />
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 (
<button
type="button"
className={
"portal-mocks-toggle" +
(enabled ? " is-on" : " is-off") +
(pending ? " is-pending" : "")
}
onClick={toggle}
aria-pressed={enabled}
title={
enabled
? "Mock data ON — fetch calls are intercepted by MSW. Click to switch to the real network (reloads the page)."
: "Mock data OFF — fetch calls go to the real network. Click to re-enable mocks (reloads the page)."
}
>
<span className="portal-mocks-toggle__dot" aria-hidden />
<span className="portal-mocks-toggle__label">
Mocks {enabled ? "ON" : "OFF"}
</span>
</button>
);
}
@@ -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);
}
@@ -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<typeof NotificationsDropdown> = {
title: "Portal/Header/NotificationsDropdown",
component: NotificationsDropdown,
parameters: { layout: "centered" },
decorators: [
(S) => (
<div
style={{
padding: "4rem 8rem",
minHeight: "30rem",
background: "var(--color-bg)",
}}
>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof NotificationsDropdown>;
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 }),
),
],
},
},
};
@@ -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<NotificationCategory, string> = {
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<Notification[]>(() => 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 (
<Dropdown.Root align="end">
<Dropdown.Trigger>
<button
type="button"
className="portal-header__icon-btn portal-header__icon-btn--badge"
aria-label={
hasUnread
? `Notifications, ${visible.length} unread`
: "Notifications, no unread"
}
>
<BellIcon size={16} />
{hasUnread && (
<span className="portal-header__bell-dot" aria-hidden />
)}
</button>
</Dropdown.Trigger>
<Dropdown.Menu width="22.5rem" className="portal-notif__menu">
<div className="portal-notif__header">
<span className="portal-notif__title">Notifications</span>
{hasUnread ? (
<span className="portal-notif__count">{visible.length} new</span>
) : isLoading ? (
<span className="portal-notif__count portal-notif__count--quiet">
loading
</span>
) : (
<span className="portal-notif__count portal-notif__count--quiet">
all read
</span>
)}
</div>
{isLoading && (
<div className="portal-notif__loading">
<Skeleton height="0.875rem" />
<Skeleton height="0.6875rem" width="60%" />
<Skeleton height="0.875rem" />
<Skeleton height="0.6875rem" width="60%" />
</div>
)}
{isEmpty && (
<EmptyState
size="compact"
title="You're all caught up"
description="No new notifications."
/>
)}
{!isLoading && !isEmpty && (
<ul className="portal-notif__list">
{visible.map((item) => (
<li key={item.id} className="portal-notif__item">
<span
className="portal-notif__dot"
style={{ background: CATEGORY_COLOUR[item.category] }}
aria-hidden
/>
<div className="portal-notif__body">
<div className="portal-notif__item-title">{item.title}</div>
<div className="portal-notif__desc">{item.description}</div>
<div className="portal-notif__time">{item.time}</div>
</div>
</li>
))}
</ul>
)}
<div className="portal-notif__footer">
<button
type="button"
className="portal-notif__action"
onClick={onMarkAllRead}
disabled={!hasUnread}
>
Mark all read
</button>
<button type="button" className="portal-notif__action">
View all
</button>
</div>
</Dropdown.Menu>
</Dropdown.Root>
);
}
@@ -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;
}
@@ -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<CardProps["accent"]>;
interface UseCase {
eyebrow: string;
title: string;
blurb: string;
cta: string;
accent: Accent;
}
const ACCENT_COLOR: Record<Accent, string> = {
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 (
<section className="portal-usecases" aria-label="Popular use cases">
<header className="portal-usecases__head">
<h2 className="portal-usecases__title">Popular use cases</h2>
<button
type="button"
className="portal-usecases__viewall"
onClick={() => setActiveView("pipelines")}
>
View all pipelines <span aria-hidden></span>
</button>
</header>
<div className="portal-usecases__grid">
{USE_CASES.map((uc) => (
<Card
key={uc.eyebrow}
accent={uc.accent}
padding="loose"
className="portal-usecases__card"
>
<span
className="portal-usecases__eyebrow"
style={{ color: ACCENT_COLOR[uc.accent] }}
>
{uc.eyebrow}
</span>
<h3 className="portal-usecases__card-title">{uc.title}</h3>
<p className="portal-usecases__blurb">{uc.blurb}</p>
<button
type="button"
className="portal-usecases__cta"
style={{ color: ACCENT_COLOR[uc.accent] }}
onClick={() => setActiveView("pipelines")}
>
{uc.cta} <span aria-hidden></span>
</button>
</Card>
))}
</div>
</section>
);
}
@@ -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;
}
@@ -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<typeof RecentActivity> = {
title: "Portal/Home/RecentActivity",
component: RecentActivity,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "40rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof RecentActivity>;
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([]))],
},
},
};
@@ -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<ActivityKind, string> = {
"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<ActivityEvent[]>(() => fetchRecentActivity(), []);
const { data: events } = state;
const { isLoading, isEmpty } = useSectionFlags(state);
return (
<Card
padding="none"
className="portal-activity"
aria-label="Recent activity"
>
<header className="portal-activity__head">
<h2 className="portal-activity__title">Recent activity</h2>
<button type="button" className="portal-activity__more">
View all
</button>
</header>
{isLoading && (
<ul className="portal-activity__list" aria-hidden>
{Array.from({ length: 5 }).map((_, i) => (
<li
key={i}
className="portal-activity__item portal-activity__item--skeleton"
>
<span className="portal-activity__rail" />
<div className="portal-activity__body">
<Skeleton width="70%" />
<Skeleton width="50%" height="0.625rem" />
</div>
</li>
))}
</ul>
)}
{isEmpty && (
<EmptyState
size="compact"
title="Nothing here yet"
description="Pipeline runs, deploys and agent events will appear here."
/>
)}
{events && events.length > 0 && (
<ol className="portal-activity__list">
{events.map((event) => (
<li key={event.id} className="portal-activity__item">
<span
className="portal-activity__rail"
style={{ background: KIND_COLOUR[event.kind] }}
aria-hidden
/>
<div className="portal-activity__body">
<div className="portal-activity__row">
<span className="portal-activity__action">
{event.action}
</span>
<span className="portal-activity__time">{event.time}</span>
</div>
<div className="portal-activity__subject">{event.subject}</div>
<div className="portal-activity__detail-row">
<span className="portal-activity__detail">
{event.detail}
</span>
<StatusBadge tone={event.status} size="sm">
{event.status}
</StatusBadge>
</div>
</div>
</li>
))}
</ol>
)}
</Card>
);
}
@@ -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);
}
@@ -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<typeof SearchModal> = {
title: "Portal/Header/SearchModal",
component: SearchModal,
parameters: { layout: "fullscreen" },
decorators: [
(S) => (
<div style={{ minHeight: "100vh", background: "var(--color-bg)" }}>
<ForceOpen />
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof SearchModal>;
export const Default: Story = {};
export const EmptyCatalogue: Story = {
parameters: {
msw: {
handlers: [
http.get("/v1/search/quick-actions", () => HttpResponse.json([])),
],
},
},
};
@@ -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<HTMLInputElement | null>(null);
const [query, setQuery] = useState("");
const state = useAsync<QuickAction[]>(() => 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<Record<string, QuickAction[]>>((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 (
<Modal
open={searchOpen}
onClose={closeSearch}
width="lg"
ariaLabel="Search"
>
<div className="portal-search">
<div className="portal-search__input-row">
<SearchIcon size={16} />
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search Stirling — endpoints, pipelines, docs…"
aria-label="Search"
className="portal-search__input"
autoComplete="off"
spellCheck={false}
/>
<span className="portal-search__esc" aria-hidden>
ESC
</span>
</div>
<div className="portal-search__results">
{isLoading && (
<div className="portal-search__loading">
<Skeleton height="1rem" width="40%" />
<Skeleton height="0.875rem" />
<Skeleton height="0.875rem" width="80%" />
<Skeleton height="0.875rem" width="65%" />
</div>
)}
{isEmpty && (
<EmptyState
size="compact"
title={
query.trim()
? `No matches for "${query.trim()}"`
: "No quick actions"
}
description={
query.trim()
? "Try a different keyword or browse the catalogue."
: "Quick actions will appear here once they're available."
}
/>
)}
{!isLoading &&
!isEmpty &&
Object.entries(groups).map(([group, items]) => (
<div key={group} className="portal-search__group">
<div className="portal-search__group-label">{group}</div>
{items.map((item) => (
<button
key={item.label}
type="button"
className="portal-search__item"
onClick={closeSearch}
>
<span className="portal-search__item-label">
{item.label}
</span>
<span className="portal-search__item-hint">
{item.hint}
</span>
</button>
))}
</div>
))}
</div>
</div>
</Modal>
);
}
+130
View File
@@ -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);
}
@@ -0,0 +1,29 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Sidebar } from "@portal/components/Sidebar";
const meta: Meta<typeof Sidebar> = {
title: "Portal/Shell/Sidebar",
component: Sidebar,
parameters: { layout: "fullscreen" },
decorators: [
(S) => (
<div
style={{
display: "flex",
height: "100vh",
background: "var(--color-bg)",
}}
>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof Sidebar>;
export const Default: Story = {};
export const FreeTier: Story = { globals: { tier: "free" } };
export const EnterpriseTier: Story = { globals: { tier: "enterprise" } };
+174
View File
@@ -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: <HomeIcon /> },
];
const GROUP_OPERATIONAL: NavEntry[] = [
{ id: "editor", label: "Editor", icon: <EditorIcon /> },
{ id: "sources", label: "Sources", icon: <SourcesIcon /> },
{ id: "pipelines", label: "Pipelines", icon: <PipelinesIcon /> },
{ id: "documents", label: "Documents", icon: <DocumentsIcon /> },
];
const GROUP_PLATFORM: NavEntry[] = [
{
id: "infrastructure",
label: "Infrastructure",
icon: <InfrastructureIcon />,
},
{ id: "usage", label: "Usage & Billing", icon: <UsageIcon /> },
{ id: "docs", label: "Developer Docs", icon: <DocsIcon /> },
];
function StirlingMark() {
// The Stirling brand mark — two stacked parallelograms in the brand blues.
return (
<svg
width="22"
height="22"
viewBox="0 0 22 22"
aria-hidden
role="presentation"
>
<path d="M5 4 L20 4 L17 11 L2 11 Z" fill="var(--color-blue)" />
<path d="M5 11 L17 11 L14 18 L2 18 Z" fill="var(--color-blue-border)" />
</svg>
);
}
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<KpiEntry[]>(
() => 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 (
<div className="portal-sidebar__usage portal-sidebar__usage--free">
<div className="portal-sidebar__usage-line">
<span className="portal-sidebar__usage-label">Docs processed</span>
<span className="portal-sidebar__usage-value">{docs ?? "—"}</span>
</div>
<div
className="portal-sidebar__usage-track"
role="progressbar"
aria-valuenow={used ?? 0}
aria-valuemax={cap ?? 100}
>
<div
className="portal-sidebar__usage-fill"
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
}
const planLabel = tier === "pro" ? "Pay-as-you-go" : "Enterprise Plan";
return (
<div className="portal-sidebar__usage">
<div className="portal-sidebar__usage-line">
<span className="portal-sidebar__plan">
<span className="portal-sidebar__plan-dot" aria-hidden />
{planLabel}
</span>
<span className="portal-sidebar__usage-value">
{docs != null ? `${docs} docs` : "—"}
</span>
</div>
</div>
);
}
export function Sidebar() {
const { activeView, setActiveView } = useView();
function renderGroup(entries: NavEntry[]) {
return entries.map((entry) => (
<NavItem
key={entry.id}
id={entry.id}
label={entry.label}
icon={entry.icon}
isActive={activeView === entry.id}
onClick={(id) => setActiveView(id as ViewId)}
/>
));
}
return (
<aside className="portal-sidebar" aria-label="Primary navigation">
<div className="portal-sidebar__logo">
<StirlingMark />
<span className="portal-sidebar__wordmark">Stirling</span>
<button
type="button"
className="portal-sidebar__workspace"
aria-label="Switch workspace"
>
<ChevronDownIcon size={14} />
</button>
</div>
<nav className="portal-sidebar__nav">
<div className="portal-sidebar__group">
{renderGroup(GROUP_PRIMARY)}
</div>
<div className="portal-sidebar__divider" aria-hidden />
<div className="portal-sidebar__group">
{renderGroup(GROUP_OPERATIONAL)}
</div>
<div className="portal-sidebar__divider" aria-hidden />
<div className="portal-sidebar__group">
{renderGroup(GROUP_PLATFORM)}
</div>
</nav>
<div className="portal-sidebar__footer">
<NavItem
id="settings"
label="Settings"
icon={<SettingsIcon />}
isActive={activeView === "settings"}
onClick={(id) => setActiveView(id as ViewId)}
/>
<UsageFooter />
</div>
</aside>
);
}
@@ -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;
}
@@ -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<typeof SingleOpRunner> = {
title: "Portal/Home/SingleOpRunner",
component: SingleOpRunner,
parameters: { layout: "fullscreen" },
args: { open: true, onClose: () => console.log("close") },
decorators: [
(S) => (
<div style={{ minHeight: "100vh", background: "var(--color-bg)" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof SingleOpRunner>;
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,
});
}),
],
},
},
};
@@ -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<FeaturedOp[]>(() => fetchFeaturedOps(), []);
const { data: ops } = opsState;
const { isLoading: opsIsLoading, isEmpty: opsIsEmpty } =
useSectionFlags(opsState);
const [selectedOpId, setSelectedOpId] = useState<string | null>(
initialOpId ?? null,
);
const [phase, setPhase] = useState<Phase>("idle");
const [sample, setSample] = useState<string | null>(null);
const [dragOver, setDragOver] = useState(false);
const [runResult, setRunResult] = useState<RunResult | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
// Default selection once the catalogue loads.
useEffect(() => {
if (!selectedOpId && ops && ops.length > 0) {
setSelectedOpId(ops[0].id);
}
}, [ops, selectedOpId]);
const selectedOp = useMemo<FeaturedOp | null>(
() => 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 (
<Modal
open={open}
onClose={onClose}
width="xl"
title="Try a PDF operation"
subtitle="Drop a sample, pick an op, see what Stirling returns."
footer={
<>
<div className="portal-runner__footer-status">
{phase === "running" && selectedOp && (
<>
<span className="portal-runner__spinner" aria-hidden />
<code>POST {selectedOp.endpoint}</code>
</>
)}
{phase === "done" && (
<StatusBadge tone="success" size="sm">
200 OK
</StatusBadge>
)}
{phase === "error" && (
<StatusBadge tone="danger" size="sm">
{errorMsg ?? "Failed"}
</StatusBadge>
)}
</div>
<Button variant="ghost" onClick={onClose}>
Close
</Button>
{phase === "done" ? (
<>
<Button variant="outline" onClick={reset}>
Run again
</Button>
<Button
variant="gradient"
onClick={buildPipelineWithOp}
trailingIcon={<span aria-hidden></span>}
>
Open the pipeline builder
</Button>
</>
) : (
<Button
variant="gradient"
onClick={run}
disabled={phase === "running" || !selectedOp}
trailingIcon={<span aria-hidden></span>}
>
{phase === "running" ? "Running…" : "Run operation"}
</Button>
)}
</>
}
>
<div className="portal-runner__layout">
{/* Left column: file + op picker */}
<section className="portal-runner__left">
<div
className={
"portal-runner__drop" +
(dragOver ? " is-armed" : "") +
(sample ? " has-file" : "")
}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
>
<div className="portal-runner__drop-icon" aria-hidden>
<svg
viewBox="0 0 24 24"
width="32"
height="32"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
</div>
<div className="portal-runner__drop-text">
{sample ? (
<>
<strong>{sample}</strong>
<span>Drop again or pick another sample to replace.</span>
</>
) : (
<>
<strong>Drop a PDF here</strong>
<span>or use a sample document.</span>
</>
)}
</div>
<button
type="button"
className="portal-runner__sample-btn"
onClick={pickSample}
>
{sample ? "Pick another sample" : "Use a sample"}
</button>
</div>
<div>
<div className="portal-runner__section-title">Featured ops</div>
<div className="portal-runner__ops">
{opsIsLoading &&
Array.from({ length: 4 }).map((_, i) => (
<div
key={`op-skel-${i}`}
className="portal-runner__op portal-runner__op--skeleton"
aria-hidden
>
<Skeleton width="60%" />
<Skeleton width="40%" height="0.625rem" />
<Skeleton width="80%" height="0.625rem" />
</div>
))}
{opsIsEmpty && (
<EmptyState
size="compact"
title="No featured ops yet"
description="Once operations are published, they'll show up here."
/>
)}
{ops?.map((op) => (
<button
key={op.id}
type="button"
className={
"portal-runner__op" +
(selectedOpId === op.id ? " is-selected" : "")
}
onClick={() => {
setSelectedOpId(op.id);
if (phase === "done") {
setPhase("idle");
setRunResult(null);
}
}}
data-accent={op.accent}
>
<span className="portal-runner__op-label">{op.label}</span>
<span className="portal-runner__op-endpoint">
{op.endpoint}
</span>
<span className="portal-runner__op-blurb">{op.blurb}</span>
</button>
))}
</div>
</div>
</section>
{/* Right column: state-driven result panel */}
<section className="portal-runner__right">
{phase === "idle" && selectedOp && (
<div className="portal-runner__hint">
<div className="portal-runner__hint-eyebrow">Ready</div>
<h3>
Run <code>{selectedOp.label}</code> on{" "}
<code>{sample ?? "a sample"}</code>
</h3>
<p>
{selectedOp.blurb}. Press <kbd>Run operation</kbd> to invoke{" "}
<code>POST {selectedOp.endpoint}</code>.
</p>
</div>
)}
{phase === "running" && selectedOp && (
<div className="portal-runner__running">
<div className="portal-runner__spinner-lg" aria-hidden />
<div className="portal-runner__running-text">
<div className="portal-runner__running-title">
Running {selectedOp.label}
</div>
<code>POST {selectedOp.endpoint}</code>
</div>
</div>
)}
{phase === "done" && selectedOp && runResult && (
<div className="portal-runner__result">
<header className="portal-runner__result-head">
<StatusBadge tone="success" size="sm">
Completed
</StatusBadge>
<code>POST {selectedOp.endpoint}</code>
<span className="portal-runner__result-meta">
{runResult.durationMs} ms
</span>
</header>
<pre className="portal-runner__result-code">
{JSON.stringify(runResult.result, null, 2)}
</pre>
</div>
)}
{phase === "error" && (
<div className="portal-runner__hint">
<div
className="portal-runner__hint-eyebrow"
style={{ color: "var(--color-red)" }}
>
Failed
</div>
<h3>The operation didn&rsquo;t complete</h3>
<p>{errorMsg ?? "Unknown error"}</p>
</div>
)}
</section>
</div>
</Modal>
);
}
@@ -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;
}
@@ -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<typeof UsageAreaChart> = {
title: "Portal/Home/UsageAreaChart",
component: UsageAreaChart,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "60rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof UsageAreaChart>;
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: "—" },
};
@@ -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<number | null>(null);
const svgRef = useRef<SVGSVGElement | null>(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<SVGSVGElement>) {
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<SVGSVGElement>) {
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 (
<div className="portal-chart">
<header className="portal-chart__head">
<div>
<div className="portal-chart__label">{totalLabel}</div>
<div className="portal-chart__value">{displayTotal}</div>
</div>
{deltaPct !== undefined && (
<div
className={
"portal-chart__delta " + (deltaPct >= 0 ? "is-up" : "is-down")
}
>
<span aria-hidden>{deltaPct >= 0 ? "↑" : "↓"}</span>
{Math.abs(Math.round(deltaPct * 100))}% vs prior 30d
</div>
)}
</header>
<svg
ref={svgRef}
viewBox={`0 0 ${VIEW.width} ${VIEW.height}`}
preserveAspectRatio="none"
role="img"
aria-label={totalLabel}
className="portal-chart__svg"
tabIndex={points.length > 0 ? 0 : -1}
onMouseMove={onMouseMove}
onMouseLeave={() => setHoverIndex(null)}
onKeyDown={onKeyDown}
onBlur={() => setHoverIndex(null)}
>
<defs>
<linearGradient id="chart-fill" x1="0" y1="0" x2="0" y2="1">
<stop
offset="0%"
stopColor="var(--color-blue)"
stopOpacity="0.32"
/>
<stop offset="100%" stopColor="var(--color-blue)" stopOpacity="0" />
</linearGradient>
</defs>
{/* Y gridlines + labels */}
{yTicks.map((tick) => {
const innerH = VIEW.height - PADDING.top - PADDING.bottom;
const y = PADDING.top + innerH - (tick / yMax) * innerH;
return (
<g key={tick}>
<line
x1={PADDING.left}
x2={VIEW.width - PADDING.right}
y1={y}
y2={y}
className="portal-chart__gridline"
/>
<text
x={PADDING.left - 6}
y={y + 4}
className="portal-chart__axis-label"
textAnchor="end"
>
{formatNumber(tick)}
</text>
</g>
);
})}
{/* Area + line */}
<path d={areaPath} fill="url(#chart-fill)" />
<path d={linePath} className="portal-chart__line" />
{/* X ticks */}
{xTickIndices.map((idx) => {
const p = points[idx];
if (!p) return null;
return (
<text
key={idx}
x={p.x}
y={VIEW.height - PADDING.bottom + 18}
className="portal-chart__axis-label"
textAnchor="middle"
>
{formatTick(p.raw.date)}
</text>
);
})}
{/* Hover scrub + dot + tooltip */}
{hovered && (
<>
<line
x1={hovered.x}
x2={hovered.x}
y1={PADDING.top}
y2={VIEW.height - PADDING.bottom}
className="portal-chart__scrub"
/>
<circle
cx={hovered.x}
cy={hovered.y}
r={4}
className="portal-chart__dot"
/>
</>
)}
</svg>
{hovered && (
<div
className="portal-chart__tooltip"
style={{
left: `${(hovered.x / VIEW.width) * 100}%`,
}}
>
<div className="portal-chart__tooltip-date">
{new Date(hovered.raw.date).toLocaleDateString(undefined, {
weekday: "short",
month: "short",
day: "numeric",
})}
</div>
<div className="portal-chart__tooltip-value">
{hovered.raw.value.toLocaleString()} docs
</div>
</div>
)}
<div className="sr-only" aria-live="polite" aria-atomic="true">
{hovered
? `${new Date(hovered.raw.date).toLocaleDateString(undefined, {
weekday: "short",
month: "short",
day: "numeric",
})}: ${hovered.raw.value.toLocaleString()} docs`
: ""}
</div>
</div>
);
}
@@ -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;
}
@@ -0,0 +1,20 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { WelcomeCarousel } from "@portal/components/WelcomeCarousel";
const meta: Meta<typeof WelcomeCarousel> = {
title: "Portal/Home/WelcomeCarousel",
component: WelcomeCarousel,
args: { onTryOp: () => console.log("try op") },
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "64rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof WelcomeCarousel>;
export const AutoRotating: Story = {};
@@ -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 (
<div className="portal-carousel__doc">
<StatusBadge tone="danger" size="sm" pulse>
Critical
</StatusBadge>
<div className="portal-carousel__doc-title">
Vulnerability Assessment Report
</div>
<div className="portal-carousel__doc-sub">CVE-2026-1847 · 12 pages</div>
<div className="portal-carousel__doc-meta">
<span>signed</span>
<span>·</span>
<span>OCR-clean</span>
<span>·</span>
<span>schema match 0.97</span>
</div>
</div>
);
}
function PlatformOrnament() {
return (
<pre className="portal-carousel__code">
<span className="portal-carousel__code-method">POST</span>{" "}
<span className="portal-carousel__code-path">/v1/secure</span>
{"\n"}
<span className="portal-carousel__code-key">Authorization</span>:{" "}
<span className="portal-carousel__code-string">Bearer sk_live_a3f8</span>
{"\n"}
<span className="portal-carousel__code-key">file</span>:{" "}
<span className="portal-carousel__code-string">
federal_CUI_contract.pdf
</span>
{"\n"}
<span className="portal-carousel__code-key">pipeline</span>:{" "}
<span className="portal-carousel__code-string">cui-redact-v2</span>
</pre>
);
}
function AgentOrnament() {
return (
<div className="portal-carousel__eval">
<div className="portal-carousel__eval-head">
<span className="portal-carousel__eval-title">KYC Processor</span>
<span className="portal-carousel__eval-score">94%</span>
</div>
<div className="portal-carousel__eval-sub">
Eval pass rate · 28 test cases
</div>
<div className="portal-carousel__eval-bar">
<div
className="portal-carousel__eval-fill"
style={{ width: "94%" }}
aria-hidden
/>
</div>
<div className="portal-carousel__eval-meta">
<span>26 passed</span>
<span>2 review</span>
<span>MCP · Claude</span>
</div>
</div>
);
}
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: <EditorOrnament />,
},
{
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: <PlatformOrnament />,
},
{
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: <AgentOrnament />,
},
];
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 (
<section
className="portal-carousel"
onMouseEnter={() => 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"
>
<div className="portal-carousel__inner" key={slide.id}>
<div className="portal-carousel__text">
<div className="portal-carousel__eyebrow">{slide.eyebrow}</div>
<h1 className="portal-carousel__title">{slide.title}</h1>
<p className="portal-carousel__sub">{slide.sub}</p>
<div className="portal-carousel__cta">
<Button
variant="gradient"
onClick={() => runAction(slide.primary)}
trailingIcon={<span aria-hidden></span>}
>
{slide.primary.label}
</Button>
<Button
variant="outline"
onClick={() => runAction(slide.secondary)}
>
{slide.secondary.label}
</Button>
</div>
</div>
<div className="portal-carousel__ornament">{slide.ornament}</div>
</div>
<div className="portal-carousel__decor" aria-hidden>
<div className="portal-carousel__blob portal-carousel__blob--1" />
<div className="portal-carousel__blob portal-carousel__blob--2" />
</div>
<div
className="portal-carousel__dots"
role="group"
aria-label="Carousel pagination"
>
{SLIDES.map((s, i) => (
<button
key={s.id}
type="button"
aria-current={i === index ? "true" : undefined}
aria-label={`Slide ${i + 1}: ${s.title}`}
className={
"portal-carousel__dot" + (i === index ? " is-active" : "")
}
onClick={() => setIndex(i)}
/>
))}
</div>
</section>
);
}
+202
View File
@@ -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 (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
{children}
</svg>
);
}
export function HomeIcon(props: IconProps) {
return (
<Svg {...props}>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</Svg>
);
}
export function EditorIcon(props: IconProps) {
return (
<Svg {...props}>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<path d="m10 13-2 2 2 2" />
<path d="m14 17 2-2-2-2" />
</Svg>
);
}
export function SourcesIcon(props: IconProps) {
return (
<Svg {...props}>
<path d="M9 2v6" />
<path d="M15 2v6" />
<path d="M6 8h12v3a6 6 0 0 1-12 0z" />
<path d="M12 14v8" />
</Svg>
);
}
export function PipelinesIcon(props: IconProps) {
return (
<Svg {...props}>
<rect x="3" y="3" width="6" height="6" rx="1" />
<rect x="15" y="3" width="6" height="6" rx="1" />
<rect x="9" y="15" width="6" height="6" rx="1" />
<path d="M6 9v3a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V9" />
<path d="M12 14v1" />
</Svg>
);
}
export function DocumentsIcon(props: IconProps) {
return (
<Svg {...props}>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="8" y1="13" x2="16" y2="13" />
<line x1="8" y1="17" x2="16" y2="17" />
<line x1="8" y1="9" x2="10" y2="9" />
</Svg>
);
}
export function InfrastructureIcon(props: IconProps) {
return (
<Svg {...props}>
<rect x="3" y="4" width="18" height="6" rx="1" />
<rect x="3" y="14" width="18" height="6" rx="1" />
<line x1="7" y1="7" x2="7.01" y2="7" />
<line x1="7" y1="17" x2="7.01" y2="17" />
</Svg>
);
}
export function UsageIcon(props: IconProps) {
return (
<Svg {...props}>
<line x1="6" y1="20" x2="6" y2="10" />
<line x1="12" y1="20" x2="12" y2="4" />
<line x1="18" y1="20" x2="18" y2="14" />
<line x1="3" y1="20" x2="21" y2="20" />
</Svg>
);
}
export function DocsIcon(props: IconProps) {
return (
<Svg {...props}>
<path d="M2 4.5A2.5 2.5 0 0 1 4.5 2H20v17H4.5A2.5 2.5 0 0 0 2 21.5z" />
<path d="M2 19.5A2.5 2.5 0 0 1 4.5 17H20" />
</Svg>
);
}
export function SettingsIcon(props: IconProps) {
return (
<Svg {...props}>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</Svg>
);
}
export function SearchIcon(props: IconProps) {
return (
<Svg {...props}>
<circle cx="11" cy="11" r="7" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</Svg>
);
}
export function SunIcon(props: IconProps) {
return (
<Svg {...props}>
<circle cx="12" cy="12" r="4" />
<line x1="12" y1="2" x2="12" y2="4" />
<line x1="12" y1="20" x2="12" y2="22" />
<line x1="4.93" y1="4.93" x2="6.34" y2="6.34" />
<line x1="17.66" y1="17.66" x2="19.07" y2="19.07" />
<line x1="2" y1="12" x2="4" y2="12" />
<line x1="20" y1="12" x2="22" y2="12" />
<line x1="4.93" y1="19.07" x2="6.34" y2="17.66" />
<line x1="17.66" y1="6.34" x2="19.07" y2="4.93" />
</Svg>
);
}
export function MoonIcon(props: IconProps) {
return (
<Svg {...props}>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</Svg>
);
}
export function BellIcon(props: IconProps) {
return (
<Svg {...props}>
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</Svg>
);
}
export function ChevronDownIcon(props: IconProps) {
return (
<Svg {...props}>
<polyline points="6 9 12 15 18 9" />
</Svg>
);
}
export function SparklesIcon(props: IconProps) {
return (
<Svg {...props}>
<path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z" />
<path d="M19 4v3" />
<path d="M17.5 5.5h3" />
</Svg>
);
}
export function CloseIcon(props: IconProps) {
return (
<Svg {...props}>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</Svg>
);
}
export function SendIcon(props: IconProps) {
return (
<Svg {...props}>
<line x1="22" y1="2" x2="11" y2="13" />
<polygon points="22 2 15 22 11 13 2 9 22 2" />
</Svg>
);
}
@@ -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<ThemeContextValue | null>(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<Theme>(readInitialTheme);
useEffect(() => {
document.documentElement.setAttribute("data-theme", theme);
window.localStorage.setItem(STORAGE_KEY, theme);
}, [theme]);
const value = useMemo<ThemeContextValue>(
() => ({
theme,
setTheme,
toggle: () => setTheme((t) => (t === "light" ? "dark" : "light")),
}),
[theme],
);
return (
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
);
}
export function useTheme(): ThemeContextValue {
const v = useContext(ThemeContext);
if (!v) throw new Error("useTheme must be used inside <ThemeProvider>");
return v;
}
@@ -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<Tier, TierInfo> = {
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<TierContextValue | null>(null);
export function TierProvider({
children,
initialTier = "pro",
}: {
children: ReactNode;
initialTier?: Tier;
}) {
const [tier, setTier] = useState<Tier>(initialTier);
const value = useMemo<TierContextValue>(() => ({ tier, setTier }), [tier]);
return <TierContext.Provider value={value}>{children}</TierContext.Provider>;
}
export function useTier(): TierContextValue {
const v = useContext(TierContext);
if (!v) throw new Error("useTier must be used inside <TierProvider>");
return v;
}
@@ -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<UIContextValue | null>(null);
export function UIProvider({ children }: { children: ReactNode }) {
const [searchOpen, setSearchOpen] = useState(false);
const [assistantOpen, setAssistantOpen] = useState(false);
const value = useMemo<UIContextValue>(
() => ({
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 <UIContext.Provider value={value}>{children}</UIContext.Provider>;
}
export function useUI(): UIContextValue {
const v = useContext(UIContext);
if (!v) throw new Error("useUI must be used inside <UIProvider>");
return v;
}
@@ -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<ViewId, string> = {
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<ViewId, string> = {
home: "/",
editor: "/editor",
sources: "/sources",
pipelines: "/pipelines",
documents: "/documents",
infrastructure: "/infrastructure",
usage: "/usage",
docs: "/docs",
settings: "/settings",
};
const PATH_TO_VIEW: Record<string, ViewId> = 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 <ViewProvider> wrapper exists any more — the router is the provider.
* App.tsx wraps its children in <BrowserRouter>.
*/
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 };
}
+97
View File
@@ -0,0 +1,97 @@
import { useEffect, useMemo, useState } from "react";
export interface AsyncState<T> {
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 <EmptyState>
* - 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<T>(state: AsyncState<T>): 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<T>(state: AsyncState<T>): 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 <Spinner />;
* if (!data) return null;
*/
export function useAsync<T>(
fn: (signal: AbortSignal) => Promise<T>,
deps: ReadonlyArray<unknown>,
): AsyncState<T> {
const [state, setState] = useState<AsyncState<T>>({
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;
}
+61
View File
@@ -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/<endpoint> 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;
}
+23
View File
@@ -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<void> {
if (workerStarted) return;
await worker.start({
onUnhandledRequest: "bypass",
serviceWorker: { url: "/mockServiceWorker.js" },
quiet: true,
});
workerStarted = true;
}
@@ -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) });
}),
];
@@ -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);
}),
];
@@ -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);
}),
];
@@ -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";
@@ -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 });
}),
];
+30
View File
@@ -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),
});
}),
];
@@ -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);
}),
];
+255
View File
@@ -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" },
},
];
@@ -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",
},
];
+166
View File
@@ -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<string, unknown>;
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<string, OpResultMap> = {
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,
},
};
+23
View File
@@ -0,0 +1,23 @@
/// <reference types="vite/client" />
/**
* 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));
}
+18
View File
@@ -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" },
];
@@ -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: () => (
<Stack gap="md" style={{ maxWidth: 420 }}>
<Group>
<SuiButton>SUI Button</SuiButton>
<MantineButton color="blue">Mantine Button</MantineButton>
</Group>
<Group>
<MantineButton color="green">Mantine green</MantineButton>
<MantineButton color="red">Mantine red</MantineButton>
<MantineButton color="amber">Mantine amber</MantineButton>
<MantineButton color="purple">Mantine purple</MantineButton>
</Group>
<MantineButton variant="light" color="blue">
Light variant (uses --color-blue-light)
</MantineButton>
<TextInput
label="Mantine TextInput"
placeholder="Reach for Mantine form controls when SUI doesn't have one"
/>
</Stack>
),
};
+87
View File
@@ -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 <Card> 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)",
});
+5
View File
@@ -0,0 +1,5 @@
.portal-documents {
padding: 1.5rem;
max-width: 78rem;
margin: 0 auto;
}
+15
View File
@@ -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 (
<div className="portal-documents">
<DocumentTypeGrid />
</div>
);
}
+276
View File
@@ -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);
}

Some files were not shown because too many files have changed in this diff Show More