# Description of Changes

## What & why

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

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

## What's in this PR

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

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

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

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

## Testing done locally

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

## Notes for reviewers

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

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Reece Browne
2026-06-02 16:08:24 +00:00
committed by GitHub
co-authored by Claude Opus 4.8
parent b355ccec9e
commit 919f0ade99
201 changed files with 19912 additions and 43 deletions
@@ -0,0 +1,19 @@
.portal-shell {
display: flex;
min-height: 100vh;
background: var(--color-bg);
color: var(--color-text-2);
}
.portal-shell__main {
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-width: 0; /* prevent grid blowout on narrow content */
}
.portal-shell__view {
flex: 1 1 auto;
overflow-y: auto;
animation: fadeInUp var(--motion-enter) both;
}
@@ -0,0 +1,21 @@
import type { ReactNode } from "react";
import { Sidebar } from "@portal/components/Sidebar";
import { Header } from "@portal/components/Header";
import "@portal/components/AppShell.css";
/**
* Two-column layout: fixed-width sidebar on the left, sticky header + scrolling
* main column on the right. The Sidebar and Header read their state from
* context, so this shell stays prop-free.
*/
export function AppShell({ children }: { children: ReactNode }) {
return (
<div className="portal-shell">
<Sidebar />
<div className="portal-shell__main">
<Header />
<main className="portal-shell__view">{children}</main>
</div>
</div>
);
}
@@ -0,0 +1,35 @@
.portal-assistant-btn {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
width: 3rem;
height: 3rem;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
background: linear-gradient(
135deg,
var(--color-blue) 0%,
var(--color-purple) 100%
);
box-shadow:
0 0.5rem 1.25rem rgba(59, 130, 246, 0.35),
inset 0 0.0625rem 0 rgba(255, 255, 255, 0.2);
transition:
transform var(--motion-base),
box-shadow var(--motion-base);
z-index: 40;
}
.portal-assistant-btn:hover {
transform: scale(1.08);
box-shadow:
0 0.75rem 1.75rem rgba(59, 130, 246, 0.45),
inset 0 0.0625rem 0 rgba(255, 255, 255, 0.25);
}
.portal-assistant-btn:active {
transform: scale(1.02);
}
@@ -0,0 +1,36 @@
import { useUI } from "@portal/contexts/UIContext";
import { SparklesIcon } from "@portal/components/icons";
import "@portal/components/AssistantButton.css";
export function AssistantButton() {
const { assistantOpen, toggleAssistant } = useUI();
return (
<button
type="button"
className={"portal-assistant-btn" + (assistantOpen ? " is-active" : "")}
onClick={toggleAssistant}
aria-label={assistantOpen ? "Close assistant" : "Open assistant"}
aria-expanded={assistantOpen}
title="Assistant"
>
{assistantOpen ? (
<svg
viewBox="0 0 24 24"
width="20"
height="20"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
) : (
<SparklesIcon size={22} />
)}
</button>
);
}
@@ -0,0 +1,189 @@
.portal-assistant {
position: fixed;
bottom: 5.5rem;
right: 1.5rem;
width: 23.75rem;
height: 32.5rem;
max-height: calc(100vh - 7rem);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
overflow: hidden;
z-index: 35;
animation: fadeInUp var(--motion-enter) both;
}
.portal-assistant__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.625rem 0.875rem;
color: var(--color-text-on-accent);
background: linear-gradient(
135deg,
var(--color-blue) 0%,
var(--color-purple) 100%
);
}
.portal-assistant__header-left {
display: flex;
align-items: center;
gap: 0.5rem;
}
.portal-assistant__title {
font-size: 0.875rem;
font-weight: 600;
}
.portal-assistant__close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: var(--radius-sm);
color: rgba(255, 255, 255, 0.85);
transition:
background var(--motion-fast),
color var(--motion-fast);
}
.portal-assistant__close:hover {
background: rgba(255, 255, 255, 0.15);
color: var(--color-text-on-accent);
}
.portal-assistant__messages {
flex: 1 1 auto;
overflow-y: auto;
padding: 0.875rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
background: var(--color-bg-subtle);
}
.portal-assistant__suggestions {
margin-bottom: 0.25rem;
}
.portal-assistant__suggestions-eyebrow {
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-section-label);
margin-bottom: 0.5rem;
}
.portal-assistant__suggestions-list {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.portal-assistant__suggestion {
padding: 0.4375rem 0.625rem;
font-size: 0.8125rem;
text-align: left;
color: var(--color-text-2);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
transition:
border-color var(--motion-fast),
background var(--motion-fast);
}
.portal-assistant__suggestion:hover {
background: var(--color-bg-hover);
border-color: var(--color-blue-border);
}
.portal-assistant__bubble {
max-width: 80%;
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
line-height: 1.5;
border-radius: var(--radius-md);
animation: fadeInUp var(--motion-enter) both;
word-wrap: break-word;
}
.portal-assistant__bubble--user {
align-self: flex-end;
background: var(--color-blue);
color: var(--color-text-on-accent);
border-bottom-right-radius: 0.25rem;
}
.portal-assistant__bubble--assistant {
align-self: flex-start;
background: var(--color-surface);
color: var(--color-text-2);
border: 1px solid var(--color-border);
border-bottom-left-radius: 0.25rem;
}
.portal-assistant__typing {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.portal-assistant__typing span {
width: 0.375rem;
height: 0.375rem;
border-radius: 50%;
background: var(--color-text-5);
animation: pulse 1.2s ease-in-out infinite;
}
.portal-assistant__typing span:nth-child(2) {
animation-delay: 0.15s;
}
.portal-assistant__typing span:nth-child(3) {
animation-delay: 0.3s;
}
.portal-assistant__input-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 0.75rem;
border-top: 1px solid var(--color-border);
background: var(--color-surface);
}
.portal-assistant__input {
flex: 1 1 auto;
font: inherit;
font-size: 0.8125rem;
background: transparent;
border: 1px solid var(--color-border-input);
border-radius: var(--radius-md);
padding: 0.4375rem 0.625rem;
color: var(--color-text-1);
outline: none;
transition: border-color var(--motion-fast);
}
.portal-assistant__input:focus {
border-color: var(--color-blue);
}
.portal-assistant__send {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: var(--radius-md);
background: var(--grad-blue-btn);
color: var(--color-text-on-accent);
transition: opacity var(--motion-fast);
}
.portal-assistant__send:disabled {
opacity: 0.4;
cursor: not-allowed;
}
@@ -0,0 +1,59 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useEffect } from "react";
import { http, HttpResponse, delay } from "msw";
import { AssistantPanel } from "@portal/components/AssistantPanel";
import { useUI } from "@portal/contexts/UIContext";
function ForceOpen() {
const { openAssistant } = useUI();
useEffect(() => {
openAssistant();
}, [openAssistant]);
return null;
}
const meta: Meta<typeof AssistantPanel> = {
title: "Portal/Assistant/AssistantPanel",
component: AssistantPanel,
parameters: { layout: "fullscreen" },
decorators: [
(S) => (
<div style={{ minHeight: "100vh", background: "var(--color-bg)" }}>
<ForceOpen />
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof AssistantPanel>;
export const SuggestionsOnly: Story = {};
export const SlowReply: Story = {
parameters: {
msw: {
handlers: [
http.post("/v1/assistant/messages", async () => {
await delay(3000);
return HttpResponse.json({
reply:
"After a long pause: here's a deliberately slow reply to demo the typing indicator under real network latency.",
});
}),
],
},
},
};
export const ReplyFails: Story = {
parameters: {
msw: {
handlers: [
http.post("/v1/assistant/messages", () =>
HttpResponse.json({ error: "rate limit exceeded" }, { status: 429 }),
),
],
},
},
};
@@ -0,0 +1,169 @@
import { useEffect, useRef, useState } from "react";
import { useUI } from "@portal/contexts/UIContext";
import { useAsync } from "@portal/hooks/useAsync";
import {
fetchAssistantSuggestions,
getAssistantReply,
} from "@portal/api/assistant";
import { CloseIcon, SendIcon, SparklesIcon } from "@portal/components/icons";
import "@portal/components/AssistantPanel.css";
interface Message {
id: number;
role: "user" | "assistant";
text: string;
}
export function AssistantPanel() {
const { assistantOpen, closeAssistant } = useUI();
const { data: suggestions } = useAsync<readonly string[]>(
() => fetchAssistantSuggestions(),
[],
);
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [typing, setTyping] = useState(false);
const messagesRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const nextIdRef = useRef(1);
useEffect(() => {
if (assistantOpen) {
const t = setTimeout(() => inputRef.current?.focus(), 60);
return () => clearTimeout(t);
}
return undefined;
}, [assistantOpen]);
useEffect(() => {
messagesRef.current?.scrollTo({
top: messagesRef.current.scrollHeight,
behavior: "smooth",
});
}, [messages, typing]);
async function send(text: string) {
const trimmed = text.trim();
if (!trimmed || typing) return;
const userMsg: Message = {
id: nextIdRef.current++,
role: "user",
text: trimmed,
};
setMessages((prev) => [...prev, userMsg]);
setInput("");
setTyping(true);
try {
const reply = await getAssistantReply(trimmed);
const assistantMsg: Message = {
id: nextIdRef.current++,
role: "assistant",
text: reply,
};
setMessages((prev) => [...prev, assistantMsg]);
} catch (err) {
const failMsg: Message = {
id: nextIdRef.current++,
role: "assistant",
text:
err instanceof Error
? `Couldn't reach the assistant: ${err.message}`
: "Couldn't reach the assistant.",
};
setMessages((prev) => [...prev, failMsg]);
} finally {
setTyping(false);
}
}
if (!assistantOpen) return null;
return (
<aside className="portal-assistant" role="dialog" aria-label="Assistant">
<header className="portal-assistant__header">
<div className="portal-assistant__header-left">
<SparklesIcon size={16} />
<span className="portal-assistant__title">Assistant</span>
</div>
<button
type="button"
className="portal-assistant__close"
onClick={closeAssistant}
aria-label="Close assistant"
>
<CloseIcon size={16} />
</button>
</header>
<div className="portal-assistant__messages" ref={messagesRef}>
{messages.length === 0 && suggestions && (
<div className="portal-assistant__suggestions">
<div className="portal-assistant__suggestions-eyebrow">
Try asking
</div>
<div className="portal-assistant__suggestions-list">
{suggestions.map((s) => (
<button
key={s}
type="button"
className="portal-assistant__suggestion"
onClick={() => send(s)}
disabled={typing}
>
{s}
</button>
))}
</div>
</div>
)}
{messages.map((m) => (
<div
key={m.id}
className={
"portal-assistant__bubble portal-assistant__bubble--" + m.role
}
>
{m.text}
</div>
))}
{typing && (
<div className="portal-assistant__bubble portal-assistant__bubble--assistant">
<span className="portal-assistant__typing" aria-label="Typing">
<span />
<span />
<span />
</span>
</div>
)}
</div>
<form
className="portal-assistant__input-row"
onSubmit={(e) => {
e.preventDefault();
send(input);
}}
>
<input
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask about Stirling…"
aria-label="Ask the assistant"
className="portal-assistant__input"
disabled={typing}
/>
<button
type="submit"
className="portal-assistant__send"
disabled={!input.trim() || typing}
aria-label="Send"
>
<SendIcon size={14} />
</button>
</form>
</aside>
);
}
@@ -0,0 +1,227 @@
.portal-doctype {
display: flex;
flex-direction: column;
gap: 1rem;
}
.portal-doctype__head {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.portal-doctype__title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-doctype__sub {
margin: 0;
font-size: 0.8125rem;
color: var(--color-text-4);
}
/* Tabs */
.portal-doctype__tabs {
display: flex;
gap: 0.375rem;
flex-wrap: wrap;
}
.portal-doctype__tab {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.3125rem 0.625rem;
font-size: 0.75rem;
color: var(--color-text-3);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-pill);
transition:
border-color var(--motion-fast),
background var(--motion-fast),
color var(--motion-fast);
}
.portal-doctype__tab:hover {
background: var(--color-bg-hover);
color: var(--color-text-1);
}
.portal-doctype__tab.is-active {
color: var(--portal-tab-accent, var(--color-blue));
border-color: var(--portal-tab-accent, var(--color-blue));
background: var(--color-surface);
font-weight: 500;
}
.portal-doctype__tab-dot {
width: 0.4375rem;
height: 0.4375rem;
border-radius: 50%;
}
.portal-doctype__tab-count {
font-size: 0.6875rem;
color: var(--color-text-5);
}
/* Vertical groups */
.portal-doctype__groups {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.portal-doctype__group-head {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.625rem;
}
.portal-doctype__group-title {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text-2);
}
.portal-doctype__group-count {
font-size: 0.75rem;
color: var(--color-text-5);
}
.portal-doctype__scroller {
display: flex;
gap: 0.75rem;
overflow-x: auto;
padding-bottom: 0.5rem;
scroll-snap-type: x mandatory;
scrollbar-width: thin;
}
.portal-doctype__scroller::-webkit-scrollbar {
height: 0.375rem;
}
.portal-doctype__scroller::-webkit-scrollbar-thumb {
background: var(--color-border-hover);
border-radius: var(--radius-pill);
}
/* Cards */
.portal-doctype__card {
flex: 0 0 17rem;
display: flex;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
scroll-snap-align: start;
transition:
border-color var(--motion-fast),
box-shadow var(--motion-fast),
transform var(--motion-fast);
}
.portal-doctype__card:hover {
border-color: var(--color-border-hover);
box-shadow: var(--shadow-md);
transform: translateY(-0.0625rem);
}
.portal-doctype__card-accent {
width: 0.25rem;
flex-shrink: 0;
}
.portal-doctype__card-body {
flex: 1 1 auto;
padding: 0.875rem 1rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.portal-doctype__card-eyebrow {
font-size: 0.625rem;
font-weight: 600;
letter-spacing: 0.08em;
color: var(--color-text-5);
}
.portal-doctype__card-title {
margin: 0.125rem 0 0;
font-size: 0.9375rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-doctype__card-desc {
margin: 0;
font-size: 0.75rem;
line-height: 1.45;
color: var(--color-text-4);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.portal-doctype__card-meta {
margin-top: 0.5rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
font-size: 0.6875rem;
color: var(--color-text-4);
}
.portal-doctype__regions {
font-family: var(--font-mono);
}
.portal-doctype__regions-more {
color: var(--color-text-5);
}
.portal-doctype__card-cta {
margin-top: 0.5rem;
font-size: 0.75rem;
font-weight: 500;
text-decoration: none;
transition: opacity var(--motion-fast);
}
.portal-doctype__card-cta:hover {
opacity: 0.8;
}
.portal-doctype__skel,
.portal-doctype__card-skel {
border-radius: var(--radius-md);
background: linear-gradient(
90deg,
var(--color-bg-muted) 0%,
var(--color-bg-hover) 50%,
var(--color-bg-muted) 100%
);
background-size: 200% 100%;
animation: shimmer 1.4s linear infinite;
}
.portal-doctype__skel {
display: inline-block;
height: 0.75rem;
}
.portal-doctype__card-skel {
flex: 0 0 17rem;
height: 8rem;
border: 1px solid var(--color-border-light);
}
@@ -0,0 +1,41 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { http, HttpResponse, delay } from "msw";
import { DocumentTypeGrid } from "@portal/components/DocumentTypeGrid";
const meta: Meta<typeof DocumentTypeGrid> = {
title: "Portal/Home/DocumentTypeGrid",
component: DocumentTypeGrid,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "72rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof DocumentTypeGrid>;
export const Default: Story = {};
export const Loading: Story = {
parameters: {
msw: {
handlers: [
http.get("/v1/endpoints", async () => {
await delay("infinite");
return HttpResponse.json([]);
}),
],
},
},
};
export const Empty: Story = {
parameters: {
msw: {
handlers: [http.get("/v1/endpoints", () => HttpResponse.json([]))],
},
},
};
@@ -0,0 +1,181 @@
import { useState } from "react";
import {
EmptyState,
Skeleton,
StatusBadge,
Tabs,
type TabItem,
} from "@shared/components";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
import {
fetchVerticals,
type Endpoint,
type Vertical,
type VerticalKey,
} from "@portal/api/endpoints";
import "@portal/components/DocumentTypeGrid.css";
type ActiveTab = VerticalKey | "all";
const TIER_LABEL = ["Free", "Paid", "Enterprise"] as const;
const TIER_TONE = ["success", "info", "purple"] as const;
function tierBadge(tier: 0 | 1 | 2) {
return (
<StatusBadge tone={TIER_TONE[tier]} size="sm">
{TIER_LABEL[tier]}
</StatusBadge>
);
}
function EndpointCard({
endpoint,
vertical,
}: {
endpoint: Endpoint;
vertical: Vertical;
}) {
const visibleRegions = endpoint.regions.slice(0, 2);
const extraRegions = endpoint.regions.length - visibleRegions.length;
return (
<article className="portal-doctype__card">
<div
className="portal-doctype__card-accent"
style={{ background: vertical.color }}
aria-hidden
/>
<div className="portal-doctype__card-body">
<div className="portal-doctype__card-eyebrow">
{vertical.label.toUpperCase()}
</div>
<h3 className="portal-doctype__card-title">{endpoint.name}</h3>
<p className="portal-doctype__card-desc">{endpoint.desc}</p>
<div className="portal-doctype__card-meta">
<span className="portal-doctype__regions">
{visibleRegions.join(" · ")}
{extraRegions > 0 && (
<span className="portal-doctype__regions-more">
{" "}
+{extraRegions} more
</span>
)}
</span>
{tierBadge(endpoint.tier)}
</div>
<a
className="portal-doctype__card-cta"
style={{ color: vertical.color }}
href={`#${endpoint.endpoint}`}
onClick={(e) => e.preventDefault()}
>
Explore <span aria-hidden></span>
</a>
</div>
</article>
);
}
function GridSkeleton() {
return (
<div className="portal-doctype__groups" aria-hidden>
{Array.from({ length: 2 }).map((_, gi) => (
<div key={gi} className="portal-doctype__group">
<div className="portal-doctype__group-head">
<Skeleton shape="circle" width="0.4375rem" height="0.4375rem" />
<Skeleton width="6rem" />
</div>
<div className="portal-doctype__scroller">
{Array.from({ length: 4 }).map((_, ci) => (
<Skeleton key={ci} shape="rect" width="17rem" height="8rem" />
))}
</div>
</div>
))}
</div>
);
}
export function DocumentTypeGrid() {
const [tab, setTab] = useState<ActiveTab>("all");
const state = useAsync<Vertical[]>(() => fetchVerticals(), []);
const { data: verticals } = state;
const { isLoading, isEmpty } = useSectionFlags(state);
const hasVerticals = verticals !== null && verticals.length > 0;
const tabItems: TabItem<ActiveTab>[] = hasVerticals
? [
{ key: "all", label: "All" },
...verticals.map<TabItem<ActiveTab>>((v) => ({
key: v.key,
label: v.label,
count: v.endpoints.length,
accentColor: v.color,
dotColor: v.color,
})),
]
: [];
return (
<section className="portal-doctype" aria-label="Document types">
<header className="portal-doctype__head">
<h2 className="portal-doctype__title">Document types</h2>
<p className="portal-doctype__sub">
Typed endpoints across every supported vertical each carries a
schema, region availability and tier gate.
</p>
</header>
{hasVerticals && (
<Tabs<ActiveTab>
items={tabItems}
activeKey={tab}
onChange={setTab}
ariaLabel="Document type verticals"
/>
)}
{isLoading && <GridSkeleton />}
{isEmpty && (
<EmptyState
title="No document types yet"
description="When endpoints are registered they'll appear in this catalogue."
/>
)}
{hasVerticals && (
<div className="portal-doctype__groups">
{(tab === "all"
? verticals
: verticals.filter((v) => v.key === tab)
).map((v) => (
<div key={v.key} className="portal-doctype__group">
{tab === "all" && (
<div className="portal-doctype__group-head">
<span
className="portal-doctype__tab-dot"
style={{ background: v.color }}
aria-hidden
/>
<h3 className="portal-doctype__group-title">{v.label}</h3>
<span className="portal-doctype__group-count">
{v.endpoints.length} endpoints
</span>
</div>
)}
<div className="portal-doctype__scroller">
{v.endpoints.map((endpoint) => (
<EndpointCard
key={endpoint.endpoint}
endpoint={endpoint}
vertical={v}
/>
))}
</div>
</div>
))}
</div>
)}
</section>
);
}
+182
View File
@@ -0,0 +1,182 @@
.portal-header {
height: 3.25rem;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0 1rem;
background: var(--color-header-bg);
border-bottom: 1px solid var(--color-header-border);
position: sticky;
top: 0;
z-index: 10;
}
.portal-header__left {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 12.5rem;
}
.portal-header__breadcrumb {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-header-text);
}
/* Search trigger */
.portal-header__search {
flex: 0 1 20rem;
margin: 0 auto;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.625rem;
background: var(--color-search-bg);
border: 1px solid var(--color-search-border);
border-radius: var(--radius-md);
color: var(--color-search-text);
font-size: 0.75rem;
transition:
border-color var(--motion-fast),
background var(--motion-fast);
}
.portal-header__search:hover {
border-color: var(--color-search-border-hover);
}
.portal-header__search-placeholder {
flex: 1 1 auto;
text-align: left;
}
.portal-header__search-kbd {
font-family: var(--font-mono);
font-size: 0.6875rem;
padding: 0.0625rem 0.25rem;
border-radius: var(--radius-xs);
background: var(--color-bg-muted);
color: var(--color-text-4);
}
/* Right cluster */
.portal-header__right {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 0.375rem;
}
.portal-header__icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: var(--radius-md);
color: var(--color-bell-stroke, var(--color-text-3));
position: relative;
transition:
background var(--motion-fast),
color var(--motion-fast);
}
.portal-header__icon-btn:hover {
background: var(--color-bg-hover);
color: var(--color-text-1);
}
.portal-header__bell-dot {
position: absolute;
top: 0.375rem;
right: 0.4375rem;
width: 0.4375rem;
height: 0.4375rem;
border-radius: 50%;
background: var(--color-red);
border: 2px solid var(--color-header-bg);
box-sizing: content-box;
}
/* Tier switcher */
.portal-header__tier {
position: relative;
}
.portal-header__tier-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.3125rem 0.625rem;
font-size: 0.75rem;
color: var(--color-text-2);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
transition:
border-color var(--motion-fast),
background var(--motion-fast);
}
.portal-header__tier-btn:hover {
background: var(--color-bg-hover);
border-color: var(--color-border-hover);
}
.portal-header__tier-dot {
width: 0.4375rem;
height: 0.4375rem;
border-radius: 50%;
}
.portal-header__tier-label {
font-weight: 500;
}
.portal-header__tier-menu {
position: absolute;
top: calc(100% + 0.25rem);
right: 0;
min-width: 12rem;
margin: 0;
padding: 0.25rem;
list-style: none;
background: var(--color-dropdown-bg);
border: 1px solid var(--color-dropdown-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: 20;
animation: fadeInUp var(--motion-enter) both;
}
.portal-header__tier-option {
width: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4375rem 0.625rem;
font-size: 0.8125rem;
color: var(--color-text-2);
border-radius: var(--radius-sm);
text-align: left;
}
.portal-header__tier-option:hover {
background: var(--color-dropdown-hover);
}
.portal-header__tier-option.is-active {
background: var(--color-nav-active);
color: var(--color-nav-active-text);
}
/* Avatar */
.portal-header__avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 50%;
background: var(--grad-blue-btn);
color: #fff;
font-size: 0.8125rem;
font-weight: 600;
}
@@ -0,0 +1,26 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Header } from "@portal/components/Header";
const meta: Meta<typeof Header> = {
title: "Portal/Shell/Header",
component: Header,
parameters: { layout: "fullscreen" },
decorators: [
(S) => (
<div style={{ background: "var(--color-bg)" }}>
<S />
<div style={{ padding: "1.5rem", color: "var(--color-text-3)" }}>
Page body would render below the header.
</div>
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof Header>;
export const Default: Story = {};
export const FreeTier: Story = { globals: { tier: "free" } };
export const ProTier: Story = { globals: { tier: "pro" } };
export const EnterpriseTier: Story = { globals: { tier: "enterprise" } };
+104
View File
@@ -0,0 +1,104 @@
import { Avatar, Dropdown } from "@shared/components";
import { useTheme } from "@portal/contexts/ThemeContext";
import { useTier, TIER_INFO, type Tier } from "@portal/contexts/TierContext";
import { useView, VIEW_LABELS } from "@portal/contexts/ViewContext";
import { useUI } from "@portal/contexts/UIContext";
import {
SearchIcon,
SunIcon,
MoonIcon,
ChevronDownIcon,
} from "@portal/components/icons";
import { NotificationsDropdown } from "@portal/components/NotificationsDropdown";
import { MocksToggle } from "@portal/components/MocksToggle";
import "@portal/components/Header.css";
function ThemeToggle() {
const { theme, toggle } = useTheme();
return (
<button
type="button"
className="portal-header__icon-btn"
onClick={toggle}
aria-label={
theme === "light" ? "Switch to dark theme" : "Switch to light theme"
}
title={theme === "light" ? "Dark mode" : "Light mode"}
>
{theme === "light" ? <MoonIcon size={16} /> : <SunIcon size={16} />}
</button>
);
}
function TierSwitcher() {
const { tier, setTier } = useTier();
const info = TIER_INFO[tier];
return (
<Dropdown.Root align="end">
<Dropdown.Trigger>
<button type="button" className="portal-header__tier-btn">
<span
className="portal-header__tier-dot"
style={{ background: info.dotColor }}
aria-hidden
/>
<span className="portal-header__tier-label">{info.label}</span>
<ChevronDownIcon size={14} />
</button>
</Dropdown.Trigger>
<Dropdown.Menu width="12rem">
{(Object.keys(TIER_INFO) as Tier[]).map((id) => (
<Dropdown.Item
key={id}
active={tier === id}
onSelect={() => setTier(id)}
leading={
<span
className="portal-header__tier-dot"
style={{ background: TIER_INFO[id].dotColor }}
aria-hidden
/>
}
>
{TIER_INFO[id].label}
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown.Root>
);
}
export function Header() {
const { activeView } = useView();
const { openSearch } = useUI();
return (
<header className="portal-header">
<div className="portal-header__left">
<span className="portal-header__breadcrumb">
{VIEW_LABELS[activeView]}
</span>
</div>
<button
type="button"
className="portal-header__search"
aria-label="Search"
onClick={openSearch}
>
<SearchIcon size={14} />
<span className="portal-header__search-placeholder">Search</span>
<span className="portal-header__search-kbd" aria-hidden>
K
</span>
</button>
<div className="portal-header__right">
<MocksToggle />
<ThemeToggle />
<NotificationsDropdown />
<TierSwitcher />
<Avatar name="Reece" size="md" tone="blue" />
</div>
</header>
);
}
@@ -0,0 +1,51 @@
.portal-mocks-toggle {
display: inline-flex;
align-items: center;
gap: var(--space-1_5);
padding: var(--space-1) var(--space-2);
font-family: var(--font-mono);
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
border-radius: var(--radius-sm);
border: 1px dashed transparent;
transition:
background var(--motion-fast),
border-color var(--motion-fast),
color var(--motion-fast);
}
.portal-mocks-toggle.is-on {
color: var(--color-amber-dark);
background: var(--color-amber-light);
border-color: var(--color-amber-border);
}
.portal-mocks-toggle.is-off {
color: var(--color-text-4);
background: var(--color-bg-muted);
border-color: var(--color-border);
}
.portal-mocks-toggle:hover {
filter: brightness(1.04);
}
.portal-mocks-toggle.is-pending {
opacity: 0.6;
cursor: progress;
}
.portal-mocks-toggle__dot {
width: 0.4375rem;
height: 0.4375rem;
border-radius: 50%;
background: currentColor;
box-shadow: 0 0 0 2px color-mix(in srgb, currentColor 28%, transparent);
}
.portal-mocks-toggle.is-off .portal-mocks-toggle__dot {
background: var(--color-text-5);
box-shadow: none;
}
@@ -0,0 +1,54 @@
/// <reference types="vite/client" />
import { useState } from "react";
import {
readMocksPreference,
writeMocksPreference,
} from "@portal/mocks/preference";
import "@portal/components/MocksToggle.css";
/**
* Dev-only header chip that flips MSW interception on and off. Persists the
* preference to localStorage so it survives reloads. Hidden entirely in
* production builds — there's no MSW worker to toggle there.
*
* Toggling reloads the page. Without a reload, components that already
* fetched data via useAsync keep showing the cached result, which makes the
* toggle feel like it does nothing. A reload gives a clean view of what the
* app looks like with/without mocks.
*/
export function MocksToggle() {
const [enabled] = useState(() => readMocksPreference());
const [pending, setPending] = useState(false);
if (!import.meta.env.DEV) return null;
function toggle() {
if (pending) return;
setPending(true);
writeMocksPreference(!enabled);
window.location.reload();
}
return (
<button
type="button"
className={
"portal-mocks-toggle" +
(enabled ? " is-on" : " is-off") +
(pending ? " is-pending" : "")
}
onClick={toggle}
aria-pressed={enabled}
title={
enabled
? "Mock data ON — fetch calls are intercepted by MSW. Click to switch to the real network (reloads the page)."
: "Mock data OFF — fetch calls go to the real network. Click to re-enable mocks (reloads the page)."
}
>
<span className="portal-mocks-toggle__dot" aria-hidden />
<span className="portal-mocks-toggle__label">
Mocks {enabled ? "ON" : "OFF"}
</span>
</button>
);
}
@@ -0,0 +1,137 @@
.portal-notif {
position: relative;
}
.portal-notif__panel {
position: absolute;
top: calc(100% + 0.25rem);
right: 0;
width: 22.5rem;
background: var(--color-notif-bg, var(--color-surface));
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
overflow: hidden;
z-index: 25;
animation: fadeInUp var(--motion-enter) both;
}
.portal-notif__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.625rem 0.875rem;
border-bottom: 1px solid var(--color-border-light);
font-size: 0.8125rem;
}
.portal-notif__title {
font-weight: 600;
color: var(--color-text-1);
}
.portal-notif__count {
font-size: 0.6875rem;
font-weight: 500;
color: var(--color-blue);
padding: 0.125rem 0.375rem;
background: var(--color-blue-light);
border-radius: var(--radius-pill);
}
.portal-notif__list {
list-style: none;
margin: 0;
padding: 0;
max-height: 22rem;
overflow-y: auto;
}
.portal-notif__item {
display: flex;
align-items: flex-start;
gap: 0.625rem;
padding: 0.625rem 0.875rem;
border-bottom: 1px solid var(--color-border-light);
cursor: pointer;
transition: background var(--motion-fast);
}
.portal-notif__item:last-child {
border-bottom: none;
}
.portal-notif__item:hover {
background: var(--color-bg-hover);
}
.portal-notif__dot {
flex: 0 0 auto;
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
margin-top: 0.4375rem;
}
.portal-notif__body {
flex: 1 1 auto;
min-width: 0;
}
.portal-notif__item-title {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-text-1);
margin-bottom: 0.125rem;
}
.portal-notif__desc {
font-size: 0.75rem;
color: var(--color-text-4);
line-height: 1.4;
margin-bottom: 0.25rem;
}
.portal-notif__time {
font-size: 0.6875rem;
color: var(--color-text-5);
}
.portal-notif__footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.625rem;
border-top: 1px solid var(--color-border-light);
background: var(--color-bg-subtle);
}
.portal-notif__action {
font-size: 0.75rem;
font-weight: 500;
color: var(--color-blue);
padding: 0.25rem 0.5rem;
border-radius: var(--radius-sm);
transition: background var(--motion-fast);
}
.portal-notif__action:hover:not(:disabled) {
background: var(--color-bg-hover);
}
.portal-notif__action:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.portal-notif__count--quiet {
background: var(--color-bg-muted);
color: var(--color-text-5);
}
.portal-notif__empty {
padding: 1.5rem 1rem;
text-align: center;
font-size: 0.8125rem;
color: var(--color-text-4);
}
@@ -0,0 +1,76 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { http, HttpResponse, delay } from "msw";
import { NotificationsDropdown } from "@portal/components/NotificationsDropdown";
import { NOTIFICATIONS } from "@portal/mocks/notifications";
const meta: Meta<typeof NotificationsDropdown> = {
title: "Portal/Header/NotificationsDropdown",
component: NotificationsDropdown,
parameters: { layout: "centered" },
decorators: [
(S) => (
<div
style={{
padding: "4rem 8rem",
minHeight: "30rem",
background: "var(--color-bg)",
}}
>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof NotificationsDropdown>;
export const Default: Story = {};
export const Empty: Story = {
parameters: {
msw: {
handlers: [http.get("/v1/notifications", () => HttpResponse.json([]))],
},
},
};
export const Loading: Story = {
parameters: {
msw: {
handlers: [
http.get("/v1/notifications", async () => {
await delay("infinite");
return HttpResponse.json([]);
}),
],
},
},
};
export const HighVolume: Story = {
parameters: {
msw: {
handlers: [
http.get("/v1/notifications", () => {
const items = Array.from({ length: 24 }, (_, i) => ({
...NOTIFICATIONS[i % NOTIFICATIONS.length],
id: `n${i + 1}`,
}));
return HttpResponse.json(items);
}),
],
},
},
};
export const NetworkError: Story = {
parameters: {
msw: {
handlers: [
http.get("/v1/notifications", () =>
HttpResponse.json({ error: "Service unavailable" }, { status: 503 }),
),
],
},
},
};
@@ -0,0 +1,125 @@
import { useState } from "react";
import { Dropdown, EmptyState, Skeleton } from "@shared/components";
import { BellIcon } from "@portal/components/icons";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
import {
fetchNotifications,
markAllNotificationsRead,
type Notification,
type NotificationCategory,
} from "@portal/api/notifications";
import "@portal/components/NotificationsDropdown.css";
const CATEGORY_COLOUR: Record<NotificationCategory, string> = {
pipeline: "var(--color-blue)",
deploy: "var(--color-green)",
billing: "var(--color-amber)",
audit: "var(--color-purple)",
agent: "var(--color-cat-insurance)",
doc: "var(--color-cat-healthcare)",
};
export function NotificationsDropdown() {
const state = useAsync<Notification[]>(() => fetchNotifications(), []);
const { data: items } = state;
const { isLoading } = useSectionFlags(state);
// Optimistically clear on "mark all read"; revert if the request fails.
const [cleared, setCleared] = useState(false);
const visible = cleared ? [] : (items ?? []);
async function onMarkAllRead() {
setCleared(true);
try {
await markAllNotificationsRead();
} catch {
setCleared(false);
}
}
const isEmpty = !isLoading && visible.length === 0;
const hasUnread = visible.length > 0;
return (
<Dropdown.Root align="end">
<Dropdown.Trigger>
<button
type="button"
className="portal-header__icon-btn portal-header__icon-btn--badge"
aria-label={
hasUnread
? `Notifications, ${visible.length} unread`
: "Notifications, no unread"
}
>
<BellIcon size={16} />
{hasUnread && (
<span className="portal-header__bell-dot" aria-hidden />
)}
</button>
</Dropdown.Trigger>
<Dropdown.Menu width="22.5rem" className="portal-notif__menu">
<div className="portal-notif__header">
<span className="portal-notif__title">Notifications</span>
{hasUnread ? (
<span className="portal-notif__count">{visible.length} new</span>
) : isLoading ? (
<span className="portal-notif__count portal-notif__count--quiet">
loading
</span>
) : (
<span className="portal-notif__count portal-notif__count--quiet">
all read
</span>
)}
</div>
{isLoading && (
<div className="portal-notif__loading">
<Skeleton height="0.875rem" />
<Skeleton height="0.6875rem" width="60%" />
<Skeleton height="0.875rem" />
<Skeleton height="0.6875rem" width="60%" />
</div>
)}
{isEmpty && (
<EmptyState
size="compact"
title="You're all caught up"
description="No new notifications."
/>
)}
{!isLoading && !isEmpty && (
<ul className="portal-notif__list">
{visible.map((item) => (
<li key={item.id} className="portal-notif__item">
<span
className="portal-notif__dot"
style={{ background: CATEGORY_COLOUR[item.category] }}
aria-hidden
/>
<div className="portal-notif__body">
<div className="portal-notif__item-title">{item.title}</div>
<div className="portal-notif__desc">{item.description}</div>
<div className="portal-notif__time">{item.time}</div>
</div>
</li>
))}
</ul>
)}
<div className="portal-notif__footer">
<button
type="button"
className="portal-notif__action"
onClick={onMarkAllRead}
disabled={!hasUnread}
>
Mark all read
</button>
<button type="button" className="portal-notif__action">
View all
</button>
</div>
</Dropdown.Menu>
</Dropdown.Root>
);
}
@@ -0,0 +1,87 @@
.portal-usecases {
display: flex;
flex-direction: column;
gap: 1rem;
}
.portal-usecases__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
}
.portal-usecases__title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-usecases__viewall,
.portal-usecases__cta {
background: none;
border: none;
padding: 0;
cursor: pointer;
font-family: inherit;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 0.25rem;
transition: opacity var(--motion-fast);
}
.portal-usecases__viewall {
font-size: 0.8125rem;
color: var(--color-blue);
}
.portal-usecases__viewall:hover,
.portal-usecases__cta:hover {
opacity: 0.8;
}
.portal-usecases__grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.875rem;
}
@media (max-width: 50rem) {
.portal-usecases__grid {
grid-template-columns: 1fr;
}
}
.portal-usecases__card {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.portal-usecases__eyebrow {
font-size: 0.625rem;
font-weight: 600;
letter-spacing: 0.08em;
}
.portal-usecases__card-title {
margin: 0.125rem 0 0;
font-size: 1rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-usecases__blurb {
margin: 0;
font-size: 0.8125rem;
line-height: 1.5;
color: var(--color-text-3);
}
.portal-usecases__cta {
margin-top: 0.5rem;
align-self: flex-start;
font-size: 0.8125rem;
}
@@ -0,0 +1,107 @@
import { Card, type CardProps } from "@shared/components";
import { useView } from "@portal/contexts/ViewContext";
import "@portal/components/PopularUseCases.css";
type Accent = NonNullable<CardProps["accent"]>;
interface UseCase {
eyebrow: string;
title: string;
blurb: string;
cta: string;
accent: Accent;
}
const ACCENT_COLOR: Record<Accent, string> = {
blue: "var(--color-blue)",
purple: "var(--color-purple)",
green: "var(--color-green)",
amber: "var(--color-amber)",
red: "var(--color-red)",
};
/**
* Curated landing-page use cases — a teaser, not the full catalogue. The
* exhaustive per-vertical endpoint list lives on the Documents view; here we
* surface the four cross-cutting pipelines people reach for first. Copy mirrors
* the prototype's "Popular use cases" block.
*/
const USE_CASES: UseCase[] = [
{
eyebrow: "AUTO-ROUTING",
title: "Auto-classify and route incoming documents",
blurb:
"One classifier reads what arrived — KYC form, invoice, contract, COI — and routes to the right downstream pipeline. No manual triage, no docs in the wrong workflow.",
cta: "Build a classifier pipeline",
accent: "blue",
},
{
eyebrow: "PII REDACTION",
title: "Redact PII before it leaves your stack",
blurb:
"Strip sensitive fields before storage, indexing, or LLM processing. Schema-aware, per-field audit, BYOK or HYOK keys. Compliance at the document boundary, not per pipeline.",
cta: "See redaction pipelines",
accent: "red",
},
{
eyebrow: "TRAINING DATA",
title: "Turn PDFs into training data",
blurb:
"Batch-import an archive, redact PII, classify, chunk, and emit ready-to-load JSON for fine-tuning, eval sets, or RAG. Self-completing and replayable.",
cta: "Build a training-data pipeline",
accent: "purple",
},
{
eyebrow: "AUTHENTICITY",
title: "Verify signatures and detect tampering",
blurb:
"Cryptographic checks at the document boundary — signature validation, tamper detection, signing flows for outbound documents. Trust decisions in the pipeline, not your app code.",
cta: "Try authenticity check",
accent: "green",
},
];
export function PopularUseCases() {
const { setActiveView } = useView();
return (
<section className="portal-usecases" aria-label="Popular use cases">
<header className="portal-usecases__head">
<h2 className="portal-usecases__title">Popular use cases</h2>
<button
type="button"
className="portal-usecases__viewall"
onClick={() => setActiveView("pipelines")}
>
View all pipelines <span aria-hidden></span>
</button>
</header>
<div className="portal-usecases__grid">
{USE_CASES.map((uc) => (
<Card
key={uc.eyebrow}
accent={uc.accent}
padding="loose"
className="portal-usecases__card"
>
<span
className="portal-usecases__eyebrow"
style={{ color: ACCENT_COLOR[uc.accent] }}
>
{uc.eyebrow}
</span>
<h3 className="portal-usecases__card-title">{uc.title}</h3>
<p className="portal-usecases__blurb">{uc.blurb}</p>
<button
type="button"
className="portal-usecases__cta"
style={{ color: ACCENT_COLOR[uc.accent] }}
onClick={() => setActiveView("pipelines")}
>
{uc.cta} <span aria-hidden></span>
</button>
</Card>
))}
</div>
</section>
);
}
@@ -0,0 +1,138 @@
.portal-activity {
display: flex;
flex-direction: column;
}
.portal-activity__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.875rem 1rem;
border-bottom: 1px solid var(--color-border-light);
}
.portal-activity__title {
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-activity__more {
font-size: 0.75rem;
font-weight: 500;
color: var(--color-blue);
padding: 0.25rem 0.5rem;
border-radius: var(--radius-sm);
transition: background var(--motion-fast);
}
.portal-activity__more:hover {
background: var(--color-bg-hover);
}
.portal-activity__list {
list-style: none;
margin: 0;
padding: 0;
}
.portal-activity__item {
display: flex;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-border-light);
transition: background var(--motion-fast);
}
.portal-activity__item:last-child {
border-bottom: none;
}
.portal-activity__item:hover {
background: var(--color-bg-hover);
}
.portal-activity__rail {
width: 0.1875rem;
border-radius: var(--radius-pill);
flex-shrink: 0;
}
.portal-activity__body {
flex: 1 1 auto;
min-width: 0;
}
.portal-activity__row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
}
.portal-activity__action {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-activity__time {
font-size: 0.6875rem;
color: var(--color-text-5);
}
.portal-activity__subject {
font-size: 0.75rem;
color: var(--color-text-3);
margin-top: 0.0625rem;
}
.portal-activity__detail-row {
margin-top: 0.375rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.portal-activity__detail {
font-size: 0.75rem;
color: var(--color-text-4);
font-family: var(--font-mono);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.portal-activity__item--skeleton {
cursor: default;
}
.portal-activity__item--skeleton .portal-activity__rail {
background: var(--color-border-light);
}
.portal-activity__skel {
height: 0.75rem;
border-radius: var(--radius-pill);
background: linear-gradient(
90deg,
var(--color-bg-muted) 0%,
var(--color-bg-hover) 50%,
var(--color-bg-muted) 100%
);
background-size: 200% 100%;
animation: shimmer 1.4s linear infinite;
margin-bottom: 0.375rem;
}
.portal-activity__skel--md {
width: 70%;
}
.portal-activity__skel--sm {
width: 50%;
height: 0.625rem;
}
@@ -0,0 +1,41 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { http, HttpResponse, delay } from "msw";
import { RecentActivity } from "@portal/components/RecentActivity";
const meta: Meta<typeof RecentActivity> = {
title: "Portal/Home/RecentActivity",
component: RecentActivity,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "40rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof RecentActivity>;
export const Default: Story = {};
export const Loading: Story = {
parameters: {
msw: {
handlers: [
http.get("/v1/activity", async () => {
await delay("infinite");
return HttpResponse.json([]);
}),
],
},
},
};
export const Empty: Story = {
parameters: {
msw: {
handlers: [http.get("/v1/activity", () => HttpResponse.json([]))],
},
},
};
@@ -0,0 +1,94 @@
import { Card, EmptyState, Skeleton, StatusBadge } from "@shared/components";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
import {
fetchRecentActivity,
type ActivityEvent,
type ActivityKind,
} from "@portal/api/home";
import "@portal/components/RecentActivity.css";
const KIND_COLOUR: Record<ActivityKind, string> = {
"pipeline-run": "var(--color-blue)",
deploy: "var(--color-green)",
drift: "var(--color-amber)",
eval: "var(--color-purple)",
agent: "var(--color-cat-insurance)",
billing: "var(--color-red)",
};
export function RecentActivity() {
const state = useAsync<ActivityEvent[]>(() => fetchRecentActivity(), []);
const { data: events } = state;
const { isLoading, isEmpty } = useSectionFlags(state);
return (
<Card
padding="none"
className="portal-activity"
aria-label="Recent activity"
>
<header className="portal-activity__head">
<h2 className="portal-activity__title">Recent activity</h2>
<button type="button" className="portal-activity__more">
View all
</button>
</header>
{isLoading && (
<ul className="portal-activity__list" aria-hidden>
{Array.from({ length: 5 }).map((_, i) => (
<li
key={i}
className="portal-activity__item portal-activity__item--skeleton"
>
<span className="portal-activity__rail" />
<div className="portal-activity__body">
<Skeleton width="70%" />
<Skeleton width="50%" height="0.625rem" />
</div>
</li>
))}
</ul>
)}
{isEmpty && (
<EmptyState
size="compact"
title="Nothing here yet"
description="Pipeline runs, deploys and agent events will appear here."
/>
)}
{events && events.length > 0 && (
<ol className="portal-activity__list">
{events.map((event) => (
<li key={event.id} className="portal-activity__item">
<span
className="portal-activity__rail"
style={{ background: KIND_COLOUR[event.kind] }}
aria-hidden
/>
<div className="portal-activity__body">
<div className="portal-activity__row">
<span className="portal-activity__action">
{event.action}
</span>
<span className="portal-activity__time">{event.time}</span>
</div>
<div className="portal-activity__subject">{event.subject}</div>
<div className="portal-activity__detail-row">
<span className="portal-activity__detail">
{event.detail}
</span>
<StatusBadge tone={event.status} size="sm">
{event.status}
</StatusBadge>
</div>
</div>
</li>
))}
</ol>
)}
</Card>
);
}
@@ -0,0 +1,70 @@
.portal-search__input-row {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.25rem 0.25rem 0.75rem;
border-bottom: 1px solid var(--color-border-light);
color: var(--color-text-4);
}
.portal-search__input {
flex: 1 1 auto;
font: inherit;
font-size: 0.9375rem;
background: transparent;
border: none;
outline: none;
color: var(--color-text-1);
}
.portal-search__input::placeholder {
color: var(--color-text-placeholder);
}
.portal-search__esc {
font-family: var(--font-mono);
font-size: 0.6875rem;
padding: 0.0625rem 0.375rem;
border-radius: var(--radius-xs);
background: var(--color-bg-muted);
color: var(--color-text-4);
}
.portal-search__results {
padding-top: 0.75rem;
display: flex;
flex-direction: column;
gap: 1rem;
max-height: 24rem;
overflow-y: auto;
}
.portal-search__group-label {
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-section-label);
padding: 0 0.5rem 0.375rem;
}
.portal-search__item {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.625rem;
border-radius: var(--radius-sm);
color: var(--color-text-2);
font-size: 0.8125rem;
text-align: left;
}
.portal-search__item:hover {
background: var(--color-bg-hover);
}
.portal-search__item-hint {
font-family: var(--font-mono);
font-size: 0.6875rem;
color: var(--color-text-5);
}
@@ -0,0 +1,41 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useEffect } from "react";
import { http, HttpResponse } from "msw";
import { SearchModal } from "@portal/components/SearchModal";
import { useUI } from "@portal/contexts/UIContext";
function ForceOpen() {
const { openSearch } = useUI();
useEffect(() => {
openSearch();
}, [openSearch]);
return null;
}
const meta: Meta<typeof SearchModal> = {
title: "Portal/Header/SearchModal",
component: SearchModal,
parameters: { layout: "fullscreen" },
decorators: [
(S) => (
<div style={{ minHeight: "100vh", background: "var(--color-bg)" }}>
<ForceOpen />
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof SearchModal>;
export const Default: Story = {};
export const EmptyCatalogue: Story = {
parameters: {
msw: {
handlers: [
http.get("/v1/search/quick-actions", () => HttpResponse.json([])),
],
},
},
};
@@ -0,0 +1,121 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { EmptyState, Modal, Skeleton } from "@shared/components";
import { useUI } from "@portal/contexts/UIContext";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
import { fetchQuickActions, type QuickAction } from "@portal/api/search";
import { SearchIcon } from "@portal/components/icons";
import "@portal/components/SearchModal.css";
export function SearchModal() {
const { searchOpen, closeSearch } = useUI();
const inputRef = useRef<HTMLInputElement | null>(null);
const [query, setQuery] = useState("");
const state = useAsync<QuickAction[]>(() => fetchQuickActions(), []);
const { data: actions } = state;
const { isLoading } = useSectionFlags(state);
useEffect(() => {
if (searchOpen) {
setQuery("");
const t = setTimeout(() => inputRef.current?.focus(), 0);
return () => clearTimeout(t);
}
return undefined;
}, [searchOpen]);
const filtered = useMemo(() => {
if (!actions) return [] as QuickAction[];
if (!query.trim()) return actions;
const needle = query.trim().toLowerCase();
return actions.filter((item) => item.label.toLowerCase().includes(needle));
}, [actions, query]);
const groups = useMemo(
() =>
filtered.reduce<Record<string, QuickAction[]>>((acc, item) => {
if (!acc[item.group]) acc[item.group] = [];
acc[item.group].push(item);
return acc;
}, {}),
[filtered],
);
const groupKeys = Object.keys(groups);
const isEmpty = !isLoading && groupKeys.length === 0;
return (
<Modal
open={searchOpen}
onClose={closeSearch}
width="lg"
ariaLabel="Search"
>
<div className="portal-search">
<div className="portal-search__input-row">
<SearchIcon size={16} />
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search Stirling — endpoints, pipelines, docs…"
aria-label="Search"
className="portal-search__input"
autoComplete="off"
spellCheck={false}
/>
<span className="portal-search__esc" aria-hidden>
ESC
</span>
</div>
<div className="portal-search__results">
{isLoading && (
<div className="portal-search__loading">
<Skeleton height="1rem" width="40%" />
<Skeleton height="0.875rem" />
<Skeleton height="0.875rem" width="80%" />
<Skeleton height="0.875rem" width="65%" />
</div>
)}
{isEmpty && (
<EmptyState
size="compact"
title={
query.trim()
? `No matches for "${query.trim()}"`
: "No quick actions"
}
description={
query.trim()
? "Try a different keyword or browse the catalogue."
: "Quick actions will appear here once they're available."
}
/>
)}
{!isLoading &&
!isEmpty &&
Object.entries(groups).map(([group, items]) => (
<div key={group} className="portal-search__group">
<div className="portal-search__group-label">{group}</div>
{items.map((item) => (
<button
key={item.label}
type="button"
className="portal-search__item"
onClick={closeSearch}
>
<span className="portal-search__item-label">
{item.label}
</span>
<span className="portal-search__item-hint">
{item.hint}
</span>
</button>
))}
</div>
))}
</div>
</div>
</Modal>
);
}
+130
View File
@@ -0,0 +1,130 @@
.portal-sidebar {
width: 15rem;
height: 100vh;
background: var(--color-sidebar-bg);
border-right: 1px solid var(--color-sidebar-border);
display: flex;
flex-direction: column;
flex-shrink: 0;
position: sticky;
top: 0;
}
/* Logo block */
.portal-sidebar__logo {
height: 3.1875rem; /* 51px */
padding: 0 0.875rem;
display: flex;
align-items: center;
gap: 0.5rem;
border-bottom: 1px solid var(--color-sidebar-divider);
}
.portal-sidebar__wordmark {
font-family: var(--font-brand);
font-size: 1.125rem;
font-weight: 700;
color: var(--color-logo-text);
letter-spacing: 0.02em;
}
.portal-sidebar__workspace {
margin-left: auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border-radius: var(--radius-sm);
color: var(--color-text-4);
transition:
background var(--motion-fast),
color var(--motion-fast);
}
.portal-sidebar__workspace:hover {
background: var(--color-bg-hover);
color: var(--color-text-2);
}
/* Nav body */
.portal-sidebar__nav {
flex: 1 1 auto;
overflow-y: auto;
padding: 0.75rem 0.625rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.portal-sidebar__group {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.portal-sidebar__divider {
height: 1px;
background: var(--color-sidebar-divider);
margin: 0.25rem 0;
}
/* Footer */
.portal-sidebar__footer {
border-top: 1px solid var(--color-sidebar-divider);
padding: 0.5rem 0.625rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.portal-sidebar__usage {
padding: 0.5rem 0.625rem;
font-size: 0.75rem;
color: var(--color-usage-text);
}
.portal-sidebar__usage-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.portal-sidebar__usage-label {
color: var(--color-text-4);
}
.portal-sidebar__usage-value {
color: var(--color-usage-value);
font-weight: 500;
}
.portal-sidebar__usage-track {
margin-top: 0.5rem;
height: 0.25rem;
background: var(--color-usage-track);
border-radius: var(--radius-pill);
overflow: hidden;
}
.portal-sidebar__usage-fill {
height: 100%;
background: var(--grad-blue-btn);
transition: width var(--motion-slow);
}
.portal-sidebar__plan {
display: inline-flex;
align-items: center;
gap: 0.375rem;
color: var(--color-text-2);
font-weight: 500;
}
.portal-sidebar__plan-dot {
width: 0.4375rem;
height: 0.4375rem;
border-radius: 50%;
background: var(--color-green);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-green) 30%, transparent);
}
@@ -0,0 +1,29 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Sidebar } from "@portal/components/Sidebar";
const meta: Meta<typeof Sidebar> = {
title: "Portal/Shell/Sidebar",
component: Sidebar,
parameters: { layout: "fullscreen" },
decorators: [
(S) => (
<div
style={{
display: "flex",
height: "100vh",
background: "var(--color-bg)",
}}
>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof Sidebar>;
export const Default: Story = {};
export const FreeTier: Story = { globals: { tier: "free" } };
export const EnterpriseTier: Story = { globals: { tier: "enterprise" } };
+174
View File
@@ -0,0 +1,174 @@
import { NavItem } from "@shared/components";
import { useView, type ViewId } from "@portal/contexts/ViewContext";
import { useTier } from "@portal/contexts/TierContext";
import { useAsync } from "@portal/hooks/useAsync";
import { fetchHomeKpis, type KpiEntry } from "@portal/api/home";
import {
HomeIcon,
EditorIcon,
SourcesIcon,
PipelinesIcon,
DocumentsIcon,
InfrastructureIcon,
UsageIcon,
DocsIcon,
SettingsIcon,
ChevronDownIcon,
} from "@portal/components/icons";
import "@portal/components/Sidebar.css";
interface NavEntry {
id: ViewId;
label: string;
icon: React.ReactNode;
}
const GROUP_PRIMARY: NavEntry[] = [
{ id: "home", label: "Home", icon: <HomeIcon /> },
];
const GROUP_OPERATIONAL: NavEntry[] = [
{ id: "editor", label: "Editor", icon: <EditorIcon /> },
{ id: "sources", label: "Sources", icon: <SourcesIcon /> },
{ id: "pipelines", label: "Pipelines", icon: <PipelinesIcon /> },
{ id: "documents", label: "Documents", icon: <DocumentsIcon /> },
];
const GROUP_PLATFORM: NavEntry[] = [
{
id: "infrastructure",
label: "Infrastructure",
icon: <InfrastructureIcon />,
},
{ id: "usage", label: "Usage & Billing", icon: <UsageIcon /> },
{ id: "docs", label: "Developer Docs", icon: <DocsIcon /> },
];
function StirlingMark() {
// The Stirling brand mark — two stacked parallelograms in the brand blues.
return (
<svg
width="22"
height="22"
viewBox="0 0 22 22"
aria-hidden
role="presentation"
>
<path d="M5 4 L20 4 L17 11 L2 11 Z" fill="var(--color-blue)" />
<path d="M5 11 L17 11 L14 18 L2 18 Z" fill="var(--color-blue-border)" />
</svg>
);
}
function UsageFooter() {
const { tier } = useTier();
// Read the same endpoint Home's KPI strip uses so the doc count here can't
// drift from the headline figure. The first KPI is always the doc total.
const { data: kpis, loading } = useAsync<KpiEntry[]>(
() => fetchHomeKpis(tier),
[tier],
);
const docs = loading ? undefined : kpis?.[0]?.value;
if (tier === "free") {
// The free doc KPI is formatted "used / cap"; parse it for the meter.
const [used, cap] =
typeof docs === "string"
? docs.split("/").map((s) => Number(s.replace(/[^\d]/g, "")))
: [];
const pct = used && cap ? (used / cap) * 100 : 0;
return (
<div className="portal-sidebar__usage portal-sidebar__usage--free">
<div className="portal-sidebar__usage-line">
<span className="portal-sidebar__usage-label">Docs processed</span>
<span className="portal-sidebar__usage-value">{docs ?? "—"}</span>
</div>
<div
className="portal-sidebar__usage-track"
role="progressbar"
aria-valuenow={used ?? 0}
aria-valuemax={cap ?? 100}
>
<div
className="portal-sidebar__usage-fill"
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
}
const planLabel = tier === "pro" ? "Pay-as-you-go" : "Enterprise Plan";
return (
<div className="portal-sidebar__usage">
<div className="portal-sidebar__usage-line">
<span className="portal-sidebar__plan">
<span className="portal-sidebar__plan-dot" aria-hidden />
{planLabel}
</span>
<span className="portal-sidebar__usage-value">
{docs != null ? `${docs} docs` : "—"}
</span>
</div>
</div>
);
}
export function Sidebar() {
const { activeView, setActiveView } = useView();
function renderGroup(entries: NavEntry[]) {
return entries.map((entry) => (
<NavItem
key={entry.id}
id={entry.id}
label={entry.label}
icon={entry.icon}
isActive={activeView === entry.id}
onClick={(id) => setActiveView(id as ViewId)}
/>
));
}
return (
<aside className="portal-sidebar" aria-label="Primary navigation">
<div className="portal-sidebar__logo">
<StirlingMark />
<span className="portal-sidebar__wordmark">Stirling</span>
<button
type="button"
className="portal-sidebar__workspace"
aria-label="Switch workspace"
>
<ChevronDownIcon size={14} />
</button>
</div>
<nav className="portal-sidebar__nav">
<div className="portal-sidebar__group">
{renderGroup(GROUP_PRIMARY)}
</div>
<div className="portal-sidebar__divider" aria-hidden />
<div className="portal-sidebar__group">
{renderGroup(GROUP_OPERATIONAL)}
</div>
<div className="portal-sidebar__divider" aria-hidden />
<div className="portal-sidebar__group">
{renderGroup(GROUP_PLATFORM)}
</div>
</nav>
<div className="portal-sidebar__footer">
<NavItem
id="settings"
label="Settings"
icon={<SettingsIcon />}
isActive={activeView === "settings"}
onClick={(id) => setActiveView(id as ViewId)}
/>
<UsageFooter />
</div>
</aside>
);
}
@@ -0,0 +1,345 @@
.portal-runner__layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr);
gap: 1rem;
min-height: 26rem;
}
@media (max-width: 50rem) {
.portal-runner__layout {
grid-template-columns: 1fr;
}
}
.portal-runner__left,
.portal-runner__right {
display: flex;
flex-direction: column;
gap: 0.875rem;
min-width: 0;
}
/* Drop zone */
.portal-runner__drop {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.625rem;
padding: 1.25rem;
border: 1.5px dashed var(--color-border-input);
border-radius: var(--radius-md);
background: var(--color-bg-subtle);
color: var(--color-text-3);
text-align: center;
transition:
border-color var(--motion-fast),
background var(--motion-fast);
}
.portal-runner__drop.is-armed,
.portal-runner__drop:hover {
border-color: var(--color-blue);
background: var(--color-blue-light);
color: var(--color-blue);
}
.portal-runner__drop.has-file {
border-color: var(--color-green);
background: var(--color-green-light);
color: var(--color-text-2);
}
.portal-runner__drop-icon {
color: var(--color-text-4);
}
.portal-runner__drop.is-armed .portal-runner__drop-icon,
.portal-runner__drop.has-file .portal-runner__drop-icon {
color: inherit;
}
.portal-runner__drop-text {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.125rem;
font-size: 0.8125rem;
}
.portal-runner__drop-text strong {
font-size: 0.875rem;
color: var(--color-text-1);
font-family: var(--font-mono);
}
.portal-runner__drop-text span {
font-size: 0.75rem;
color: var(--color-text-4);
}
.portal-runner__sample-btn {
font-size: 0.75rem;
font-weight: 500;
color: var(--color-blue);
padding: 0.25rem 0.5rem;
border-radius: var(--radius-sm);
transition: background var(--motion-fast);
}
.portal-runner__sample-btn:hover {
background: var(--color-bg-hover);
}
/* Op picker */
.portal-runner__section-title {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-section-label);
margin-bottom: 0.5rem;
}
.portal-runner__ops {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.375rem;
}
.portal-runner__op {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.125rem;
padding: 0.5rem 0.625rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
text-align: left;
transition:
border-color var(--motion-fast),
background var(--motion-fast);
}
.portal-runner__op:hover {
border-color: var(--color-border-hover);
background: var(--color-bg-hover);
}
.portal-runner__op.is-selected {
border-color: var(--color-blue);
background: var(--color-blue-light);
}
.portal-runner__op[data-accent="purple"].is-selected {
border-color: var(--color-purple);
background: var(--color-purple-light);
}
.portal-runner__op[data-accent="green"].is-selected {
border-color: var(--color-green);
background: var(--color-green-light);
}
.portal-runner__op[data-accent="amber"].is-selected {
border-color: var(--color-amber);
background: var(--color-amber-light);
}
.portal-runner__op[data-accent="red"].is-selected {
border-color: var(--color-red);
background: var(--color-red-light);
}
.portal-runner__op-label {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-runner__op-endpoint {
font-family: var(--font-mono);
font-size: 0.6875rem;
color: var(--color-text-4);
}
.portal-runner__op-blurb {
font-size: 0.6875rem;
color: var(--color-text-3);
line-height: 1.35;
}
.portal-runner__op--skeleton {
display: flex;
flex-direction: column;
gap: 0.375rem;
cursor: default;
}
.portal-runner__op--skeleton:hover {
border-color: var(--color-border-light);
background: transparent;
}
/* Right column states */
.portal-runner__right {
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
background: var(--color-bg-subtle);
padding: 1.125rem;
overflow: hidden;
min-height: 22rem;
}
.portal-runner__hint {
color: var(--color-text-3);
}
.portal-runner__hint-eyebrow {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-blue);
margin-bottom: 0.5rem;
}
.portal-runner__hint h3 {
margin: 0 0 0.5rem;
font-size: 1rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-runner__hint h3 code {
font-family: var(--font-mono);
font-size: 0.9375rem;
color: var(--color-blue);
background: var(--color-blue-light);
padding: 0 0.25rem;
border-radius: var(--radius-xs);
}
.portal-runner__hint p {
margin: 0;
font-size: 0.8125rem;
line-height: 1.5;
}
.portal-runner__hint p code {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--color-text-2);
background: var(--color-bg-muted);
padding: 0 0.25rem;
border-radius: var(--radius-xs);
}
.portal-runner__hint kbd {
font-family: var(--font-mono);
font-size: 0.6875rem;
background: var(--color-bg-muted);
padding: 0.0625rem 0.3125rem;
border-radius: var(--radius-xs);
border: 1px solid var(--color-border);
}
/* Running state */
.portal-runner__running {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: var(--color-blue-light);
border: 1px solid var(--color-blue-border);
border-radius: var(--radius-md);
color: var(--color-blue);
}
.portal-runner__running-title {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text-1);
margin-bottom: 0.25rem;
}
.portal-runner__running code {
font-family: var(--font-mono);
font-size: 0.75rem;
}
.portal-runner__spinner,
.portal-runner__spinner-lg {
border-radius: 50%;
border: 2px solid currentColor;
border-right-color: transparent;
animation: spin 0.7s linear infinite;
flex-shrink: 0;
}
.portal-runner__spinner {
width: 0.875rem;
height: 0.875rem;
}
.portal-runner__spinner-lg {
width: 1.5rem;
height: 1.5rem;
}
/* Done state */
.portal-runner__result {
display: flex;
flex-direction: column;
height: 100%;
}
.portal-runner__result-head {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.5rem 0.75rem;
background: var(--code-bg-header);
border-bottom: 1px solid var(--code-border);
border-radius: var(--radius-md) var(--radius-md) 0 0;
color: var(--code-muted);
font-family: var(--font-mono);
font-size: 0.75rem;
}
.portal-runner__result-head code {
flex: 1;
font-family: inherit;
color: var(--code-keyword);
}
.portal-runner__result-meta {
font-size: 0.6875rem;
color: var(--code-dim);
}
.portal-runner__result-code {
margin: 0;
padding: 0.875rem 1rem;
background: var(--code-bg);
color: var(--code-text);
font-family: var(--font-mono);
font-size: 0.75rem;
line-height: 1.6;
border-radius: 0 0 var(--radius-md) var(--radius-md);
overflow: auto;
white-space: pre;
max-height: 22rem;
}
/* Footer */
.portal-runner__footer-status {
margin-right: auto;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
color: var(--color-text-4);
font-family: var(--font-mono);
}
.portal-runner__footer-status code {
font-family: inherit;
}
@@ -0,0 +1,53 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { http, HttpResponse, delay } from "msw";
import { SingleOpRunner } from "@portal/components/SingleOpRunner";
const meta: Meta<typeof SingleOpRunner> = {
title: "Portal/Home/SingleOpRunner",
component: SingleOpRunner,
parameters: { layout: "fullscreen" },
args: { open: true, onClose: () => console.log("close") },
decorators: [
(S) => (
<div style={{ minHeight: "100vh", background: "var(--color-bg)" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof SingleOpRunner>;
export const Idle: Story = {};
export const PreSelectedOp: Story = {
args: { initialOpId: "redact" },
};
export const RunFailsWithUnknownOp: Story = {
parameters: {
msw: {
handlers: [
http.post("/v1/ops/:opId/run", () =>
HttpResponse.json({ error: "Unknown op" }, { status: 404 }),
),
],
},
},
};
export const SlowOp: Story = {
parameters: {
msw: {
handlers: [
http.post("/v1/ops/:opId/run", async () => {
await delay(4000);
return HttpResponse.json({
result: { schema: "demo", note: "took its time" },
durationMs: 4000,
});
}),
],
},
},
};
@@ -0,0 +1,355 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Button,
EmptyState,
Modal,
Skeleton,
StatusBadge,
} from "@shared/components";
import { useView } from "@portal/contexts/ViewContext";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
import {
fetchFeaturedOps,
runSingleOp,
type FeaturedOp,
type OpResultMap,
} from "@portal/api/ops";
import "@portal/components/SingleOpRunner.css";
type Phase = "idle" | "running" | "done" | "error";
interface SingleOpRunnerProps {
open: boolean;
onClose: () => void;
/** Optional pre-selected op id (e.g. carousel deep-link). */
initialOpId?: string;
}
const SAMPLE_DOCS = [
"certificate-of-insurance.pdf",
"loss-run-2025.pdf",
"invoice-acme-corp-q1.pdf",
"prior-auth-cigna-12471.pdf",
"contract-acme-msa-v3.pdf",
];
interface RunResult {
result: OpResultMap;
durationMs: number;
opId: string;
}
export function SingleOpRunner({
open,
onClose,
initialOpId,
}: SingleOpRunnerProps) {
const { setActiveView } = useView();
const opsState = useAsync<FeaturedOp[]>(() => fetchFeaturedOps(), []);
const { data: ops } = opsState;
const { isLoading: opsIsLoading, isEmpty: opsIsEmpty } =
useSectionFlags(opsState);
const [selectedOpId, setSelectedOpId] = useState<string | null>(
initialOpId ?? null,
);
const [phase, setPhase] = useState<Phase>("idle");
const [sample, setSample] = useState<string | null>(null);
const [dragOver, setDragOver] = useState(false);
const [runResult, setRunResult] = useState<RunResult | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
// Default selection once the catalogue loads.
useEffect(() => {
if (!selectedOpId && ops && ops.length > 0) {
setSelectedOpId(ops[0].id);
}
}, [ops, selectedOpId]);
const selectedOp = useMemo<FeaturedOp | null>(
() => ops?.find((o) => o.id === selectedOpId) ?? null,
[ops, selectedOpId],
);
useEffect(() => {
if (open) {
setSelectedOpId(initialOpId ?? ops?.[0]?.id ?? null);
setPhase("idle");
setSample(null);
setRunResult(null);
setErrorMsg(null);
}
// ops is intentionally not in deps — we don't want to reset state when
// the catalogue arrives while the modal is open.
}, [open, initialOpId]);
const pickSample = useCallback(() => {
if (!ops) return;
const idx = ops.findIndex((o) => o.id === selectedOpId);
const safeIdx = idx < 0 ? 0 : idx;
setSample(SAMPLE_DOCS[safeIdx % SAMPLE_DOCS.length]);
}, [ops, selectedOpId]);
async function run() {
if (!selectedOp) return;
let activeSample = sample;
if (!activeSample) {
pickSample();
// pickSample updates state; use the synchronously-derived value for the
// call so we don't race.
const idx = ops?.findIndex((o) => o.id === selectedOpId) ?? 0;
activeSample = SAMPLE_DOCS[idx % SAMPLE_DOCS.length];
}
setPhase("running");
setErrorMsg(null);
try {
const res = await runSingleOp(selectedOp.id, activeSample);
setRunResult({ ...res, opId: selectedOp.id });
setPhase("done");
} catch (err) {
setPhase("error");
setErrorMsg(err instanceof Error ? err.message : String(err));
}
}
function reset() {
setPhase("idle");
setRunResult(null);
setErrorMsg(null);
}
function buildPipelineWithOp() {
onClose();
setActiveView("pipelines");
}
function onDragOver(e: React.DragEvent) {
e.preventDefault();
setDragOver(true);
}
function onDragLeave() {
setDragOver(false);
}
function onDrop(e: React.DragEvent) {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files[0];
if (file) setSample(file.name);
else pickSample();
}
return (
<Modal
open={open}
onClose={onClose}
width="xl"
title="Try a PDF operation"
subtitle="Drop a sample, pick an op, see what Stirling returns."
footer={
<>
<div className="portal-runner__footer-status">
{phase === "running" && selectedOp && (
<>
<span className="portal-runner__spinner" aria-hidden />
<code>POST {selectedOp.endpoint}</code>
</>
)}
{phase === "done" && (
<StatusBadge tone="success" size="sm">
200 OK
</StatusBadge>
)}
{phase === "error" && (
<StatusBadge tone="danger" size="sm">
{errorMsg ?? "Failed"}
</StatusBadge>
)}
</div>
<Button variant="ghost" onClick={onClose}>
Close
</Button>
{phase === "done" ? (
<>
<Button variant="outline" onClick={reset}>
Run again
</Button>
<Button
variant="gradient"
onClick={buildPipelineWithOp}
trailingIcon={<span aria-hidden></span>}
>
Open the pipeline builder
</Button>
</>
) : (
<Button
variant="gradient"
onClick={run}
disabled={phase === "running" || !selectedOp}
trailingIcon={<span aria-hidden></span>}
>
{phase === "running" ? "Running…" : "Run operation"}
</Button>
)}
</>
}
>
<div className="portal-runner__layout">
{/* Left column: file + op picker */}
<section className="portal-runner__left">
<div
className={
"portal-runner__drop" +
(dragOver ? " is-armed" : "") +
(sample ? " has-file" : "")
}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
>
<div className="portal-runner__drop-icon" aria-hidden>
<svg
viewBox="0 0 24 24"
width="32"
height="32"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
</div>
<div className="portal-runner__drop-text">
{sample ? (
<>
<strong>{sample}</strong>
<span>Drop again or pick another sample to replace.</span>
</>
) : (
<>
<strong>Drop a PDF here</strong>
<span>or use a sample document.</span>
</>
)}
</div>
<button
type="button"
className="portal-runner__sample-btn"
onClick={pickSample}
>
{sample ? "Pick another sample" : "Use a sample"}
</button>
</div>
<div>
<div className="portal-runner__section-title">Featured ops</div>
<div className="portal-runner__ops">
{opsIsLoading &&
Array.from({ length: 4 }).map((_, i) => (
<div
key={`op-skel-${i}`}
className="portal-runner__op portal-runner__op--skeleton"
aria-hidden
>
<Skeleton width="60%" />
<Skeleton width="40%" height="0.625rem" />
<Skeleton width="80%" height="0.625rem" />
</div>
))}
{opsIsEmpty && (
<EmptyState
size="compact"
title="No featured ops yet"
description="Once operations are published, they'll show up here."
/>
)}
{ops?.map((op) => (
<button
key={op.id}
type="button"
className={
"portal-runner__op" +
(selectedOpId === op.id ? " is-selected" : "")
}
onClick={() => {
setSelectedOpId(op.id);
if (phase === "done") {
setPhase("idle");
setRunResult(null);
}
}}
data-accent={op.accent}
>
<span className="portal-runner__op-label">{op.label}</span>
<span className="portal-runner__op-endpoint">
{op.endpoint}
</span>
<span className="portal-runner__op-blurb">{op.blurb}</span>
</button>
))}
</div>
</div>
</section>
{/* Right column: state-driven result panel */}
<section className="portal-runner__right">
{phase === "idle" && selectedOp && (
<div className="portal-runner__hint">
<div className="portal-runner__hint-eyebrow">Ready</div>
<h3>
Run <code>{selectedOp.label}</code> on{" "}
<code>{sample ?? "a sample"}</code>
</h3>
<p>
{selectedOp.blurb}. Press <kbd>Run operation</kbd> to invoke{" "}
<code>POST {selectedOp.endpoint}</code>.
</p>
</div>
)}
{phase === "running" && selectedOp && (
<div className="portal-runner__running">
<div className="portal-runner__spinner-lg" aria-hidden />
<div className="portal-runner__running-text">
<div className="portal-runner__running-title">
Running {selectedOp.label}
</div>
<code>POST {selectedOp.endpoint}</code>
</div>
</div>
)}
{phase === "done" && selectedOp && runResult && (
<div className="portal-runner__result">
<header className="portal-runner__result-head">
<StatusBadge tone="success" size="sm">
Completed
</StatusBadge>
<code>POST {selectedOp.endpoint}</code>
<span className="portal-runner__result-meta">
{runResult.durationMs} ms
</span>
</header>
<pre className="portal-runner__result-code">
{JSON.stringify(runResult.result, null, 2)}
</pre>
</div>
)}
{phase === "error" && (
<div className="portal-runner__hint">
<div
className="portal-runner__hint-eyebrow"
style={{ color: "var(--color-red)" }}
>
Failed
</div>
<h3>The operation didn&rsquo;t complete</h3>
<p>{errorMsg ?? "Unknown error"}</p>
</div>
)}
</section>
</div>
</Modal>
);
}
@@ -0,0 +1,115 @@
.portal-chart {
position: relative;
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1.125rem 1.25rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.portal-chart__head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 0.75rem;
}
.portal-chart__label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-section-label);
}
.portal-chart__value {
margin-top: 0.125rem;
font-size: 1.5rem;
font-weight: 700;
color: var(--color-text-1);
font-variant-numeric: tabular-nums;
}
.portal-chart__delta {
font-size: 0.75rem;
font-weight: 500;
padding: 0.1875rem 0.5rem;
border-radius: var(--radius-pill);
}
.portal-chart__delta.is-up {
color: var(--color-green-dark);
background: var(--color-green-light);
}
.portal-chart__delta.is-down {
color: var(--color-red);
background: var(--color-red-light);
}
.portal-chart__svg {
width: 100%;
height: 15rem;
display: block;
overflow: visible;
}
.portal-chart__gridline {
stroke: var(--color-border-light);
stroke-width: 1;
stroke-dasharray: 3 3;
}
.portal-chart__axis-label {
font-family: var(--font-sans);
font-size: 10.5px;
fill: var(--color-text-5);
}
.portal-chart__line {
fill: none;
stroke: var(--color-blue);
stroke-width: 1.75;
stroke-linecap: round;
stroke-linejoin: round;
}
.portal-chart__scrub {
stroke: var(--color-text-5);
stroke-width: 1;
stroke-dasharray: 2 3;
opacity: 0.6;
}
.portal-chart__dot {
fill: var(--color-blue);
stroke: var(--color-surface);
stroke-width: 2;
}
.portal-chart__tooltip {
position: absolute;
transform: translateX(-50%);
top: 4rem;
padding: 0.4375rem 0.625rem;
background: var(--color-tooltip-bg);
color: var(--color-tooltip-text);
border-radius: var(--radius-sm);
font-size: 0.75rem;
white-space: nowrap;
pointer-events: none;
box-shadow: var(--shadow-md);
z-index: 2;
}
.portal-chart__tooltip-date {
font-size: 0.6875rem;
opacity: 0.7;
}
.portal-chart__tooltip-value {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
@@ -0,0 +1,53 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { UsageAreaChart } from "@portal/components/UsageAreaChart";
import { buildUsageSeries } from "@portal/mocks/home";
const meta: Meta<typeof UsageAreaChart> = {
title: "Portal/Home/UsageAreaChart",
component: UsageAreaChart,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "60rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof UsageAreaChart>;
const series = buildUsageSeries();
const total = series.reduce((sum, p) => sum + p.value, 0);
export const Default: Story = {
args: { data: series, totalValue: total.toLocaleString(), deltaPct: 0.12 },
};
export const NegativeDelta: Story = {
args: { data: series, totalValue: total.toLocaleString(), deltaPct: -0.08 },
};
export const NoDelta: Story = {
args: { data: series },
};
export const Flat: Story = {
args: {
data: series.map((p) => ({ ...p, value: 1200 })),
totalValue: "36,000",
},
};
export const HighVolatility: Story = {
args: {
data: series.map((p, i) => ({
...p,
value: Math.round(p.value * (1 + Math.sin(i) * 0.6)),
})),
},
};
export const Empty: Story = {
args: { data: [], totalValue: "—" },
};
@@ -0,0 +1,278 @@
import { useMemo, useRef, useState, type KeyboardEvent } from "react";
import type { UsagePoint } from "@portal/api/home";
import "@portal/components/UsageAreaChart.css";
interface UsageAreaChartProps {
data: UsagePoint[];
/** Headline metric shown above the chart (e.g. total 30d, current value). */
totalLabel?: string;
totalValue?: string;
deltaPct?: number;
}
const PADDING = { top: 16, right: 20, bottom: 28, left: 32 } as const;
const VIEW = { width: 800, height: 240 } as const;
function formatTick(date: string): string {
return new Date(date).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
}
function formatNumber(value: number): string {
if (value >= 1000) {
return `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`;
}
return value.toLocaleString();
}
export function UsageAreaChart({
data,
totalLabel = "Docs processed · last 30 days",
totalValue,
deltaPct,
}: UsageAreaChartProps) {
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
const svgRef = useRef<SVGSVGElement | null>(null);
const { points, areaPath, linePath, yMax, yTicks, xTickIndices } =
useMemo(() => {
if (data.length === 0) {
return {
points: [] as Array<{ x: number; y: number; raw: UsagePoint }>,
areaPath: "",
linePath: "",
yMax: 0,
yTicks: [] as number[],
xTickIndices: [] as number[],
};
}
const max = Math.max(...data.map((d) => d.value));
// Round yMax up to a "nice" number for ticks. Floor at 500 so an
// all-zero series doesn't divide by zero (which would NaN every point).
const niceMax = Math.max(Math.ceil(max / 500) * 500, 500);
const yTicksCalc = [0, niceMax * 0.5, niceMax];
const innerW = VIEW.width - PADDING.left - PADDING.right;
const innerH = VIEW.height - PADDING.top - PADDING.bottom;
const xStep = innerW / Math.max(data.length - 1, 1);
const pts = data.map((raw, i) => ({
x: PADDING.left + i * xStep,
y: PADDING.top + innerH - (raw.value / niceMax) * innerH,
raw,
}));
const linePathStr = pts
.map(
(p, i) =>
`${i === 0 ? "M" : "L"} ${p.x.toFixed(2)} ${p.y.toFixed(2)}`,
)
.join(" ");
const baseY = PADDING.top + innerH;
const areaPathStr = `${linePathStr} L ${pts[pts.length - 1].x.toFixed(2)} ${baseY} L ${pts[0].x.toFixed(2)} ${baseY} Z`;
// X ticks: 5 evenly spread.
const xIdx: number[] = [];
const tickCount = 5;
for (let t = 0; t < tickCount; t++) {
xIdx.push(Math.round((t * (data.length - 1)) / (tickCount - 1)));
}
return {
points: pts,
areaPath: areaPathStr,
linePath: linePathStr,
yMax: niceMax,
yTicks: yTicksCalc,
xTickIndices: xIdx,
};
}, [data]);
function onMouseMove(e: React.MouseEvent<SVGSVGElement>) {
if (!svgRef.current || points.length === 0) return;
const rect = svgRef.current.getBoundingClientRect();
const xRatio = (e.clientX - rect.left) / rect.width;
const svgX = xRatio * VIEW.width;
// Find nearest point
let nearestIdx = 0;
let nearestDist = Infinity;
for (let i = 0; i < points.length; i++) {
const d = Math.abs(points[i].x - svgX);
if (d < nearestDist) {
nearestDist = d;
nearestIdx = i;
}
}
setHoverIndex(nearestIdx);
}
const hovered = hoverIndex !== null ? points[hoverIndex] : null;
function onKeyDown(e: KeyboardEvent<SVGSVGElement>) {
if (points.length === 0) return;
if (e.key === "ArrowRight") {
e.preventDefault();
setHoverIndex((idx) =>
idx === null ? 0 : Math.min(points.length - 1, idx + 1),
);
} else if (e.key === "ArrowLeft") {
e.preventDefault();
setHoverIndex((idx) =>
idx === null ? points.length - 1 : Math.max(0, idx - 1),
);
} else if (e.key === "Home") {
e.preventDefault();
setHoverIndex(0);
} else if (e.key === "End") {
e.preventDefault();
setHoverIndex(points.length - 1);
} else if (e.key === "Escape") {
setHoverIndex(null);
}
}
const displayTotal =
totalValue ?? data.reduce((sum, p) => sum + p.value, 0).toLocaleString();
return (
<div className="portal-chart">
<header className="portal-chart__head">
<div>
<div className="portal-chart__label">{totalLabel}</div>
<div className="portal-chart__value">{displayTotal}</div>
</div>
{deltaPct !== undefined && (
<div
className={
"portal-chart__delta " + (deltaPct >= 0 ? "is-up" : "is-down")
}
>
<span aria-hidden>{deltaPct >= 0 ? "↑" : "↓"}</span>
{Math.abs(Math.round(deltaPct * 100))}% vs prior 30d
</div>
)}
</header>
<svg
ref={svgRef}
viewBox={`0 0 ${VIEW.width} ${VIEW.height}`}
preserveAspectRatio="none"
role="img"
aria-label={totalLabel}
className="portal-chart__svg"
tabIndex={points.length > 0 ? 0 : -1}
onMouseMove={onMouseMove}
onMouseLeave={() => setHoverIndex(null)}
onKeyDown={onKeyDown}
onBlur={() => setHoverIndex(null)}
>
<defs>
<linearGradient id="chart-fill" x1="0" y1="0" x2="0" y2="1">
<stop
offset="0%"
stopColor="var(--color-blue)"
stopOpacity="0.32"
/>
<stop offset="100%" stopColor="var(--color-blue)" stopOpacity="0" />
</linearGradient>
</defs>
{/* Y gridlines + labels */}
{yTicks.map((tick) => {
const innerH = VIEW.height - PADDING.top - PADDING.bottom;
const y = PADDING.top + innerH - (tick / yMax) * innerH;
return (
<g key={tick}>
<line
x1={PADDING.left}
x2={VIEW.width - PADDING.right}
y1={y}
y2={y}
className="portal-chart__gridline"
/>
<text
x={PADDING.left - 6}
y={y + 4}
className="portal-chart__axis-label"
textAnchor="end"
>
{formatNumber(tick)}
</text>
</g>
);
})}
{/* Area + line */}
<path d={areaPath} fill="url(#chart-fill)" />
<path d={linePath} className="portal-chart__line" />
{/* X ticks */}
{xTickIndices.map((idx) => {
const p = points[idx];
if (!p) return null;
return (
<text
key={idx}
x={p.x}
y={VIEW.height - PADDING.bottom + 18}
className="portal-chart__axis-label"
textAnchor="middle"
>
{formatTick(p.raw.date)}
</text>
);
})}
{/* Hover scrub + dot + tooltip */}
{hovered && (
<>
<line
x1={hovered.x}
x2={hovered.x}
y1={PADDING.top}
y2={VIEW.height - PADDING.bottom}
className="portal-chart__scrub"
/>
<circle
cx={hovered.x}
cy={hovered.y}
r={4}
className="portal-chart__dot"
/>
</>
)}
</svg>
{hovered && (
<div
className="portal-chart__tooltip"
style={{
left: `${(hovered.x / VIEW.width) * 100}%`,
}}
>
<div className="portal-chart__tooltip-date">
{new Date(hovered.raw.date).toLocaleDateString(undefined, {
weekday: "short",
month: "short",
day: "numeric",
})}
</div>
<div className="portal-chart__tooltip-value">
{hovered.raw.value.toLocaleString()} docs
</div>
</div>
)}
<div className="sr-only" aria-live="polite" aria-atomic="true">
{hovered
? `${new Date(hovered.raw.date).toLocaleDateString(undefined, {
weekday: "short",
month: "short",
day: "numeric",
})}: ${hovered.raw.value.toLocaleString()} docs`
: ""}
</div>
</div>
);
}
@@ -0,0 +1,234 @@
.portal-carousel {
position: relative;
overflow: hidden;
padding: 2rem;
border-radius: var(--radius-xl);
background: var(--grad-banner);
border: 1px solid var(--color-border);
isolation: isolate;
}
.portal-carousel__inner {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 20rem);
gap: 2rem;
align-items: center;
animation: fadeInUp var(--motion-enter) both;
}
@media (max-width: 50rem) {
.portal-carousel__inner {
grid-template-columns: 1fr;
}
}
.portal-carousel__text {
min-width: 0;
}
.portal-carousel__eyebrow {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-blue);
margin-bottom: 0.5rem;
}
.portal-carousel__title {
margin: 0 0 0.75rem;
font-size: 1.75rem;
font-weight: 700;
line-height: 1.15;
color: var(--color-text-1);
}
.portal-carousel__sub {
margin: 0 0 1.25rem;
font-size: 0.9375rem;
line-height: 1.55;
color: var(--color-text-3);
max-width: 36rem;
}
.portal-carousel__cta {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
/* Ornament container */
.portal-carousel__ornament {
display: flex;
align-items: center;
justify-content: center;
}
/* Doc card ornament (slide 0) */
.portal-carousel__doc {
width: 100%;
padding: 1rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
}
.portal-carousel__doc-title {
margin-top: 0.5rem;
font-size: 0.9375rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-carousel__doc-sub {
margin-top: 0.125rem;
font-size: 0.75rem;
color: var(--color-text-4);
}
.portal-carousel__doc-meta {
margin-top: 0.75rem;
font-size: 0.75rem;
color: var(--color-text-4);
display: flex;
gap: 0.375rem;
flex-wrap: wrap;
}
/* Code ornament (slide 1) */
.portal-carousel__code {
width: 100%;
margin: 0;
padding: 0.875rem 1rem;
background: var(--code-bg);
color: var(--code-text);
border: 1px solid var(--code-border);
border-radius: var(--radius-md);
font-family: var(--font-mono);
font-size: 0.75rem;
line-height: 1.65;
white-space: pre-wrap;
word-break: break-word;
box-shadow: var(--shadow-lg);
}
.portal-carousel__code-method {
color: var(--code-fn);
font-weight: 600;
}
.portal-carousel__code-path {
color: var(--code-keyword);
}
.portal-carousel__code-key {
color: var(--code-property);
}
.portal-carousel__code-string {
color: var(--code-string);
}
/* Eval ornament (slide 2) */
.portal-carousel__eval {
width: 100%;
padding: 1rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
}
.portal-carousel__eval-head {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.portal-carousel__eval-title {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-carousel__eval-score {
font-size: 1.125rem;
font-weight: 700;
color: var(--color-green);
}
.portal-carousel__eval-sub {
font-size: 0.75rem;
color: var(--color-text-4);
margin-top: 0.125rem;
}
.portal-carousel__eval-bar {
margin-top: 0.75rem;
height: 0.375rem;
background: var(--color-bg-muted);
border-radius: var(--radius-pill);
overflow: hidden;
}
.portal-carousel__eval-fill {
height: 100%;
background: var(--grad-green-btn);
}
.portal-carousel__eval-meta {
margin-top: 0.625rem;
display: flex;
justify-content: space-between;
font-size: 0.6875rem;
color: var(--color-text-4);
}
/* Decoration blobs */
.portal-carousel__decor {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
}
.portal-carousel__blob {
position: absolute;
border-radius: 50%;
filter: blur(2.5rem);
}
.portal-carousel__blob--1 {
top: -3rem;
right: -2rem;
width: 16rem;
height: 16rem;
background: radial-gradient(circle, var(--color-purple), transparent 70%);
opacity: 0.4;
}
.portal-carousel__blob--2 {
bottom: -4rem;
right: 8rem;
width: 12rem;
height: 12rem;
background: radial-gradient(circle, var(--color-blue), transparent 70%);
opacity: 0.3;
}
/* Pagination dots */
.portal-carousel__dots {
position: absolute;
bottom: 0.875rem;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 0.375rem;
z-index: 2;
}
.portal-carousel__dot {
width: 0.4375rem;
height: 0.4375rem;
border-radius: 50%;
background: var(--color-text-5);
opacity: 0.4;
transition:
opacity var(--motion-fast),
background var(--motion-fast),
width var(--motion-fast);
}
.portal-carousel__dot:hover {
opacity: 0.7;
}
.portal-carousel__dot.is-active {
width: 1rem;
border-radius: var(--radius-pill);
background: var(--color-blue);
opacity: 1;
}
@@ -0,0 +1,20 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { WelcomeCarousel } from "@portal/components/WelcomeCarousel";
const meta: Meta<typeof WelcomeCarousel> = {
title: "Portal/Home/WelcomeCarousel",
component: WelcomeCarousel,
args: { onTryOp: () => console.log("try op") },
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "64rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof WelcomeCarousel>;
export const AutoRotating: Story = {};
@@ -0,0 +1,215 @@
import { useEffect, useState, type ReactNode } from "react";
import { Button, StatusBadge } from "@shared/components";
import { useView, type ViewId } from "@portal/contexts/ViewContext";
import "@portal/components/WelcomeCarousel.css";
type SlideAction =
| { label: string; target: ViewId }
| { label: string; action: "try-op" };
interface Slide {
id: string;
eyebrow: string;
title: string;
sub: string;
durationMs: number;
primary: SlideAction;
secondary: SlideAction;
ornament: ReactNode;
}
function EditorOrnament() {
return (
<div className="portal-carousel__doc">
<StatusBadge tone="danger" size="sm" pulse>
Critical
</StatusBadge>
<div className="portal-carousel__doc-title">
Vulnerability Assessment Report
</div>
<div className="portal-carousel__doc-sub">CVE-2026-1847 · 12 pages</div>
<div className="portal-carousel__doc-meta">
<span>signed</span>
<span>·</span>
<span>OCR-clean</span>
<span>·</span>
<span>schema match 0.97</span>
</div>
</div>
);
}
function PlatformOrnament() {
return (
<pre className="portal-carousel__code">
<span className="portal-carousel__code-method">POST</span>{" "}
<span className="portal-carousel__code-path">/v1/secure</span>
{"\n"}
<span className="portal-carousel__code-key">Authorization</span>:{" "}
<span className="portal-carousel__code-string">Bearer sk_live_a3f8</span>
{"\n"}
<span className="portal-carousel__code-key">file</span>:{" "}
<span className="portal-carousel__code-string">
federal_CUI_contract.pdf
</span>
{"\n"}
<span className="portal-carousel__code-key">pipeline</span>:{" "}
<span className="portal-carousel__code-string">cui-redact-v2</span>
</pre>
);
}
function AgentOrnament() {
return (
<div className="portal-carousel__eval">
<div className="portal-carousel__eval-head">
<span className="portal-carousel__eval-title">KYC Processor</span>
<span className="portal-carousel__eval-score">94%</span>
</div>
<div className="portal-carousel__eval-sub">
Eval pass rate · 28 test cases
</div>
<div className="portal-carousel__eval-bar">
<div
className="portal-carousel__eval-fill"
style={{ width: "94%" }}
aria-hidden
/>
</div>
<div className="portal-carousel__eval-meta">
<span>26 passed</span>
<span>2 review</span>
<span>MCP · Claude</span>
</div>
</div>
);
}
const SLIDES: Slide[] = [
{
id: "editor",
eyebrow: "PDF Editor",
title: "The #1 PDF Editor on GitHub",
sub: "Annotate, sign, redact, and review locally or in the cloud. Brought to the platform as the credibility anchor of the Stirling control plane.",
durationMs: 12000,
primary: { label: "Install PDF Editor", target: "editor" },
secondary: { label: "Connect an instance", target: "editor" },
ornament: <EditorOrnament />,
},
{
id: "platform",
eyebrow: "Platform",
title: "PDF Infrastructure for Developers",
sub: "Ingest from agents, APIs and connectors. Run composable pipelines with evals and golden sets. Land in a vault with zero-standing-access controls.",
durationMs: 8000,
primary: { label: "Try a PDF operation", action: "try-op" },
secondary: { label: "Get an API key", target: "infrastructure" },
ornament: <PlatformOrnament />,
},
{
id: "agents",
eyebrow: "AI Agents",
title: "PDF Processor for AI Agents",
sub: "Wire your agent via MCP, REST or tool definitions. Deterministic operations and guardrails — test with scenarios and evals before you ship.",
durationMs: 8000,
primary: { label: "Try PDF Processor", target: "sources" },
secondary: { label: "View MCP docs", target: "docs" },
ornament: <AgentOrnament />,
},
];
interface WelcomeCarouselProps {
/** Called when a slide CTA wants to invoke the single-op runner. */
onTryOp: () => void;
}
export function WelcomeCarousel({ onTryOp }: WelcomeCarouselProps) {
const [index, setIndex] = useState(0);
const [paused, setPaused] = useState(false);
const { setActiveView } = useView();
function runAction(act: SlideAction) {
if ("action" in act && act.action === "try-op") {
onTryOp();
} else if ("target" in act) {
setActiveView(act.target);
}
}
useEffect(() => {
if (paused) return;
const duration = SLIDES[index].durationMs;
const t = window.setTimeout(() => {
setIndex((i) => (i + 1) % SLIDES.length);
}, duration);
return () => window.clearTimeout(t);
}, [index, paused]);
const slide = SLIDES[index];
return (
<section
className="portal-carousel"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onFocus={() => setPaused(true)}
onBlur={(e) => {
// onBlur bubbles from children; only unpause when focus actually
// leaves the carousel, not when tabbing between the dot buttons.
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
setPaused(false);
}
}}
aria-label="Stirling product highlights"
aria-roledescription="carousel"
>
<div className="portal-carousel__inner" key={slide.id}>
<div className="portal-carousel__text">
<div className="portal-carousel__eyebrow">{slide.eyebrow}</div>
<h1 className="portal-carousel__title">{slide.title}</h1>
<p className="portal-carousel__sub">{slide.sub}</p>
<div className="portal-carousel__cta">
<Button
variant="gradient"
onClick={() => runAction(slide.primary)}
trailingIcon={<span aria-hidden></span>}
>
{slide.primary.label}
</Button>
<Button
variant="outline"
onClick={() => runAction(slide.secondary)}
>
{slide.secondary.label}
</Button>
</div>
</div>
<div className="portal-carousel__ornament">{slide.ornament}</div>
</div>
<div className="portal-carousel__decor" aria-hidden>
<div className="portal-carousel__blob portal-carousel__blob--1" />
<div className="portal-carousel__blob portal-carousel__blob--2" />
</div>
<div
className="portal-carousel__dots"
role="group"
aria-label="Carousel pagination"
>
{SLIDES.map((s, i) => (
<button
key={s.id}
type="button"
aria-current={i === index ? "true" : undefined}
aria-label={`Slide ${i + 1}: ${s.title}`}
className={
"portal-carousel__dot" + (i === index ? " is-active" : "")
}
onClick={() => setIndex(i)}
/>
))}
</div>
</section>
);
}
+202
View File
@@ -0,0 +1,202 @@
import type { ReactNode } from "react";
interface IconProps {
size?: number;
className?: string;
}
function Svg({
size = 18,
className,
children,
}: IconProps & { children: ReactNode }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
{children}
</svg>
);
}
export function HomeIcon(props: IconProps) {
return (
<Svg {...props}>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</Svg>
);
}
export function EditorIcon(props: IconProps) {
return (
<Svg {...props}>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<path d="m10 13-2 2 2 2" />
<path d="m14 17 2-2-2-2" />
</Svg>
);
}
export function SourcesIcon(props: IconProps) {
return (
<Svg {...props}>
<path d="M9 2v6" />
<path d="M15 2v6" />
<path d="M6 8h12v3a6 6 0 0 1-12 0z" />
<path d="M12 14v8" />
</Svg>
);
}
export function PipelinesIcon(props: IconProps) {
return (
<Svg {...props}>
<rect x="3" y="3" width="6" height="6" rx="1" />
<rect x="15" y="3" width="6" height="6" rx="1" />
<rect x="9" y="15" width="6" height="6" rx="1" />
<path d="M6 9v3a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V9" />
<path d="M12 14v1" />
</Svg>
);
}
export function DocumentsIcon(props: IconProps) {
return (
<Svg {...props}>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="8" y1="13" x2="16" y2="13" />
<line x1="8" y1="17" x2="16" y2="17" />
<line x1="8" y1="9" x2="10" y2="9" />
</Svg>
);
}
export function InfrastructureIcon(props: IconProps) {
return (
<Svg {...props}>
<rect x="3" y="4" width="18" height="6" rx="1" />
<rect x="3" y="14" width="18" height="6" rx="1" />
<line x1="7" y1="7" x2="7.01" y2="7" />
<line x1="7" y1="17" x2="7.01" y2="17" />
</Svg>
);
}
export function UsageIcon(props: IconProps) {
return (
<Svg {...props}>
<line x1="6" y1="20" x2="6" y2="10" />
<line x1="12" y1="20" x2="12" y2="4" />
<line x1="18" y1="20" x2="18" y2="14" />
<line x1="3" y1="20" x2="21" y2="20" />
</Svg>
);
}
export function DocsIcon(props: IconProps) {
return (
<Svg {...props}>
<path d="M2 4.5A2.5 2.5 0 0 1 4.5 2H20v17H4.5A2.5 2.5 0 0 0 2 21.5z" />
<path d="M2 19.5A2.5 2.5 0 0 1 4.5 17H20" />
</Svg>
);
}
export function SettingsIcon(props: IconProps) {
return (
<Svg {...props}>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</Svg>
);
}
export function SearchIcon(props: IconProps) {
return (
<Svg {...props}>
<circle cx="11" cy="11" r="7" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</Svg>
);
}
export function SunIcon(props: IconProps) {
return (
<Svg {...props}>
<circle cx="12" cy="12" r="4" />
<line x1="12" y1="2" x2="12" y2="4" />
<line x1="12" y1="20" x2="12" y2="22" />
<line x1="4.93" y1="4.93" x2="6.34" y2="6.34" />
<line x1="17.66" y1="17.66" x2="19.07" y2="19.07" />
<line x1="2" y1="12" x2="4" y2="12" />
<line x1="20" y1="12" x2="22" y2="12" />
<line x1="4.93" y1="19.07" x2="6.34" y2="17.66" />
<line x1="17.66" y1="6.34" x2="19.07" y2="4.93" />
</Svg>
);
}
export function MoonIcon(props: IconProps) {
return (
<Svg {...props}>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</Svg>
);
}
export function BellIcon(props: IconProps) {
return (
<Svg {...props}>
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</Svg>
);
}
export function ChevronDownIcon(props: IconProps) {
return (
<Svg {...props}>
<polyline points="6 9 12 15 18 9" />
</Svg>
);
}
export function SparklesIcon(props: IconProps) {
return (
<Svg {...props}>
<path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z" />
<path d="M19 4v3" />
<path d="M17.5 5.5h3" />
</Svg>
);
}
export function CloseIcon(props: IconProps) {
return (
<Svg {...props}>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</Svg>
);
}
export function SendIcon(props: IconProps) {
return (
<Svg {...props}>
<line x1="22" y1="2" x2="11" y2="13" />
<polygon points="22 2 15 22 11 13 2 9 22 2" />
</Svg>
);
}