Initial setup

This commit is contained in:
Reece
2026-05-19 14:53:51 +01:00
parent a1fb7c71c4
commit db4903724c
236 changed files with 24738 additions and 36 deletions
+33
View File
@@ -107,6 +107,13 @@ tasks:
- task: dev:_run
vars: { MODE: prototypes, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
dev:portal:
desc: "Start developer portal dev server"
deps: [prepare]
cmds:
- task: dev:_run
vars: { MODE: portal, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
# ============================================================
# Build
# ============================================================
@@ -151,6 +158,24 @@ tasks:
cmds:
- npx vite build --mode prototypes
build:portal:
desc: "Build developer portal"
deps: [prepare]
cmds:
- npx vite build --mode 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
# ============================================================
@@ -231,6 +256,12 @@ tasks:
cmds:
- npx tsc --noEmit --project src/prototypes/tsconfig.json
typecheck:portal:
desc: "Typecheck developer portal build variant"
deps: [prepare]
cmds:
- npx tsc --noEmit --project src/portal/tsconfig.json
typecheck:all:
desc: "Typecheck all build variants"
cmds:
@@ -240,6 +271,7 @@ tasks:
- task: typecheck:desktop
- task: typecheck:scripts
- task: typecheck:prototypes
- task: typecheck:portal
# ============================================================
# Quality Gate
@@ -261,6 +293,7 @@ tasks:
- task: format:check
- task: build
- task: test
- task: storybook:build
# ============================================================
# Test
+2
View File
@@ -11,6 +11,8 @@
# production
/build
/dist
/dist-portal
/storybook-static
# misc
.DS_Store
+56
View File
@@ -0,0 +1,56 @@
import { resolve } from "node:path";
import type { StorybookConfig } from "@storybook/react-vite";
// Tell vite.config.ts to skip the editor's viteStaticCopy plugin (PDF assets,
// fonts, locales — 188 items we don't need). Set before any Storybook import
// triggers config evaluation.
process.env.STIRLING_STORYBOOK = "true";
/**
* 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 src/shared/ and src/portal/.
* MDX docs pages live in src/portal/docs/.
*/
const config: StorybookConfig = {
stories: [
"../src/**/*.mdx",
"../src/**/*.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) => {
// Storybook resolves @app/* and @shared/* via explicit aliases here rather
// than via the per-mode tsconfig.*.vite.json — Storybook doesn't honour
// the --mode flag the way our vite.config.ts expects, so the safer path is
// to wire the aliases the storybook bundler actually uses.
config.resolve = config.resolve ?? {};
config.resolve.alias = {
...(config.resolve.alias ?? {}),
"@app": resolve(__dirname, "../src/portal"),
"@shared": resolve(__dirname, "../src/shared"),
};
// Strip the editor's viteStaticCopy plugin — it pulls 188 PDF/locale
// assets that the portal-and-design-system Storybook doesn't need.
if (Array.isArray(config.plugins)) {
config.plugins = config.plugins.filter((p) => {
if (!p || typeof p !== "object" || Array.isArray(p)) return true;
const name = (p as { name?: string }).name;
return name !== "vite-plugin-static-copy";
});
}
return config;
},
};
export default config;
+129
View File
@@ -0,0 +1,129 @@
import type { Decorator, Preview } from "@storybook/react-vite";
import { initialize, mswLoader } from "msw-storybook-addon";
import { MemoryRouter } from "react-router-dom";
import { useEffect } from "react";
import { withThemeByDataAttribute } from "@storybook/addon-themes";
import {
TierProvider,
useTier,
type Tier,
} from "@app/contexts/TierContext";
import { ThemeProvider } from "@app/contexts/ThemeContext";
import { UIProvider } from "@app/contexts/UIContext";
import { handlers } from "@app/mocks/handlers";
import "../src/shared/tokens/tokens.css";
import "../src/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";
return (
<MemoryRouter initialEntries={["/"]}>
<ThemeProvider>
<TierKey tier={tier}>
<UIProvider>
<ThemeWatcher />
<Story />
</UIProvider>
</TierKey>
</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;
+36
View File
@@ -75,6 +75,40 @@ 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: ["src/shared/**/*.{js,mjs,jsx,ts,tsx}"],
rules: {
"no-restricted-imports": [
"error",
{
patterns: [
...baseRestrictedImportPatterns,
{
regex: "^@app/",
message:
"src/shared/ must not depend on editor or portal layers. Use @shared/* or third-party imports only.",
},
{
regex: "^@core/",
message: "src/shared/ must not depend on src/core/.",
},
{
regex: "^@proprietary/",
message: "src/shared/ must not depend on src/proprietary/.",
},
{
regex: "^@tauri-apps/",
message: "src/shared/ must remain web-compatible (no Tauri APIs).",
},
],
},
],
},
},
// Folders that have been cleaned up and are now conformant - stricter rules enforced here
{
files: [
@@ -82,6 +116,8 @@ export default defineConfig(
"src/proprietary/**/*.{js,mjs,jsx,ts,tsx}",
"src/saas/**/*.{js,mjs,jsx,ts,tsx}",
"src/prototypes/**/*.{js,mjs,jsx,ts,tsx}",
"src/portal/**/*.{js,mjs,jsx,ts,tsx}",
"src/shared/**/*.{js,mjs,jsx,ts,tsx}",
],
languageOptions: {
parserOptions: {
+1715
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -102,6 +102,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",
@@ -125,6 +129,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",
@@ -132,6 +138,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",
@@ -147,5 +154,10 @@
"@testing-library/user-event",
"@vitest/coverage-v8"
]
},
"msw": {
"workerDirectory": [
"portal\\public"
]
}
}
+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>
+31
View File
@@ -0,0 +1,31 @@
/// <reference types="vite/client" />
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "@app/App";
import { readMocksPreference } from "@app/mocks/preference";
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("@app/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,
}
}
+57
View File
@@ -0,0 +1,57 @@
import { useEffect } from "react";
import { BrowserRouter } from "react-router-dom";
import { ThemeProvider } from "@app/contexts/ThemeContext";
import { TierProvider } from "@app/contexts/TierContext";
import { UIProvider, useUI } from "@app/contexts/UIContext";
import { AppShell } from "@app/components/AppShell";
import { AssistantButton } from "@app/components/AssistantButton";
import { AssistantPanel } from "@app/components/AssistantPanel";
import { SearchModal } from "@app/components/SearchModal";
import { ViewRouter } from "@app/ViewRouter";
/**
* 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>
<TierProvider initialTier="pro">
<BrowserRouter>
<UIProvider>
<GlobalShortcuts />
<AppShell>
<ViewRouter />
</AppShell>
<AssistantButton />
<AssistantPanel />
<SearchModal />
</UIProvider>
</BrowserRouter>
</TierProvider>
</ThemeProvider>
);
}
+68
View File
@@ -0,0 +1,68 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { Home } from "@app/views/Home";
import { Placeholder } from "@app/views/Placeholder";
import { VIEW_PATHS, type ViewId } from "@app/contexts/ViewContext";
const PLACEHOLDER_PHASES: Partial<Record<ViewId, string>> = {
editor: "Phase 8 — Editor",
sources: "Phase 5 — Sources & Agents",
pipelines: "Phase 4 — Pipelines",
documents: "Phase 6 — Documents",
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={
<Placeholder view="documents" phase={PLACEHOLDER_PHASES.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 "@app/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;
}
+29
View File
@@ -0,0 +1,29 @@
import { httpJson } from "@app/api/http";
import type { Endpoint, Vertical, VerticalKey } 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");
}
/** GET /v1/endpoints/flat — flat list across every vertical. */
export async function fetchAllEndpoints(): Promise<Endpoint[]> {
return httpJson<Endpoint[]>("/v1/endpoints/flat");
}
/** GET /v1/endpoints?vertical=… */
export async function fetchEndpointsByVertical(
key: VerticalKey,
): Promise<Endpoint[]> {
return httpJson<Endpoint[]>(
`/v1/endpoints?vertical=${encodeURIComponent(key)}`,
);
}
+44
View File
@@ -0,0 +1,44 @@
import { httpJson } from "@app/api/http";
import type {
ActivityEvent,
KpiEntry,
OnboardingStep,
RegionHealth,
UsageSeriesResponse,
} from "@app/mocks/home";
import type { Tier } from "@app/contexts/TierContext";
export type {
ActivityEvent,
ActivityKind,
KpiEntry,
OnboardingStep,
RegionHealth,
UsagePoint,
UsageSeriesResponse,
} from "@app/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");
}
+79
View File
@@ -0,0 +1,79 @@
/**
* Shared HTTP plumbing for the portal's service layer.
*
* Today everything is mocked in-process via {@link simulateNetwork}; when
* we wire up a real backend, the helpers in `api/*.ts` switch from
* `await simulateNetwork(); return MOCK_DATA;` to calling {@link httpJson}.
* Consumers don't change.
*/
/** Simulated network latency, in ms. */
const DEFAULT_LATENCY_MS = 120;
const DEFAULT_JITTER_MS = 80;
/**
* Sleeps for a small randomised duration so mocked endpoints feel like real
* network calls (loading states actually show, race-condition bugs surface).
*/
export function simulateNetwork(options?: {
latencyMs?: number;
jitterMs?: number;
}): Promise<void> {
const latency = options?.latencyMs ?? DEFAULT_LATENCY_MS;
const jitter = options?.jitterMs ?? DEFAULT_JITTER_MS;
const total = latency + Math.random() * jitter;
return new Promise((resolve) => window.setTimeout(resolve, total));
}
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 fetch wrapper for the future real backend. Currently unused — api
* modules call simulateNetwork() and return mocks directly. Wired here so
* the swap to a real backend is a per-endpoint change, not an architectural
* one.
*/
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;
}
+16
View File
@@ -0,0 +1,16 @@
import { httpJson } from "@app/api/http";
import type { Notification, NotificationCategory } from "@app/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 "@app/api/http";
import type { FeaturedOp, OpResultMap } from "@app/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 "@app/api/http";
import type { QuickAction } from "@app/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 "@app/components/Sidebar";
import { Header } from "@app/components/Header";
import "@app/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,31 @@
.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,38 @@
import { useUI } from "@app/contexts/UIContext";
import { SparklesIcon } from "@app/components/icons";
import "@app/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,185 @@
.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,58 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useEffect } from "react";
import { http, HttpResponse, delay } from "msw";
import { AssistantPanel } from "@app/components/AssistantPanel";
import { useUI } from "@app/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,167 @@
import { useEffect, useRef, useState } from "react";
import { useUI } from "@app/contexts/UIContext";
import { useAsync } from "@app/hooks/useAsync";
import {
fetchAssistantSuggestions,
getAssistantReply,
} from "@app/api/assistant";
import { CloseIcon, SendIcon, SparklesIcon } from "@app/components/icons";
import "@app/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) 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)}
>
{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"
/>
<button
type="submit"
className="portal-assistant__send"
disabled={!input.trim()}
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 "@app/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 "@app/hooks/useAsync";
import {
fetchVerticals,
type Endpoint,
type Vertical,
type VerticalKey,
} from "@app/api/endpoints";
import "@app/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>
);
}
+176
View File
@@ -0,0 +1,176 @@
.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 "@app/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" } };
+107
View File
@@ -0,0 +1,107 @@
import { Avatar, Dropdown } from "@shared/components";
import { useTheme } from "@app/contexts/ThemeContext";
import { useTier, TIER_INFO, type Tier } from "@app/contexts/TierContext";
import { useView, VIEW_LABELS } from "@app/contexts/ViewContext";
import { useUI } from "@app/contexts/UIContext";
import {
SearchIcon,
SunIcon,
MoonIcon,
ChevronDownIcon,
} from "@app/components/icons";
import { NotificationsDropdown } from "@app/components/NotificationsDropdown";
import { MocksToggle } from "@app/components/MocksToggle";
import "@app/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" onClick={() => {}} />
</div>
</header>
);
}
@@ -0,0 +1,52 @@
.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 "@app/mocks/preference";
import "@app/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,70 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { http, HttpResponse, delay } from "msw";
import { NotificationsDropdown } from "@app/components/NotificationsDropdown";
import { NOTIFICATIONS } from "@app/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,124 @@
import { useEffect, useState } from "react";
import { Dropdown, EmptyState, Skeleton } from "@shared/components";
import { BellIcon } from "@app/components/icons";
import { useAsync, useSectionFlags } from "@app/hooks/useAsync";
import {
fetchNotifications,
markAllNotificationsRead,
type Notification,
type NotificationCategory,
} from "@app/api/notifications";
import "@app/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);
const [localItems, setLocalItems] = useState<Notification[] | null>(null);
const visible = localItems ?? items ?? [];
useEffect(() => {
if (items) setLocalItems(items);
}, [items]);
async function onMarkAllRead() {
setLocalItems([]);
await markAllNotificationsRead();
}
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,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 "@app/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,104 @@
import { Card, EmptyState, Skeleton, StatusBadge } from "@shared/components";
import { useAsync, useSectionFlags } from "@app/hooks/useAsync";
import {
fetchRecentActivity,
type ActivityEvent,
type ActivityKind,
} from "@app/api/home";
import "@app/components/RecentActivity.css";
const STATUS_TONE: Record<
ActivityEvent["status"],
"success" | "warning" | "danger" | "info"
> = {
success: "success",
warning: "warning",
danger: "danger",
info: "info",
};
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={STATUS_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,39 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useEffect } from "react";
import { http, HttpResponse } from "msw";
import { SearchModal } from "@app/components/SearchModal";
import { useUI } from "@app/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,119 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { EmptyState, Modal, Skeleton } from "@shared/components";
import { useUI } from "@app/contexts/UIContext";
import { useAsync, useSectionFlags } from "@app/hooks/useAsync";
import { fetchQuickActions, type QuickAction } from "@app/api/search";
import { SearchIcon } from "@app/components/icons";
import "@app/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>
);
}
+128
View File
@@ -0,0 +1,128 @@
.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 "@app/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" } };
+160
View File
@@ -0,0 +1,160 @@
import { NavItem } from "@shared/components";
import { useView, type ViewId } from "@app/contexts/ViewContext";
import { useTier } from "@app/contexts/TierContext";
import {
HomeIcon,
EditorIcon,
SourcesIcon,
PipelinesIcon,
DocumentsIcon,
InfrastructureIcon,
UsageIcon,
DocsIcon,
SettingsIcon,
ChevronDownIcon,
} from "@app/components/icons";
import "@app/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();
if (tier === "free") {
const docs = 247;
const cap = 500;
const pct = (docs / cap) * 100;
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} / {cap}
</span>
</div>
<div
className="portal-sidebar__usage-track"
role="progressbar"
aria-valuenow={docs}
aria-valuemax={cap}
>
<div
className="portal-sidebar__usage-fill"
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
}
const planLabel = tier === "pro" ? "Pay-as-you-go" : "Enterprise Plan";
const docCount = tier === "pro" ? "12,481" : "1.84M";
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">{docCount} 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 "@app/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 "@app/contexts/ViewContext";
import { useAsync, useSectionFlags } from "@app/hooks/useAsync";
import {
fetchFeaturedOps,
runSingleOp,
type FeaturedOp,
type OpResultMap,
} from "@app/api/ops";
import "@app/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>}
>
Build a pipeline with this op
</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,50 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { UsageAreaChart } from "@app/components/UsageAreaChart";
import { buildUsageSeries } from "@app/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,271 @@
import { useMemo, useRef, useState, type KeyboardEvent } from "react";
import type { UsagePoint } from "@app/api/home";
import "@app/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.
const niceMax = Math.ceil(max / 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,223 @@
.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 "@app/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,211 @@
import { useEffect, useState, type ReactNode } from "react";
import { Button, StatusBadge } from "@shared/components";
import { useView, type ViewId } from "@app/contexts/ViewContext";
import "@app/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={() => 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,48 @@
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,69 @@
import {
createContext,
useCallback,
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;
}
/**
* Convenience hook — registers a global keyboard shortcut. Caller owns the
* handler so the same hook works for ⌘K, ESC, etc.
*/
export function useKeyboardShortcut(
match: (e: KeyboardEvent) => boolean,
handler: (e: KeyboardEvent) => void,
) {
// Use a stable handler ref via useCallback to keep dependencies stable.
const onKey = useCallback(
(e: KeyboardEvent) => {
if (match(e)) handler(e);
},
[match, handler],
);
// Effect lives in the caller; keep this a pure utility.
return onKey;
}
@@ -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 "@app/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 "@app/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,20 @@
import { http, HttpResponse, delay } from "msw";
import { ALL_ENDPOINTS, 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);
}),
http.get("/v1/endpoints/flat", async () => {
await delay(120);
return HttpResponse.json(ALL_ENDPOINTS);
}),
];
@@ -0,0 +1,49 @@
import { http, HttpResponse, delay } from "msw";
import type { Tier } from "@app/contexts/TierContext";
import {
buildUsageSeries,
buildUsageSeriesResponse,
enterpriseKpisFor,
FREE_KPIS,
FREE_ONBOARDING,
proKpisFor,
RECENT_ACTIVITY,
REGION_HEALTH,
type KpiEntry,
} from "@app/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 "@app/mocks/handlers/assistant";
import { endpointsHandlers } from "@app/mocks/handlers/endpoints";
import { homeHandlers } from "@app/mocks/handlers/home";
import { notificationsHandlers } from "@app/mocks/handlers/notifications";
import { opsHandlers } from "@app/mocks/handlers/ops";
import { searchHandlers } from "@app/mocks/handlers/search";
export const handlers = [
...homeHandlers,
...opsHandlers,
...notificationsHandlers,
...assistantHandlers,
...searchHandlers,
...endpointsHandlers,
];
export { resetNotificationsStore } from "@app/mocks/handlers/notifications";
@@ -0,0 +1,21 @@
import { http, HttpResponse, delay } from "msw";
import { NOTIFICATIONS, type Notification } from "@app/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 });
}),
];
+27
View File
@@ -0,0 +1,27 @@
import { http, HttpResponse, delay } from "msw";
import { FEATURED_OPS, OP_RESULTS } from "@app/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 "@app/mocks/search";
export const searchHandlers = [
http.get("/v1/search/quick-actions", () => {
return HttpResponse.json(QUICK_ACTIONS);
}),
];
+253
View File
@@ -0,0 +1,253 @@
/**
* Mock data sources for the home dashboard. Imported only by api/home.ts —
* components never reach into this module directly.
*
* Once a backend exists, api/home.ts swaps to httpJson() calls 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" },
];
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": "../../",
"paths": {
"@app/*": ["src/portal/*", "src/shared/*"],
"@shared/*": ["src/shared/*"]
}
},
"include": [
"../global.d.ts",
".",
"../shared",
"../../portal"
]
}
+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);
}
@@ -0,0 +1,29 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { http, HttpResponse, delay } from "msw";
import { Home } from "@app/views/Home";
const meta: Meta<typeof Home> = {
title: "Portal/Views/Home",
component: Home,
parameters: { layout: "fullscreen" },
};
export default meta;
type Story = StoryObj<typeof Home>;
export const ProTier: Story = { globals: { tier: "pro" } };
export const FreeTier: Story = { globals: { tier: "free" } };
export const EnterpriseTier: Story = { globals: { tier: "enterprise" } };
export const SlowUsage: Story = {
globals: { tier: "pro" },
parameters: {
msw: {
handlers: [
http.get("/v1/analytics/usage", async () => {
await delay(3000);
return HttpResponse.json({ points: [], priorTotal: 0 });
}),
],
},
},
};
+534
View File
@@ -0,0 +1,534 @@
import { useMemo, useState } from "react";
import {
Button,
Card,
EmptyState,
MetricCard,
Skeleton,
StatusBadge,
} from "@shared/components";
import { useTier, type Tier } from "@app/contexts/TierContext";
import { useView, type ViewId } from "@app/contexts/ViewContext";
import { useAsync, useSectionFlags } from "@app/hooks/useAsync";
import {
fetchHomeKpis,
fetchOnboarding,
fetchRegionHealth,
fetchUsageSeries,
type KpiEntry,
type OnboardingStep,
type RegionHealth,
type UsageSeriesResponse,
} from "@app/api/home";
import { WelcomeCarousel } from "@app/components/WelcomeCarousel";
import { DocumentTypeGrid } from "@app/components/DocumentTypeGrid";
import { UsageAreaChart } from "@app/components/UsageAreaChart";
import { RecentActivity } from "@app/components/RecentActivity";
import { SingleOpRunner } from "@app/components/SingleOpRunner";
import "@app/views/Home.css";
/* ──────────────────────────────────────────────────────────────────────── */
/* Product cards (Sources / Pipelines / Agents) */
/* ──────────────────────────────────────────────────────────────────────── */
interface ProductCardProps {
accent: "blue" | "purple";
badge?: string;
title: string;
blurb: string;
cta: string;
target: ViewId;
}
function ProductCard({
accent,
badge,
title,
blurb,
cta,
target,
}: ProductCardProps) {
const { setActiveView } = useView();
return (
<Card accent={accent} padding="loose" interactive>
<div className="portal-home__product-head">
<h3 className="portal-home__product-title">{title}</h3>
{badge && (
<StatusBadge tone={accent === "blue" ? "info" : "purple"} size="sm">
{badge}
</StatusBadge>
)}
</div>
<p className="portal-home__product-blurb">{blurb}</p>
<Button
variant="outline"
accent={accent}
size="sm"
onClick={() => setActiveView(target)}
trailingIcon={<span aria-hidden>→</span>}
>
{cta}
</Button>
</Card>
);
}
function ProductGrid() {
return (
<section
className="portal-home__product-grid"
aria-label="Process PDFs at scale"
>
<ProductCard
accent="purple"
title="Sources"
blurb="Attach pipelines where PDFs already live — S3, agents, SharePoint, webhooks, batch, email."
cta="Connect a source"
target="sources"
/>
<ProductCard
accent="blue"
badge="Hero"
title="Pipelines"
blurb="Compose document workflows from typed operations. Upload a sample to get suggestions or start blank."
cta="Build a pipeline"
target="pipelines"
/>
<ProductCard
accent="purple"
title="Agents"
blurb="Wire your agent via MCP, REST, or tool definitions. Deterministic operations, scenarios, evals."
cta="Connect an agent"
target="sources"
/>
</section>
);
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Quick actions card */
/* ──────────────────────────────────────────────────────────────────────── */
function QuickActions({ onTryOp }: { onTryOp: () => void }) {
const { setActiveView } = useView();
return (
<Card padding="loose" className="portal-home__quick">
<div className="portal-home__quick-head">
<h2 className="portal-home__quick-title">Quick actions</h2>
<span className="portal-home__quick-sub">Top tasks for today</span>
</div>
<div className="portal-home__quick-list">
<button
type="button"
className="portal-home__quick-row"
onClick={onTryOp}
>
<span
className="portal-home__quick-icon"
style={{
background: "var(--color-blue-light)",
color: "var(--color-blue)",
}}
aria-hidden
>
▶
</span>
<span className="portal-home__quick-text">
<strong>Try a PDF operation</strong>
<span>Drop a sample, pick an op, see the JSON</span>
</span>
<span className="portal-home__quick-arrow" aria-hidden>
→
</span>
</button>
<button
type="button"
className="portal-home__quick-row"
onClick={() => setActiveView("pipelines")}
>
<span
className="portal-home__quick-icon"
style={{
background: "var(--color-purple-light)",
color: "var(--color-purple)",
}}
aria-hidden
>
⌃
</span>
<span className="portal-home__quick-text">
<strong>Build a pipeline</strong>
<span>3-step composer over the typed op library</span>
</span>
<span className="portal-home__quick-arrow" aria-hidden>
→
</span>
</button>
<button
type="button"
className="portal-home__quick-row"
onClick={() => setActiveView("sources")}
>
<span
className="portal-home__quick-icon"
style={{
background: "var(--color-green-light)",
color: "var(--color-green-dark)",
}}
aria-hidden
>
⇢
</span>
<span className="portal-home__quick-text">
<strong>Connect a source</strong>
<span>S3, agents, webhooks, watched folders</span>
</span>
<span className="portal-home__quick-arrow" aria-hidden>
→
</span>
</button>
<button
type="button"
className="portal-home__quick-row"
onClick={() => setActiveView("infrastructure")}
>
<span
className="portal-home__quick-icon"
style={{
background: "var(--color-amber-light)",
color: "var(--color-amber-dark)",
}}
aria-hidden
>
⚙
</span>
<span className="portal-home__quick-text">
<strong>Issue an API key</strong>
<span>Scoped key with rate limits and IP allowlist</span>
</span>
<span className="portal-home__quick-arrow" aria-hidden>
→
</span>
</button>
</div>
</Card>
);
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Free-tier onboarding checklist */
/* ──────────────────────────────────────────────────────────────────────── */
function FreeOnboarding({ onTryOp }: { onTryOp: () => void }) {
const { setActiveView } = useView();
const state = useAsync<OnboardingStep[]>(() => fetchOnboarding(), []);
const { data: steps } = state;
const { isLoading, isEmpty } = useSectionFlags(state);
const doneCount = steps?.filter((s) => s.done).length ?? 0;
function renderCta(step: OnboardingStep) {
if (step.done) {
return (
<Button size="sm" variant="ghost" onClick={onTryOp}>
Run again
</Button>
);
}
if (!step.cta) return null;
if (step.cta.kind === "try-op") {
return (
<Button size="sm" variant="outline" onClick={onTryOp}>
Start
</Button>
);
}
const target = step.cta.target;
return (
<Button
size="sm"
variant="outline"
onClick={() => setActiveView(target as ViewId)}
>
Start
</Button>
);
}
return (
<Card padding="loose" className="portal-home__onboard">
<div className="portal-home__onboard-head">
<div>
<h2 className="portal-home__onboard-title">Get to value</h2>
<p className="portal-home__onboard-sub">
Four steps to a production-shaped Stirling project.
</p>
</div>
{steps && steps.length > 0 && (
<StatusBadge tone="info" size="sm">
{doneCount} / {steps.length} done
</StatusBadge>
)}
</div>
{isLoading && (
<ol className="portal-home__onboard-list" aria-hidden>
{Array.from({ length: 4 }).map((_, i) => (
<li
key={`skel-${i}`}
className="portal-home__onboard-step is-loading"
>
<span className="portal-home__onboard-mark" />
<div className="portal-home__onboard-text">
<div className="portal-home__onboard-skel" />
</div>
</li>
))}
</ol>
)}
{isEmpty && (
<EmptyState
size="compact"
title="No onboarding steps yet"
description="Onboarding tasks will appear here once your workspace is set up."
/>
)}
{steps && steps.length > 0 && (
<ol className="portal-home__onboard-list">
{steps.map((s, i) => (
<li
key={s.id}
className={
"portal-home__onboard-step" + (s.done ? " is-done" : "")
}
>
<span className="portal-home__onboard-mark">
{s.done ? "✓" : i + 1}
</span>
<div className="portal-home__onboard-text">
<strong>{s.title}</strong>
<span>{s.blurb}</span>
</div>
{renderCta(s)}
</li>
))}
</ol>
)}
</Card>
);
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Tier KPI strip */
/* ──────────────────────────────────────────────────────────────────────── */
interface KpiLabelSpec {
label: string;
description?: string;
}
/**
* KPI labels are product copy — they describe what each metric IS, not what
* its current value is. Labels stay client-side so the strip's structure is
* stable across loading / error / ready states. Only the values + deltas
* flow through the API.
*/
const KPI_LABELS_BY_TIER: Record<Tier, KpiLabelSpec[]> = {
free: [
{ label: "Docs processed", description: "Free plan cap" },
{ label: "Operations" },
{ label: "Pipelines" },
{ label: "Agents" },
],
pro: [
{ label: "Docs / 30d" },
{ label: "Pipelines" },
{ label: "Agents active" },
{ label: "Eval pass rate" },
],
enterprise: [
{ label: "Docs / 30d" },
{ label: "P95 latency" },
{ label: "Eval pass rate" },
{ label: "SLA uptime (30d)" },
],
};
function TierKpiStrip() {
const { tier } = useTier();
const labels = KPI_LABELS_BY_TIER[tier];
const { data: kpis } = useAsync<KpiEntry[]>(
() => fetchHomeKpis(tier),
[tier],
);
return (
<section className="portal-home__metrics">
{labels.map((spec, i) => {
const fetched = kpis?.[i];
return (
<MetricCard
key={`${tier}-${i}`}
label={spec.label}
value={fetched?.value ?? "—"}
delta={fetched?.delta}
deltaDirection={fetched?.deltaDirection}
description={spec.description ?? fetched?.description}
/>
);
})}
</section>
);
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Enterprise region health */
/* ──────────────────────────────────────────────────────────────────────── */
const REGION_DOT: Record<RegionHealth["status"], string> = {
healthy: "var(--color-green)",
degraded: "var(--color-amber)",
down: "var(--color-red)",
};
function EnterpriseRegions() {
const state = useAsync<RegionHealth[]>(() => fetchRegionHealth(), []);
const { data: regions } = state;
const { isLoading, isEmpty } = useSectionFlags(state);
return (
<section className="portal-home__region-block">
<header className="portal-home__region-header">
<h2 className="portal-home__region-title">Region health</h2>
<p className="portal-home__region-sub">
Real-time status for every deployed Stirling region.
</p>
</header>
{isLoading && (
<div className="portal-home__regions" aria-hidden>
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="portal-home__region">
<Skeleton width="6rem" />
<Skeleton width="80%" height="0.625rem" />
</div>
))}
</div>
)}
{isEmpty && (
<EmptyState
size="compact"
title="No regions yet"
description="Once a region is deployed, its health appears here."
/>
)}
{regions && regions.length > 0 && (
<div className="portal-home__regions">
{regions.map((r) => (
<div key={r.name} className="portal-home__region">
<div className="portal-home__region-name">
<span
className="portal-home__region-dot"
style={{ background: REGION_DOT[r.status] }}
aria-hidden
/>
{r.name}
<span className="sr-only">{` — ${r.status}`}</span>
</div>
<div className="portal-home__region-meta">{r.meta}</div>
</div>
))}
</div>
)}
</section>
);
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Chart section */
/* ──────────────────────────────────────────────────────────────────────── */
function ChartSection() {
const { data: usage } = useAsync<UsageSeriesResponse>(
() => fetchUsageSeries(),
[],
);
const docs30d = useMemo(
() => usage?.points.reduce((sum, p) => sum + p.value, 0) ?? 0,
[usage],
);
const deltaPct = useMemo(() => {
if (!usage || usage.priorTotal <= 0) return undefined;
return (docs30d - usage.priorTotal) / usage.priorTotal;
}, [usage, docs30d]);
return (
<UsageAreaChart
data={usage?.points ?? []}
totalValue={usage ? docs30d.toLocaleString() : "—"}
deltaPct={deltaPct}
/>
);
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Home view */
/* ──────────────────────────────────────────────────────────────────────── */
export function Home() {
const { tier } = useTier();
const [runnerOpen, setRunnerOpen] = useState(false);
return (
<div className="portal-home">
<WelcomeCarousel onTryOp={() => setRunnerOpen(true)} />
{tier === "free" && (
<>
<TierKpiStrip />
<div className="portal-home__row">
<FreeOnboarding onTryOp={() => setRunnerOpen(true)} />
<QuickActions onTryOp={() => setRunnerOpen(true)} />
</div>
<ProductGrid />
<DocumentTypeGrid />
</>
)}
{tier === "pro" && (
<>
<ChartSection />
<TierKpiStrip />
<div className="portal-home__row">
<RecentActivity />
<QuickActions onTryOp={() => setRunnerOpen(true)} />
</div>
<ProductGrid />
<DocumentTypeGrid />
</>
)}
{tier === "enterprise" && (
<>
<ChartSection />
<TierKpiStrip />
<EnterpriseRegions />
<div className="portal-home__row">
<RecentActivity />
<QuickActions onTryOp={() => setRunnerOpen(true)} />
</div>
<ProductGrid />
<DocumentTypeGrid />
</>
)}
<SingleOpRunner
open={runnerOpen}
onClose={() => setRunnerOpen(false)}
/>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
.portal-placeholder {
display: flex;
justify-content: center;
padding: 3rem 1.5rem;
}
.portal-placeholder__card {
max-width: 36rem;
text-align: center;
}
.portal-placeholder__eyebrow {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-blue);
margin-bottom: 0.5rem;
}
.portal-placeholder__title {
margin: 0 0 0.75rem;
font-size: 1.5rem;
font-weight: 700;
color: var(--color-text-1);
}
.portal-placeholder__copy {
margin: 0;
font-size: 0.9375rem;
line-height: 1.55;
color: var(--color-text-3);
}
@@ -0,0 +1,16 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Placeholder } from "@app/views/Placeholder";
const meta: Meta<typeof Placeholder> = {
title: "Portal/Views/Placeholder",
component: Placeholder,
parameters: { layout: "fullscreen" },
};
export default meta;
type Story = StoryObj<typeof Placeholder>;
export const Sources: Story = { args: { view: "sources", phase: "Phase 5 — Sources & Agents" } };
export const Documents: Story = { args: { view: "documents", phase: "Phase 6 — Documents" } };
export const Infrastructure: Story = { args: { view: "infrastructure", phase: "Phase 7 — Infrastructure" } };
export const Editor: Story = { args: { view: "editor", phase: "Phase 8 — Editor" } };
export const Settings: Story = { args: { view: "settings", phase: "Settings — modal overlay" } };
+27
View File
@@ -0,0 +1,27 @@
import { Card } from "@shared/components";
import { VIEW_LABELS, type ViewId } from "@app/contexts/ViewContext";
import "@app/views/Placeholder.css";
interface PlaceholderProps {
view: ViewId;
phase?: string;
}
export function Placeholder({ view, phase }: PlaceholderProps) {
return (
<div className="portal-placeholder">
<Card padding="loose" className="portal-placeholder__card">
<div className="portal-placeholder__eyebrow">
{phase ?? "Coming soon"}
</div>
<h1 className="portal-placeholder__title">{VIEW_LABELS[view]}</h1>
<p className="portal-placeholder__copy">
This surface is part of the build plan but hasn&rsquo;t been wired up
yet. The shell, theme, and tier behaviour around it are already live —
you can switch views from the sidebar, flip the theme, and change tier
to see how the chrome reacts.
</p>
</Card>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
.sui-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-weight: 600;
color: #fff;
font-family: var(--font-sans);
letter-spacing: 0.01em;
overflow: hidden;
flex-shrink: 0;
}
.sui-avatar--interactive { cursor: pointer; transition: transform var(--motion-fast); }
.sui-avatar--interactive:hover { transform: scale(1.05); }
.sui-avatar--xs { width: 1.25rem; height: 1.25rem; font-size: 0.625rem; }
.sui-avatar--sm { width: 1.625rem; height: 1.625rem; font-size: 0.6875rem; }
.sui-avatar--md { width: 2rem; height: 2rem; font-size: 0.8125rem; }
.sui-avatar--lg { width: 2.5rem; height: 2.5rem; font-size: 1rem; }
.sui-avatar__img { width: 100%; height: 100%; object-fit: cover; display: block; }
.sui-avatar--blue { background: var(--grad-blue-btn); }
.sui-avatar--purple { background: var(--grad-purple-btn); }
.sui-avatar--green { background: var(--grad-green-btn); }
.sui-avatar--amber { background: linear-gradient(180deg, #fbbf24, #f59e0b); }
.sui-avatar--red { background: linear-gradient(180deg, #f87171, #ef4444); }
.sui-avatar--neutral { background: var(--color-bg-muted); color: var(--color-text-2); }
@@ -0,0 +1,46 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Avatar } from "@shared/components/Avatar";
const meta: Meta<typeof Avatar> = {
title: "Primitives/Avatar",
component: Avatar,
tags: ["autodocs"],
parameters: { layout: "centered" },
args: { name: "Harper Lee", size: "md", tone: "blue" },
argTypes: {
size: { control: "inline-radio", options: ["xs", "sm", "md", "lg"] },
tone: {
control: "inline-radio",
options: ["blue", "purple", "green", "amber", "red", "neutral"],
},
onClick: { action: "clicked" },
},
};
export default meta;
type Story = StoryObj<typeof Avatar>;
/** Flip name / size / tone / interactive in controls. */
export const Playground: Story = {};
export const SizeRow: Story = {
render: () => (
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
<Avatar name="A B" size="xs" />
<Avatar name="A B" size="sm" />
<Avatar name="A B" size="md" />
<Avatar name="A B" size="lg" />
</div>
),
};
export const ToneRow: Story = {
render: () => (
<div style={{ display: "flex", gap: 8 }}>
{(["blue", "purple", "green", "amber", "red", "neutral"] as const).map(
(t) => (
<Avatar key={t} name={t[0].toUpperCase()} tone={t} />
),
)}
</div>
),
};
+79
View File
@@ -0,0 +1,79 @@
import "@shared/components/Avatar.css";
export type AvatarSize = "xs" | "sm" | "md" | "lg";
export type AvatarTone =
| "blue"
| "purple"
| "green"
| "amber"
| "red"
| "neutral";
export interface AvatarProps {
/** Image source. Falls back to initials when missing or load fails. */
src?: string;
/** Full name. Initials are derived from the first letter of each word, max 2. */
name: string;
size?: AvatarSize;
/** Background tone when rendering initials. Defaults to blue. */
tone?: AvatarTone;
/** Optional click handler — renders as a button when supplied. */
onClick?: () => void;
ariaLabel?: string;
className?: string;
}
function initialsOf(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "?";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
/**
* Round avatar showing either an image or coloured initials. Used by the
* Header account button, reviewer rows, and assistant message gutters.
*/
export function Avatar({
src,
name,
size = "md",
tone = "blue",
onClick,
ariaLabel,
className,
}: AvatarProps) {
const classes = [
"sui-avatar",
`sui-avatar--${size}`,
`sui-avatar--${tone}`,
onClick ? "sui-avatar--interactive" : "",
className ?? "",
]
.filter(Boolean)
.join(" ");
const content = src ? (
<img src={src} alt={ariaLabel ?? name} className="sui-avatar__img" />
) : (
<span aria-hidden>{initialsOf(name)}</span>
);
if (onClick) {
return (
<button
type="button"
className={classes}
onClick={onClick}
aria-label={ariaLabel ?? name}
>
{content}
</button>
);
}
return (
<span className={classes} role="img" aria-label={ariaLabel ?? name}>
{content}
</span>
);
}
+49
View File
@@ -0,0 +1,49 @@
.sui-banner {
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
border: 1px solid transparent;
font-size: 0.8125rem;
line-height: 1.5;
}
.sui-banner--info { background: var(--color-blue-light); color: var(--color-text-2); border-color: var(--color-blue-border); }
.sui-banner--success { background: var(--color-green-light); color: var(--color-text-2); border-color: var(--color-green-border); }
.sui-banner--warning { background: var(--color-amber-light); color: var(--color-text-2); border-color: var(--color-amber-border); }
.sui-banner--danger { background: var(--color-red-light); color: var(--color-text-2); border-color: var(--color-red-border); }
.sui-banner--neutral { background: var(--color-bg-subtle); color: var(--color-text-2); border-color: var(--color-border); }
.sui-banner__icon {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
}
.sui-banner--info .sui-banner__icon { color: var(--color-blue); }
.sui-banner--success .sui-banner__icon { color: var(--color-green); }
.sui-banner--warning .sui-banner__icon { color: var(--color-amber-dark); }
.sui-banner--danger .sui-banner__icon { color: var(--color-red); }
.sui-banner__body { flex: 1 1 auto; min-width: 0; }
.sui-banner__title { font-weight: 600; color: var(--color-text-1); }
.sui-banner__desc { color: var(--color-text-3); margin-top: 0.125rem; }
.sui-banner__action { flex: 0 0 auto; display: flex; align-items: center; }
.sui-banner__close {
flex: 0 0 auto;
width: 1.5rem; height: 1.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
color: var(--color-text-4);
font-size: 1rem;
line-height: 1;
}
.sui-banner__close:hover { background: rgba(0, 0, 0, 0.06); color: var(--color-text-1); }
@@ -0,0 +1,70 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Banner } from "@shared/components/Banner";
import { Button } from "@shared/components/Button";
const meta: Meta<typeof Banner> = {
title: "Primitives/Banner",
component: Banner,
tags: ["autodocs"],
parameters: { layout: "padded" },
args: {
tone: "info",
title: "Heads up",
description: "Storage quota approaching 80%.",
},
argTypes: {
tone: {
control: "inline-radio",
options: ["info", "success", "warning", "danger", "neutral"],
},
onDismiss: { action: "dismissed" },
},
decorators: [
(S) => (
<div style={{ maxWidth: "40rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof Banner>;
/** Flip tone / title / description / action / onDismiss in controls. */
export const Playground: Story = {};
export const WithAction: Story = {
args: {
tone: "warning",
title: "Approaching cap",
description: "389k of 500k docs processed.",
action: (
<Button size="sm" variant="outline" accent="amber">
Upgrade
</Button>
),
},
};
export const ToneMatrix: Story = {
render: () => (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<Banner tone="info" title="Info" description="Just so you know." />
<Banner
tone="success"
title="Deployed"
description="Prior Auth v3.1.0 is live in us-east-1."
/>
<Banner
tone="warning"
title="Schema drift"
description="12 docs in 1h didn't match — confidence ↓ 0.07."
/>
<Banner
tone="danger"
title="Pipeline run failed"
description="8% error rate — 14 docs sent to review queue."
/>
</div>
),
};
+61
View File
@@ -0,0 +1,61 @@
import type { ReactNode } from "react";
import "@shared/components/Banner.css";
export type BannerTone = "info" | "success" | "warning" | "danger" | "neutral";
export interface BannerProps {
tone?: BannerTone;
title?: ReactNode;
description?: ReactNode;
/** Right-aligned action — typically a button. */
action?: ReactNode;
/** When set, shows an × button that calls this handler. */
onDismiss?: () => void;
/** Optional leading icon (caller supplies — keeps the primitive icon-set-agnostic). */
icon?: ReactNode;
className?: string;
children?: ReactNode;
}
/**
* Inline alert. Use `tone` to convey severity; pair with `action` for
* "Approaching cap → Upgrade" style flows. Use `Toast` (separate primitive)
* for transient/dismissible notifications layered over the UI.
*/
export function Banner({
tone = "info",
title,
description,
action,
onDismiss,
icon,
className,
children,
}: BannerProps) {
return (
<div
role="status"
className={["sui-banner", `sui-banner--${tone}`, className ?? ""]
.filter(Boolean)
.join(" ")}
>
{icon && <div className="sui-banner__icon" aria-hidden>{icon}</div>}
<div className="sui-banner__body">
{title && <div className="sui-banner__title">{title}</div>}
{description && <div className="sui-banner__desc">{description}</div>}
{children}
</div>
{action && <div className="sui-banner__action">{action}</div>}
{onDismiss && (
<button
type="button"
className="sui-banner__close"
onClick={onDismiss}
aria-label="Dismiss"
>
×
</button>
)}
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
.sui-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
border: 1px solid transparent;
border-radius: var(--radius-md);
font-family: var(--font-sans);
font-weight: 500;
cursor: pointer;
transition: transform var(--motion-fast), box-shadow var(--motion-fast),
background var(--motion-fast), color var(--motion-fast), border-color var(--motion-fast);
white-space: nowrap;
user-select: none;
}
.sui-btn:disabled { cursor: not-allowed; opacity: 0.55; }
.sui-btn:focus-visible {
outline: 2px solid var(--color-blue);
outline-offset: 2px;
}
/* sizes */
.sui-btn--md { font-size: 0.8125rem; padding: 0.4375rem 0.875rem; min-height: 2rem; }
.sui-btn--sm { font-size: 0.75rem; padding: 0.3125rem 0.625rem; min-height: 1.625rem; border-radius: var(--radius-sm); }
.sui-btn--full { width: 100%; justify-content: center; }
/* gradient (primary) */
.sui-btn--gradient {
color: #fff;
background: var(--grad-blue-btn);
box-shadow: var(--shadow-blue);
}
.sui-btn--gradient.sui-btn--purple { background: var(--grad-purple-btn); }
.sui-btn--gradient.sui-btn--green { background: var(--grad-green-btn); }
.sui-btn--gradient:hover:not(:disabled) {
box-shadow: var(--shadow-blue-hover);
transform: translateY(-0.0625rem);
}
.sui-btn--gradient:active:not(:disabled) { transform: translateY(0); }
/* outline (secondary) */
.sui-btn--outline {
color: var(--color-text-2);
background: var(--color-surface);
border-color: var(--color-border);
}
.sui-btn--outline.sui-btn--blue { color: var(--color-blue); border-color: var(--color-blue-border); }
.sui-btn--outline.sui-btn--purple { color: var(--color-purple); border-color: var(--color-purple-border); }
.sui-btn--outline.sui-btn--green { color: var(--color-green); border-color: var(--color-green-border); }
.sui-btn--outline.sui-btn--amber { color: var(--color-amber); border-color: var(--color-amber-border); }
.sui-btn--outline.sui-btn--red { color: var(--color-red); border-color: var(--color-red-border); }
.sui-btn--outline:hover:not(:disabled) {
background: var(--color-bg-hover);
border-color: var(--color-border-hover);
}
/* ghost (tertiary) */
.sui-btn--ghost {
color: var(--color-text-3);
background: transparent;
}
.sui-btn--ghost:hover:not(:disabled) { color: var(--color-text-1); background: var(--color-bg-hover); }
/* spinner */
.sui-btn__spinner {
width: 0.875rem; height: 0.875rem;
border-radius: 50%;
border: 2px solid currentColor;
border-right-color: transparent;
animation: spin 0.7s linear infinite;
}
.sui-btn__label { display: inline-block; }
@@ -0,0 +1,45 @@
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from '@shared/components/Button';
const meta: Meta<typeof Button> = {
title: 'Primitives/Button',
component: Button,
parameters: { layout: 'centered' },
args: { children: 'Connect agent' },
argTypes: {
variant: { control: 'inline-radio', options: ['gradient', 'outline', 'ghost'] },
accent: { control: 'inline-radio', options: ['blue', 'purple', 'green', 'amber', 'red'] },
size: { control: 'inline-radio', options: ['sm', 'md'] },
},
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Gradient: Story = { args: { variant: 'gradient' } };
export const Outline: Story = { args: { variant: 'outline' } };
export const Ghost: Story = { args: { variant: 'ghost' } };
export const WithTrailingArrow: Story = {
args: {
variant: 'gradient',
children: 'Build a pipeline',
trailingIcon: <span aria-hidden>→</span>,
},
};
export const Loading: Story = { args: { variant: 'gradient', loading: true } };
export const Disabled: Story = { args: { variant: 'gradient', disabled: true } };
export const AccentMatrix: Story = {
render: () => (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{(['gradient', 'outline'] as const).flatMap((variant) =>
(['blue', 'purple', 'green', 'amber', 'red'] as const).map((accent) => (
<Button key={`${variant}-${accent}`} variant={variant} accent={accent}>
{variant} · {accent}
</Button>
)),
)}
</div>
),
};
+64
View File
@@ -0,0 +1,64 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import '@shared/components/Button.css';
export type ButtonVariant = 'gradient' | 'outline' | 'ghost';
export type ButtonSize = 'sm' | 'md';
export type ButtonAccent = 'blue' | 'purple' | 'green' | 'amber' | 'red';
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/** Visual variant. `gradient` is the primary CTA; `outline` is secondary; `ghost` is tertiary/text. */
variant?: ButtonVariant;
/** Optional accent colour. Currently affects `gradient` and `outline` variants. */
accent?: ButtonAccent;
size?: ButtonSize;
/** Icon node rendered before the label. */
leadingIcon?: ReactNode;
/** Icon node rendered after the label (arrow → for "next" CTAs). */
trailingIcon?: ReactNode;
/** Show a spinner and disable interactivity. */
loading?: boolean;
/** Stretches to fill its parent container. */
fullWidth?: boolean;
children?: ReactNode;
}
/**
* Stirling's two-button system: a gradient primary CTA and an outlined
* secondary action. Ghost is reserved for tertiary links inside a row.
*
* Buttons compose with the existing CSS variables — they re-skin cleanly in
* dark mode without per-variant overrides.
*/
export function Button({
variant = 'gradient',
accent = 'blue',
size = 'md',
leadingIcon,
trailingIcon,
loading = false,
fullWidth = false,
disabled,
className,
children,
...rest
}: ButtonProps) {
const classes = [
'sui-btn',
`sui-btn--${variant}`,
`sui-btn--${accent}`,
`sui-btn--${size}`,
fullWidth ? 'sui-btn--full' : '',
loading ? 'sui-btn--loading' : '',
className ?? '',
]
.filter(Boolean)
.join(' ');
return (
<button {...rest} disabled={disabled || loading} className={classes}>
{loading ? <span className="sui-btn__spinner" aria-hidden /> : leadingIcon}
{children && <span className="sui-btn__label">{children}</span>}
{!loading && trailingIcon}
</button>
);
}
+36
View File
@@ -0,0 +1,36 @@
.sui-card {
position: relative;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
transition: box-shadow var(--motion-fast), border-color var(--motion-fast), transform var(--motion-fast);
}
.sui-card--pad-none { padding: 0; overflow: hidden; }
.sui-card--pad-tight { padding: 0.75rem; }
.sui-card--pad-default { padding: 1.125rem 1.25rem; }
.sui-card--pad-loose { padding: 1.5rem 1.75rem; }
.sui-card--interactive { cursor: pointer; }
.sui-card--interactive:hover {
border-color: var(--color-border-hover);
box-shadow: var(--shadow-lg);
transform: translateY(-0.0625rem);
}
.sui-card--accent-blue::before,
.sui-card--accent-purple::before,
.sui-card--accent-green::before,
.sui-card--accent-amber::before,
.sui-card--accent-red::before {
content: '';
position: absolute;
top: 0; left: 0; bottom: 0;
width: 0.25rem;
border-radius: var(--radius-lg) 0 0 var(--radius-lg);
}
.sui-card--accent-blue::before { background: var(--color-blue); }
.sui-card--accent-purple::before { background: var(--color-purple); }
.sui-card--accent-green::before { background: var(--color-green); }
.sui-card--accent-amber::before { background: var(--color-amber); }
.sui-card--accent-red::before { background: var(--color-red); }
@@ -0,0 +1,189 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Card } from "@shared/components/Card";
import { Button } from "@shared/components/Button";
import { MetricCard } from "@shared/components/MetricCard";
import { StatusBadge } from "@shared/components/StatusBadge";
function CardBody({ color }: { color: string }) {
return (
<div>
<h3 style={{ margin: 0, fontSize: 14, color: "var(--color-text-1)" }}>
{color} card
</h3>
<p
style={{
margin: "4px 0 0",
fontSize: 12,
color: "var(--color-text-3)",
}}
>
Surface treatment with the {color} accent strip.
</p>
</div>
);
}
const meta: Meta<typeof Card> = {
title: "Primitives/Card",
component: Card,
tags: ["autodocs"],
parameters: { layout: "padded" },
args: {
accent: undefined,
padding: "default",
interactive: false,
},
argTypes: {
accent: {
control: "inline-radio",
options: [undefined, "blue", "purple", "green", "amber", "red"],
},
padding: {
control: "inline-radio",
options: ["tight", "default", "loose"],
},
interactive: { control: "boolean" },
},
decorators: [
(S) => (
<div style={{ width: "20rem" }}>
<S />
</div>
),
],
render: (args) => (
<Card {...args}>
<CardBody color={args.accent ?? "neutral"} />
</Card>
),
};
export default meta;
type Story = StoryObj<typeof Card>;
/** Flip accent / padding / interactive in controls. */
export const Playground: Story = {};
export const AccentMatrix: Story = {
decorators: [
(S) => (
<div style={{ width: "100%" }}>
<S />
</div>
),
],
render: () => (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: 12,
}}
>
{(["blue", "purple", "green", "amber", "red", undefined] as const).map(
(accent) => (
<Card key={accent ?? "none"} accent={accent}>
<CardBody color={accent ?? "neutral"} />
</Card>
),
)}
</div>
),
};
export const InContext_ProductGrid: Story = {
decorators: [
(S) => (
<div style={{ width: "100%" }}>
<S />
</div>
),
],
render: () => (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: 14,
}}
>
<Card accent="purple" padding="loose" interactive>
<h3 style={{ margin: 0, fontSize: 16 }}>Sources</h3>
<p
style={{
margin: "6px 0 14px",
fontSize: 13,
color: "var(--color-text-3)",
}}
>
Attach pipelines where PDFs already live.
</p>
<Button variant="outline" accent="purple" size="sm">
Connect a source
</Button>
</Card>
<Card accent="blue" padding="loose" interactive>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 6,
}}
>
<h3 style={{ margin: 0, fontSize: 16 }}>Pipelines</h3>
<StatusBadge tone="info" size="sm">
Hero
</StatusBadge>
</div>
<p
style={{
margin: "0 0 14px",
fontSize: 13,
color: "var(--color-text-3)",
}}
>
Compose document workflows from typed operations.
</p>
<Button variant="outline" accent="blue" size="sm">
Build a pipeline
</Button>
</Card>
<Card accent="purple" padding="loose" interactive>
<h3 style={{ margin: 0, fontSize: 16 }}>Agents</h3>
<p
style={{
margin: "6px 0 14px",
fontSize: 13,
color: "var(--color-text-3)",
}}
>
Wire your agent via MCP, REST, or tool definitions.
</p>
<Button variant="outline" accent="purple" size="sm">
Connect an agent
</Button>
</Card>
</div>
),
};
export const InContext_MetricsInsideCard: Story = {
args: { padding: "loose" },
render: (args) => (
<Card {...args}>
<h3 style={{ margin: "0 0 12px", fontSize: 14 }}>Last 24 hours</h3>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 8,
}}
>
<MetricCard label="Docs" value={"1,287"} />
<MetricCard label="Errors" value={"0.4%"} />
<MetricCard label="P95" value="412ms" />
<MetricCard label="Uptime" value="99.99%" />
</div>
</Card>
),
};
+45
View File
@@ -0,0 +1,45 @@
import type { HTMLAttributes, ReactNode } from 'react';
import '@shared/components/Card.css';
export interface CardProps extends HTMLAttributes<HTMLDivElement> {
/** Adds an accent strip on the left edge of the card. */
accent?: 'blue' | 'purple' | 'green' | 'amber' | 'red';
/**
* Padding profile. `tight` = 0.75rem, `default` = 1.125rem, `loose` = 1.5rem.
* `none` removes padding so the card surface can host edge-to-edge content
* (e.g. a list with row dividers).
*/
padding?: 'none' | 'tight' | 'default' | 'loose';
/** Use the lifted surface treatment (taller shadow, hover affordance). */
interactive?: boolean;
children?: ReactNode;
}
/**
* Generic surface — the prototype's cardStyle / cardStyleSm helper, lifted
* into a primitive. Any list item, panel, or detail card that needs the
* standard surface treatment composes from here.
*/
export function Card({
accent,
padding = 'default',
interactive = false,
className,
children,
...rest
}: CardProps) {
return (
<div
{...rest}
className={[
'sui-card',
`sui-card--pad-${padding}`,
accent ? `sui-card--accent-${accent}` : '',
interactive ? 'sui-card--interactive' : '',
className ?? '',
].filter(Boolean).join(' ')}
>
{children}
</div>
);
}
@@ -0,0 +1,69 @@
.sui-check {
display: inline-flex;
align-items: flex-start;
gap: var(--space-2);
cursor: pointer;
font-size: 0.8125rem;
color: var(--color-text-2);
}
.sui-check--disabled { opacity: 0.5; cursor: not-allowed; }
.sui-check__input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.sui-check__box {
flex: 0 0 auto;
width: 1rem;
height: 1rem;
margin-top: 0.0625rem;
border-radius: var(--radius-xs);
border: 1.5px solid var(--color-border-hover);
background: var(--color-surface);
display: inline-flex;
align-items: center;
justify-content: center;
transition: background var(--motion-fast), border-color var(--motion-fast);
position: relative;
}
.sui-check__input:focus-visible + .sui-check__box {
outline: 2px solid var(--color-blue);
outline-offset: 2px;
}
.sui-check__input:checked + .sui-check__box {
background: var(--color-blue);
border-color: var(--color-blue);
color: #fff;
}
.sui-check__tick {
opacity: 0;
}
.sui-check__input:checked + .sui-check__box .sui-check__tick {
opacity: 1;
}
.sui-check--mixed .sui-check__box {
background: var(--color-blue);
border-color: var(--color-blue);
}
.sui-check--mixed .sui-check__tick { opacity: 0; }
.sui-check--mixed .sui-check__dash {
position: absolute;
width: 0.5rem;
height: 2px;
background: #fff;
border-radius: 1px;
}
.sui-check__dash { display: none; }
.sui-check--mixed .sui-check__dash { display: block; }
.sui-check__text { display: flex; flex-direction: column; }
.sui-check__label { color: var(--color-text-1); font-weight: 500; }
.sui-check__desc { font-size: 0.75rem; color: var(--color-text-4); }
@@ -0,0 +1,67 @@
import { forwardRef, type InputHTMLAttributes, type ReactNode } from "react";
import "@shared/components/Checkbox.css";
export interface CheckboxProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "size"> {
label?: ReactNode;
description?: ReactNode;
/** Tri-state checkbox visual (still focusable + submittable, but renders the dash). */
indeterminate?: boolean;
}
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
function Checkbox(
{ label, description, indeterminate, className, id, ...rest },
ref,
) {
return (
<label
className={[
"sui-check",
rest.disabled ? "sui-check--disabled" : "",
indeterminate ? "sui-check--mixed" : "",
className ?? "",
]
.filter(Boolean)
.join(" ")}
htmlFor={id}
>
<input
ref={(el) => {
if (typeof ref === "function") ref(el);
else if (ref) (ref as React.MutableRefObject<HTMLInputElement | null>).current = el;
if (el) el.indeterminate = !!indeterminate;
}}
type="checkbox"
id={id}
className="sui-check__input"
{...rest}
/>
<span className="sui-check__box" aria-hidden>
<svg
viewBox="0 0 16 16"
width="10"
height="10"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
className="sui-check__tick"
>
<polyline points="3 8.5 7 12 13 4" />
</svg>
<span className="sui-check__dash" />
</span>
{(label || description) && (
<span className="sui-check__text">
{label && <span className="sui-check__label">{label}</span>}
{description && (
<span className="sui-check__desc">{description}</span>
)}
</span>
)}
</label>
);
},
);
+47
View File
@@ -0,0 +1,47 @@
.sui-chip {
display: inline-flex;
align-items: center;
gap: var(--space-1_5);
border-radius: var(--radius-pill);
font-family: var(--font-sans);
font-weight: 500;
white-space: nowrap;
border: 1px solid transparent;
transition: background var(--motion-fast), border-color var(--motion-fast), transform var(--motion-fast);
}
.sui-chip--sm { padding: var(--space-0_5) var(--space-2); font-size: 0.6875rem; }
.sui-chip--md { padding: var(--space-1) var(--space-2_5, 0.5rem); font-size: 0.75rem; }
.sui-chip--md { padding: var(--space-1) 0.625rem; font-size: 0.75rem; }
.sui-chip--neutral { background: var(--color-bg-muted); color: var(--color-text-2); border-color: var(--color-border); }
.sui-chip--blue { background: var(--color-blue-light); color: var(--color-blue); border-color: var(--color-blue-border); }
.sui-chip--purple { background: var(--color-purple-light); color: var(--color-purple); border-color: var(--color-purple-border); }
.sui-chip--green { background: var(--color-green-light); color: var(--color-green); border-color: var(--color-green-border); }
.sui-chip--amber { background: var(--color-amber-light); color: var(--color-amber-dark); border-color: var(--color-amber-border); }
.sui-chip--red { background: var(--color-red-light); color: var(--color-red); border-color: var(--color-red-border); }
.sui-chip--interactive { cursor: pointer; }
.sui-chip--interactive:hover { transform: translateY(-0.0625rem); filter: brightness(1.05); }
.sui-chip__dot {
width: 0.4375rem; height: 0.4375rem;
border-radius: 50%;
background: currentColor;
opacity: 0.85;
}
.sui-chip__icon { display: inline-flex; line-height: 1; }
.sui-chip__remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem; height: 1rem;
border-radius: 50%;
color: inherit;
opacity: 0.6;
font-size: 0.875rem;
line-height: 1;
}
.sui-chip__remove:hover { opacity: 1; background: rgba(0, 0, 0, 0.08); }
@@ -0,0 +1,79 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Chip } from "@shared/components/Chip";
const meta: Meta<typeof Chip> = {
title: "Primitives/Chip",
component: Chip,
tags: ["autodocs"],
parameters: { layout: "centered" },
args: {
children: "us-east-1",
tone: "neutral",
size: "md",
showDot: false,
},
argTypes: {
tone: {
control: "inline-radio",
options: ["neutral", "blue", "purple", "green", "amber", "red"],
},
size: { control: "inline-radio", options: ["sm", "md"] },
showDot: { control: "boolean" },
onClick: { action: "clicked" },
onRemove: { action: "removed" },
},
};
export default meta;
type Story = StoryObj<typeof Chip>;
/** Flip tone / size / dot / interactive / removable in controls. */
export const Playground: Story = {};
export const ToneRow: Story = {
render: () => (
<div style={{ display: "flex", gap: 8 }}>
{(["neutral", "blue", "purple", "green", "amber", "red"] as const).map(
(t) => (
<Chip key={t} tone={t}>
{t}
</Chip>
),
)}
</div>
),
};
export const InContext_OpChain: Story = {
render: () => (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 6,
maxWidth: "32rem",
}}
>
<Chip tone="green" showDot>
ocr
</Chip>
<Chip tone="green" showDot>
classify
</Chip>
<Chip tone="green" showDot>
extract
</Chip>
<Chip tone="blue" showDot>
validate
</Chip>
<Chip tone="red" showDot>
redact
</Chip>
<Chip tone="red" showDot>
encrypt-rest
</Chip>
<Chip tone="purple" showDot>
store-primary
</Chip>
</div>
),
};
+88
View File
@@ -0,0 +1,88 @@
import type { ReactNode } from "react";
import "@shared/components/Chip.css";
export type ChipTone =
| "neutral"
| "blue"
| "purple"
| "green"
| "amber"
| "red";
export type ChipSize = "sm" | "md";
export interface ChipProps {
tone?: ChipTone;
size?: ChipSize;
leadingIcon?: ReactNode;
trailingIcon?: ReactNode;
/** Show a `×` button. Calls `onRemove` when clicked. */
onRemove?: () => void;
/** Renders as a button when set. */
onClick?: () => void;
/** Show the leading dot affordance. Defaults to false (set true for status-style chips). */
showDot?: boolean;
children?: ReactNode;
className?: string;
}
/**
* Generic chip / tag — `StatusBadge` has a fixed taxonomy (success/warning/…),
* `MethodBadge` is HTTP-method-only; this is the open-ended one for tag rows
* (selected ops, document regions, kbd hints, sort chips, etc).
*/
export function Chip({
tone = "neutral",
size = "md",
leadingIcon,
trailingIcon,
onRemove,
onClick,
showDot,
children,
className,
}: ChipProps) {
const Tag = onClick ? "button" : "span";
const classes = [
"sui-chip",
`sui-chip--${tone}`,
`sui-chip--${size}`,
onClick ? "sui-chip--interactive" : "",
className ?? "",
]
.filter(Boolean)
.join(" ");
return (
<Tag
className={classes}
onClick={onClick}
type={onClick ? "button" : undefined}
>
{showDot && <span className="sui-chip__dot" aria-hidden />}
{leadingIcon && (
<span className="sui-chip__icon" aria-hidden>
{leadingIcon}
</span>
)}
<span className="sui-chip__label">{children}</span>
{trailingIcon && !onRemove && (
<span className="sui-chip__icon" aria-hidden>
{trailingIcon}
</span>
)}
{onRemove && (
<button
type="button"
className="sui-chip__remove"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
aria-label="Remove"
>
×
</button>
)}
</Tag>
);
}
@@ -0,0 +1,51 @@
.sui-code {
background: var(--code-bg);
color: var(--code-text);
border-radius: var(--radius-lg);
overflow: hidden;
border: 1px solid var(--code-border);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
}
.sui-code__chrome {
display: flex; align-items: center; gap: 0.625rem;
background: var(--code-bg-header);
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--code-border);
font-size: 0.6875rem;
color: var(--code-muted);
}
.sui-code__dots { display: inline-flex; gap: 0.375rem; align-items: center; }
.sui-code__dots > span {
width: 0.625rem; height: 0.625rem; border-radius: 50%;
background: var(--code-dot);
}
.sui-code__caption { color: var(--code-muted); margin-left: 0.25rem; font-family: var(--font-mono); }
.sui-code__lang {
margin-left: auto;
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--code-dim);
}
.sui-code__copy {
background: transparent;
color: var(--code-text);
border: 1px solid var(--code-border);
border-radius: var(--radius-sm);
padding: 0.125rem 0.5rem;
font-size: 0.6875rem;
cursor: pointer;
font-family: var(--font-sans);
transition: background var(--motion-fast);
}
.sui-code__copy:hover { background: rgba(255, 255, 255, 0.05); }
.sui-code__pre {
margin: 0;
padding: 0.875rem 1rem;
font-family: var(--font-mono);
font-size: 0.78125rem;
line-height: 1.55;
overflow: auto;
white-space: pre;
}
.sui-code__pre code { white-space: inherit; color: inherit; font-family: inherit; }

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