From 919f0ade997ce954128b99ddfa4388ba1d3212ab Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:08:24 +0100 Subject: [PATCH] Portal (#6391) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 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) --- .dockerignore | 5 +- .gitignore | 4 +- .taskfiles/frontend.yml | 45 +- LICENSE | 18 +- docker/frontend/Dockerfile | 2 +- frontend/.gitignore | 2 + frontend/.prettierignore | 2 + frontend/.storybook/main.ts | 42 + frontend/.storybook/preview.tsx | 142 ++ frontend/editor/scripts/tsconfig.json | 3 +- frontend/editor/src/core/tsconfig.json | 3 +- frontend/editor/src/desktop/tsconfig.json | 3 +- frontend/editor/src/proprietary/tsconfig.json | 3 +- frontend/editor/src/prototypes/tsconfig.json | 3 +- frontend/editor/src/saas/tsconfig.json | 3 +- frontend/editor/tsconfig.core.vite.json | 5 +- frontend/editor/tsconfig.desktop.vite.json | 6 +- frontend/editor/tsconfig.json | 3 +- .../editor/tsconfig.proprietary.vite.json | 10 +- frontend/editor/tsconfig.prototypes.vite.json | 6 +- frontend/editor/tsconfig.saas.vite.json | 6 +- frontend/eslint.config.mjs | 59 +- frontend/package-lock.json | 1715 +++++++++++++++++ frontend/package.json | 12 + frontend/portal/LICENSE | 51 + frontend/portal/index.html | 15 + frontend/portal/main.tsx | 34 + frontend/portal/public/mockServiceWorker.js | 349 ++++ frontend/portal/src/App.tsx | 75 + frontend/portal/src/ViewRouter.tsx | 67 + frontend/portal/src/api/assistant.ts | 15 + frontend/portal/src/api/endpoints.ts | 15 + frontend/portal/src/api/home.ts | 44 + frontend/portal/src/api/http.ts | 59 + frontend/portal/src/api/notifications.ts | 19 + frontend/portal/src/api/ops.ts | 37 + frontend/portal/src/api/search.ts | 9 + frontend/portal/src/components/AppShell.css | 19 + frontend/portal/src/components/AppShell.tsx | 21 + .../portal/src/components/AssistantButton.css | 35 + .../portal/src/components/AssistantButton.tsx | 36 + .../portal/src/components/AssistantPanel.css | 189 ++ .../src/components/AssistantPanel.stories.tsx | 59 + .../portal/src/components/AssistantPanel.tsx | 169 ++ .../src/components/DocumentTypeGrid.css | 227 +++ .../components/DocumentTypeGrid.stories.tsx | 41 + .../src/components/DocumentTypeGrid.tsx | 181 ++ frontend/portal/src/components/Header.css | 182 ++ .../portal/src/components/Header.stories.tsx | 26 + frontend/portal/src/components/Header.tsx | 104 + .../portal/src/components/MocksToggle.css | 51 + .../portal/src/components/MocksToggle.tsx | 54 + .../src/components/NotificationsDropdown.css | 137 ++ .../NotificationsDropdown.stories.tsx | 76 + .../src/components/NotificationsDropdown.tsx | 125 ++ .../portal/src/components/PopularUseCases.css | 87 + .../portal/src/components/PopularUseCases.tsx | 107 + .../portal/src/components/RecentActivity.css | 138 ++ .../src/components/RecentActivity.stories.tsx | 41 + .../portal/src/components/RecentActivity.tsx | 94 + .../portal/src/components/SearchModal.css | 70 + .../src/components/SearchModal.stories.tsx | 41 + .../portal/src/components/SearchModal.tsx | 121 ++ frontend/portal/src/components/Sidebar.css | 130 ++ .../portal/src/components/Sidebar.stories.tsx | 29 + frontend/portal/src/components/Sidebar.tsx | 174 ++ .../portal/src/components/SingleOpRunner.css | 345 ++++ .../src/components/SingleOpRunner.stories.tsx | 53 + .../portal/src/components/SingleOpRunner.tsx | 355 ++++ .../portal/src/components/UsageAreaChart.css | 115 ++ .../src/components/UsageAreaChart.stories.tsx | 53 + .../portal/src/components/UsageAreaChart.tsx | 278 +++ .../portal/src/components/WelcomeCarousel.css | 234 +++ .../components/WelcomeCarousel.stories.tsx | 20 + .../portal/src/components/WelcomeCarousel.tsx | 215 +++ frontend/portal/src/components/icons.tsx | 202 ++ frontend/portal/src/contexts/ThemeContext.tsx | 57 + frontend/portal/src/contexts/TierContext.tsx | 45 + frontend/portal/src/contexts/UIContext.tsx | 49 + frontend/portal/src/contexts/ViewContext.tsx | 73 + frontend/portal/src/hooks/useAsync.ts | 97 + frontend/portal/src/mocks/assistant.ts | 61 + frontend/portal/src/mocks/browser.ts | 23 + .../portal/src/mocks/handlers/assistant.ts | 21 + .../portal/src/mocks/handlers/endpoints.ts | 15 + frontend/portal/src/mocks/handlers/home.ts | 49 + frontend/portal/src/mocks/handlers/index.ts | 17 + .../src/mocks/handlers/notifications.ts | 21 + frontend/portal/src/mocks/handlers/ops.ts | 30 + frontend/portal/src/mocks/handlers/search.ts | 8 + frontend/portal/src/mocks/home.ts | 255 +++ frontend/portal/src/mocks/notifications.ts | 63 + frontend/portal/src/mocks/ops.ts | 166 ++ frontend/portal/src/mocks/preference.ts | 23 + frontend/portal/src/mocks/search.ts | 18 + .../src/theme/MantineIntegration.stories.tsx | 46 + frontend/portal/src/theme/mantineTheme.ts | 87 + frontend/portal/src/views/Documents.css | 5 + frontend/portal/src/views/Documents.tsx | 15 + frontend/portal/src/views/Home.css | 276 +++ frontend/portal/src/views/Home.stories.tsx | 29 + frontend/portal/src/views/Home.tsx | 553 ++++++ frontend/portal/src/views/Placeholder.css | 33 + .../portal/src/views/Placeholder.stories.tsx | 26 + frontend/portal/src/views/Placeholder.tsx | 27 + frontend/portal/tsconfig.json | 19 + frontend/portal/vite.config.ts | 65 + frontend/shared/components/Avatar.css | 68 + frontend/shared/components/Avatar.stories.tsx | 46 + frontend/shared/components/Avatar.tsx | 79 + frontend/shared/components/Banner.css | 94 + frontend/shared/components/Banner.stories.tsx | 70 + frontend/shared/components/Banner.tsx | 65 + frontend/shared/components/Button.css | 118 ++ frontend/shared/components/Button.stories.tsx | 57 + frontend/shared/components/Button.tsx | 68 + frontend/shared/components/Card.css | 62 + frontend/shared/components/Card.stories.tsx | 189 ++ frontend/shared/components/Card.tsx | 47 + frontend/shared/components/Checkbox.css | 89 + frontend/shared/components/Checkbox.tsx | 71 + frontend/shared/components/Chip.css | 96 + frontend/shared/components/Chip.stories.tsx | 79 + frontend/shared/components/Chip.tsx | 88 + frontend/shared/components/CodeBlock.css | 69 + .../shared/components/CodeBlock.stories.tsx | 85 + frontend/shared/components/CodeBlock.tsx | 82 + frontend/shared/components/Drawer.css | 110 ++ frontend/shared/components/Drawer.stories.tsx | 91 + frontend/shared/components/Drawer.tsx | 171 ++ frontend/shared/components/Dropdown.css | 77 + .../shared/components/Dropdown.stories.tsx | 82 + frontend/shared/components/Dropdown.tsx | 233 +++ frontend/shared/components/EmptyState.css | 57 + .../shared/components/EmptyState.stories.tsx | 52 + frontend/shared/components/EmptyState.tsx | 51 + frontend/shared/components/FormField.css | 33 + frontend/shared/components/FormField.tsx | 79 + frontend/shared/components/Forms.stories.tsx | 303 +++ frontend/shared/components/Inline.css | 76 + frontend/shared/components/Inline.stories.tsx | 54 + frontend/shared/components/Inline.tsx | 50 + frontend/shared/components/Input.css | 66 + frontend/shared/components/Input.tsx | 47 + frontend/shared/components/MethodBadge.css | 36 + .../shared/components/MethodBadge.stories.tsx | 42 + frontend/shared/components/MethodBadge.tsx | 28 + frontend/shared/components/MetricCard.css | 84 + .../shared/components/MetricCard.stories.tsx | 87 + frontend/shared/components/MetricCard.tsx | 98 + frontend/shared/components/Modal.css | 99 + frontend/shared/components/Modal.tsx | 171 ++ frontend/shared/components/NavItem.css | 47 + .../shared/components/NavItem.stories.tsx | 106 + frontend/shared/components/NavItem.tsx | 51 + frontend/shared/components/PanelHeader.css | 48 + .../shared/components/PanelHeader.stories.tsx | 61 + frontend/shared/components/PanelHeader.tsx | 60 + frontend/shared/components/ProgressBar.css | 13 + .../shared/components/ProgressBar.stories.tsx | 95 + frontend/shared/components/ProgressBar.tsx | 70 + frontend/shared/components/Radio.css | 77 + frontend/shared/components/Radio.tsx | 88 + frontend/shared/components/SectionDivider.css | 5 + .../components/SectionDivider.stories.tsx | 46 + frontend/shared/components/SectionDivider.tsx | 25 + frontend/shared/components/Select.css | 60 + frontend/shared/components/Select.tsx | 69 + frontend/shared/components/Skeleton.css | 30 + .../shared/components/Skeleton.stories.tsx | 62 + frontend/shared/components/Skeleton.tsx | 67 + frontend/shared/components/Slider.css | 68 + frontend/shared/components/Slider.tsx | 63 + frontend/shared/components/Spinner.css | 27 + .../shared/components/Spinner.stories.tsx | 45 + frontend/shared/components/Spinner.tsx | 28 + frontend/shared/components/Stack.css | 76 + frontend/shared/components/Stack.stories.tsx | 70 + frontend/shared/components/Stack.tsx | 67 + frontend/shared/components/StatusBadge.css | 70 + .../shared/components/StatusBadge.stories.tsx | 53 + frontend/shared/components/StatusBadge.tsx | 56 + frontend/shared/components/Tabs.css | 77 + frontend/shared/components/Tabs.stories.tsx | 93 + frontend/shared/components/Tabs.tsx | 88 + frontend/shared/components/Toast.css | 70 + frontend/shared/components/Toast.stories.tsx | 71 + frontend/shared/components/Toast.tsx | 141 ++ frontend/shared/components/ToggleSwitch.css | 78 + .../components/ToggleSwitch.stories.tsx | 89 + frontend/shared/components/ToggleSwitch.tsx | 59 + frontend/shared/components/index.ts | 38 + frontend/shared/data/Endpoints.stories.tsx | 116 ++ frontend/shared/data/Ops.stories.tsx | 306 +++ frontend/shared/data/endpoints.ts | 1270 ++++++++++++ frontend/shared/data/ops.ts | 1308 +++++++++++++ frontend/shared/tokens/Tokens.stories.tsx | 262 +++ frontend/shared/tokens/base.css | 39 + frontend/shared/tokens/tokens.css | 419 ++++ frontend/shared/tokens/tokens.ts | 14 + frontend/shared/tsconfig.json | 18 + 201 files changed, 19912 insertions(+), 43 deletions(-) create mode 100644 frontend/.storybook/main.ts create mode 100644 frontend/.storybook/preview.tsx create mode 100644 frontend/portal/LICENSE create mode 100644 frontend/portal/index.html create mode 100644 frontend/portal/main.tsx create mode 100644 frontend/portal/public/mockServiceWorker.js create mode 100644 frontend/portal/src/App.tsx create mode 100644 frontend/portal/src/ViewRouter.tsx create mode 100644 frontend/portal/src/api/assistant.ts create mode 100644 frontend/portal/src/api/endpoints.ts create mode 100644 frontend/portal/src/api/home.ts create mode 100644 frontend/portal/src/api/http.ts create mode 100644 frontend/portal/src/api/notifications.ts create mode 100644 frontend/portal/src/api/ops.ts create mode 100644 frontend/portal/src/api/search.ts create mode 100644 frontend/portal/src/components/AppShell.css create mode 100644 frontend/portal/src/components/AppShell.tsx create mode 100644 frontend/portal/src/components/AssistantButton.css create mode 100644 frontend/portal/src/components/AssistantButton.tsx create mode 100644 frontend/portal/src/components/AssistantPanel.css create mode 100644 frontend/portal/src/components/AssistantPanel.stories.tsx create mode 100644 frontend/portal/src/components/AssistantPanel.tsx create mode 100644 frontend/portal/src/components/DocumentTypeGrid.css create mode 100644 frontend/portal/src/components/DocumentTypeGrid.stories.tsx create mode 100644 frontend/portal/src/components/DocumentTypeGrid.tsx create mode 100644 frontend/portal/src/components/Header.css create mode 100644 frontend/portal/src/components/Header.stories.tsx create mode 100644 frontend/portal/src/components/Header.tsx create mode 100644 frontend/portal/src/components/MocksToggle.css create mode 100644 frontend/portal/src/components/MocksToggle.tsx create mode 100644 frontend/portal/src/components/NotificationsDropdown.css create mode 100644 frontend/portal/src/components/NotificationsDropdown.stories.tsx create mode 100644 frontend/portal/src/components/NotificationsDropdown.tsx create mode 100644 frontend/portal/src/components/PopularUseCases.css create mode 100644 frontend/portal/src/components/PopularUseCases.tsx create mode 100644 frontend/portal/src/components/RecentActivity.css create mode 100644 frontend/portal/src/components/RecentActivity.stories.tsx create mode 100644 frontend/portal/src/components/RecentActivity.tsx create mode 100644 frontend/portal/src/components/SearchModal.css create mode 100644 frontend/portal/src/components/SearchModal.stories.tsx create mode 100644 frontend/portal/src/components/SearchModal.tsx create mode 100644 frontend/portal/src/components/Sidebar.css create mode 100644 frontend/portal/src/components/Sidebar.stories.tsx create mode 100644 frontend/portal/src/components/Sidebar.tsx create mode 100644 frontend/portal/src/components/SingleOpRunner.css create mode 100644 frontend/portal/src/components/SingleOpRunner.stories.tsx create mode 100644 frontend/portal/src/components/SingleOpRunner.tsx create mode 100644 frontend/portal/src/components/UsageAreaChart.css create mode 100644 frontend/portal/src/components/UsageAreaChart.stories.tsx create mode 100644 frontend/portal/src/components/UsageAreaChart.tsx create mode 100644 frontend/portal/src/components/WelcomeCarousel.css create mode 100644 frontend/portal/src/components/WelcomeCarousel.stories.tsx create mode 100644 frontend/portal/src/components/WelcomeCarousel.tsx create mode 100644 frontend/portal/src/components/icons.tsx create mode 100644 frontend/portal/src/contexts/ThemeContext.tsx create mode 100644 frontend/portal/src/contexts/TierContext.tsx create mode 100644 frontend/portal/src/contexts/UIContext.tsx create mode 100644 frontend/portal/src/contexts/ViewContext.tsx create mode 100644 frontend/portal/src/hooks/useAsync.ts create mode 100644 frontend/portal/src/mocks/assistant.ts create mode 100644 frontend/portal/src/mocks/browser.ts create mode 100644 frontend/portal/src/mocks/handlers/assistant.ts create mode 100644 frontend/portal/src/mocks/handlers/endpoints.ts create mode 100644 frontend/portal/src/mocks/handlers/home.ts create mode 100644 frontend/portal/src/mocks/handlers/index.ts create mode 100644 frontend/portal/src/mocks/handlers/notifications.ts create mode 100644 frontend/portal/src/mocks/handlers/ops.ts create mode 100644 frontend/portal/src/mocks/handlers/search.ts create mode 100644 frontend/portal/src/mocks/home.ts create mode 100644 frontend/portal/src/mocks/notifications.ts create mode 100644 frontend/portal/src/mocks/ops.ts create mode 100644 frontend/portal/src/mocks/preference.ts create mode 100644 frontend/portal/src/mocks/search.ts create mode 100644 frontend/portal/src/theme/MantineIntegration.stories.tsx create mode 100644 frontend/portal/src/theme/mantineTheme.ts create mode 100644 frontend/portal/src/views/Documents.css create mode 100644 frontend/portal/src/views/Documents.tsx create mode 100644 frontend/portal/src/views/Home.css create mode 100644 frontend/portal/src/views/Home.stories.tsx create mode 100644 frontend/portal/src/views/Home.tsx create mode 100644 frontend/portal/src/views/Placeholder.css create mode 100644 frontend/portal/src/views/Placeholder.stories.tsx create mode 100644 frontend/portal/src/views/Placeholder.tsx create mode 100644 frontend/portal/tsconfig.json create mode 100644 frontend/portal/vite.config.ts create mode 100644 frontend/shared/components/Avatar.css create mode 100644 frontend/shared/components/Avatar.stories.tsx create mode 100644 frontend/shared/components/Avatar.tsx create mode 100644 frontend/shared/components/Banner.css create mode 100644 frontend/shared/components/Banner.stories.tsx create mode 100644 frontend/shared/components/Banner.tsx create mode 100644 frontend/shared/components/Button.css create mode 100644 frontend/shared/components/Button.stories.tsx create mode 100644 frontend/shared/components/Button.tsx create mode 100644 frontend/shared/components/Card.css create mode 100644 frontend/shared/components/Card.stories.tsx create mode 100644 frontend/shared/components/Card.tsx create mode 100644 frontend/shared/components/Checkbox.css create mode 100644 frontend/shared/components/Checkbox.tsx create mode 100644 frontend/shared/components/Chip.css create mode 100644 frontend/shared/components/Chip.stories.tsx create mode 100644 frontend/shared/components/Chip.tsx create mode 100644 frontend/shared/components/CodeBlock.css create mode 100644 frontend/shared/components/CodeBlock.stories.tsx create mode 100644 frontend/shared/components/CodeBlock.tsx create mode 100644 frontend/shared/components/Drawer.css create mode 100644 frontend/shared/components/Drawer.stories.tsx create mode 100644 frontend/shared/components/Drawer.tsx create mode 100644 frontend/shared/components/Dropdown.css create mode 100644 frontend/shared/components/Dropdown.stories.tsx create mode 100644 frontend/shared/components/Dropdown.tsx create mode 100644 frontend/shared/components/EmptyState.css create mode 100644 frontend/shared/components/EmptyState.stories.tsx create mode 100644 frontend/shared/components/EmptyState.tsx create mode 100644 frontend/shared/components/FormField.css create mode 100644 frontend/shared/components/FormField.tsx create mode 100644 frontend/shared/components/Forms.stories.tsx create mode 100644 frontend/shared/components/Inline.css create mode 100644 frontend/shared/components/Inline.stories.tsx create mode 100644 frontend/shared/components/Inline.tsx create mode 100644 frontend/shared/components/Input.css create mode 100644 frontend/shared/components/Input.tsx create mode 100644 frontend/shared/components/MethodBadge.css create mode 100644 frontend/shared/components/MethodBadge.stories.tsx create mode 100644 frontend/shared/components/MethodBadge.tsx create mode 100644 frontend/shared/components/MetricCard.css create mode 100644 frontend/shared/components/MetricCard.stories.tsx create mode 100644 frontend/shared/components/MetricCard.tsx create mode 100644 frontend/shared/components/Modal.css create mode 100644 frontend/shared/components/Modal.tsx create mode 100644 frontend/shared/components/NavItem.css create mode 100644 frontend/shared/components/NavItem.stories.tsx create mode 100644 frontend/shared/components/NavItem.tsx create mode 100644 frontend/shared/components/PanelHeader.css create mode 100644 frontend/shared/components/PanelHeader.stories.tsx create mode 100644 frontend/shared/components/PanelHeader.tsx create mode 100644 frontend/shared/components/ProgressBar.css create mode 100644 frontend/shared/components/ProgressBar.stories.tsx create mode 100644 frontend/shared/components/ProgressBar.tsx create mode 100644 frontend/shared/components/Radio.css create mode 100644 frontend/shared/components/Radio.tsx create mode 100644 frontend/shared/components/SectionDivider.css create mode 100644 frontend/shared/components/SectionDivider.stories.tsx create mode 100644 frontend/shared/components/SectionDivider.tsx create mode 100644 frontend/shared/components/Select.css create mode 100644 frontend/shared/components/Select.tsx create mode 100644 frontend/shared/components/Skeleton.css create mode 100644 frontend/shared/components/Skeleton.stories.tsx create mode 100644 frontend/shared/components/Skeleton.tsx create mode 100644 frontend/shared/components/Slider.css create mode 100644 frontend/shared/components/Slider.tsx create mode 100644 frontend/shared/components/Spinner.css create mode 100644 frontend/shared/components/Spinner.stories.tsx create mode 100644 frontend/shared/components/Spinner.tsx create mode 100644 frontend/shared/components/Stack.css create mode 100644 frontend/shared/components/Stack.stories.tsx create mode 100644 frontend/shared/components/Stack.tsx create mode 100644 frontend/shared/components/StatusBadge.css create mode 100644 frontend/shared/components/StatusBadge.stories.tsx create mode 100644 frontend/shared/components/StatusBadge.tsx create mode 100644 frontend/shared/components/Tabs.css create mode 100644 frontend/shared/components/Tabs.stories.tsx create mode 100644 frontend/shared/components/Tabs.tsx create mode 100644 frontend/shared/components/Toast.css create mode 100644 frontend/shared/components/Toast.stories.tsx create mode 100644 frontend/shared/components/Toast.tsx create mode 100644 frontend/shared/components/ToggleSwitch.css create mode 100644 frontend/shared/components/ToggleSwitch.stories.tsx create mode 100644 frontend/shared/components/ToggleSwitch.tsx create mode 100644 frontend/shared/components/index.ts create mode 100644 frontend/shared/data/Endpoints.stories.tsx create mode 100644 frontend/shared/data/Ops.stories.tsx create mode 100644 frontend/shared/data/endpoints.ts create mode 100644 frontend/shared/data/ops.ts create mode 100644 frontend/shared/tokens/Tokens.stories.tsx create mode 100644 frontend/shared/tokens/base.css create mode 100644 frontend/shared/tokens/tokens.css create mode 100644 frontend/shared/tokens/tokens.ts create mode 100644 frontend/shared/tsconfig.json diff --git a/.dockerignore b/.dockerignore index 85355537d..33be4b6ee 100644 --- a/.dockerignore +++ b/.dockerignore @@ -26,8 +26,9 @@ version_builds/ node_modules/ **/node_modules/ frontend/node_modules/ -frontend/dist/ -frontend/playwright-report/ +frontend/editor/dist/ +frontend/dist-portal/ +frontend/editor/playwright-report/ .npm/ .yarn/ diff --git a/.gitignore b/.gitignore index 1b7e69c49..9e221c7eb 100644 --- a/.gitignore +++ b/.gitignore @@ -212,7 +212,7 @@ out/ *.asc # Allow test fixture certificates (synthetic, no real credentials) -!frontend/src/core/tests/test-fixtures/certs/** +!frontend/editor/src/core/tests/test-fixtures/certs/** # SSH Keys *.pub @@ -254,7 +254,7 @@ node_modules/ *compact*.json test_batch.json *.backup.*.json -frontend/public/locales/*/translation.backup*.json +frontend/editor/public/locales/*/translation.backup*.json # Development/build artifacts .gradle-cache/ diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index 9e6f87889..b2fc9d45d 100644 --- a/.taskfiles/frontend.yml +++ b/.taskfiles/frontend.yml @@ -112,6 +112,12 @@ tasks: - task: dev:_run vars: { MODE: prototypes, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' } + dev:portal: + desc: "Start developer portal dev server" + deps: [install] + cmds: + - npx vite portal --port {{.PORT | default "5173"}}{{if .OPEN}} --open{{end}} + # ============================================================ # Build # ============================================================ @@ -156,6 +162,24 @@ tasks: cmds: - npx vite build editor --mode prototypes + build:portal: + desc: "Build developer portal" + deps: [install] + cmds: + - npx vite build portal + + storybook: + desc: "Start Storybook dev server" + deps: [install] + cmds: + - npx storybook dev -p 6006 {{.CLI_ARGS}} + + storybook:build: + desc: "Build static Storybook" + deps: [install] + cmds: + - npx storybook build {{.CLI_ARGS}} + # ============================================================ # Code quality # ============================================================ @@ -165,7 +189,10 @@ tasks: deps: [install] cmds: - npx eslint --max-warnings=0 - - npx dpdm editor/src --circular --no-warning --no-tree --exit-code circular:1 + # Globs (not a bare dir) so dpdm walks the whole tree — `editor/src` + # alone matched only 2 files. dpdm expands the braces itself, so this is + # shell-agnostic. Covers editor, portal, and the shared design system. + - 'npx dpdm "editor/src/**/*.{ts,tsx}" "portal/src/**/*.{ts,tsx}" "shared/**/*.{ts,tsx}" --circular --no-warning --no-tree --exit-code circular:1' lint:fix: desc: "Auto-fix lint issues" @@ -236,6 +263,18 @@ tasks: cmds: - npx tsc --noEmit --project editor/src/prototypes/tsconfig.json + typecheck:portal: + desc: "Typecheck developer portal build variant" + deps: [install] + cmds: + - npx tsc --noEmit --project portal/tsconfig.json + + typecheck:shared: + desc: "Typecheck the shared design system" + deps: [install] + cmds: + - npx tsc --noEmit --project shared/tsconfig.json + typecheck:all: desc: "Typecheck all build variants" cmds: @@ -245,6 +284,8 @@ tasks: - task: typecheck:desktop - task: typecheck:scripts - task: typecheck:prototypes + - task: typecheck:portal + - task: typecheck:shared # ============================================================ # Quality Gate @@ -265,7 +306,9 @@ tasks: - task: lint - task: format:check - task: build + - task: build:portal - task: test + - task: storybook:build # ============================================================ # Test diff --git a/LICENSE b/LICENSE index 2cf4a972a..efc31d3a4 100644 --- a/LICENSE +++ b/LICENSE @@ -10,14 +10,16 @@ if that directory exists, is licensed under the license defined in "app/propriet if that directory exists, is licensed under the license defined in "app/saas/LICENSE". * All content that resides under the "engine/" directory of this repository, if that directory exists, is licensed under the license defined in "engine/LICENSE". -* All content that resides under the "frontend/src/proprietary/" directory of this repository, -if that directory exists, is licensed under the license defined in "frontend/src/proprietary/LICENSE". -* All content that resides under the "frontend/src/desktop/" directory of this repository, -if that directory exists, is licensed under the license defined in "frontend/src/desktop/LICENSE". -* All content that resides under the "frontend/src/saas/" directory of this repository, -if that directory exists, is licensed under the license defined in "frontend/src/saas/LICENSE". -* All content that resides under the "frontend/src/prototypes/" directory of this repository, -if that directory exists, is licensed under the license defined in "frontend/src/prototypes/LICENSE". +* All content that resides under the "frontend/editor/src/proprietary/" directory of this repository, +if that directory exists, is licensed under the license defined in "frontend/editor/src/proprietary/LICENSE". +* All content that resides under the "frontend/editor/src/desktop/" directory of this repository, +if that directory exists, is licensed under the license defined in "frontend/editor/src/desktop/LICENSE". +* All content that resides under the "frontend/editor/src/saas/" directory of this repository, +if that directory exists, is licensed under the license defined in "frontend/editor/src/saas/LICENSE". +* All content that resides under the "frontend/editor/src/prototypes/" directory of this repository, +if that directory exists, is licensed under the license defined in "frontend/editor/src/prototypes/LICENSE". +* All content that resides under the "frontend/portal/" directory of this repository, +if that directory exists, is licensed under the license defined in "frontend/portal/LICENSE". * Content outside of the above mentioned directories or restrictions above is available under the MIT License as defined below. diff --git a/docker/frontend/Dockerfile b/docker/frontend/Dockerfile index 9227f516a..e92f19ddc 100644 --- a/docker/frontend/Dockerfile +++ b/docker/frontend/Dockerfile @@ -13,7 +13,7 @@ RUN npm ci COPY frontend . # Build the application (vite root is editor/, output lands in editor/dist/) -RUN npx vite --root editor build +RUN npx vite build editor # Production stage FROM nginx:alpine@sha256:b0f7830b6bfaa1258f45d94c240ab668ced1b3651c8a222aefe6683447c7bf55 diff --git a/frontend/.gitignore b/frontend/.gitignore index 159c76ff5..f881e6fb0 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -11,6 +11,8 @@ # production /build /dist +/dist-portal +/storybook-static /editor/build /editor/dist diff --git a/frontend/.prettierignore b/frontend/.prettierignore index c8240371d..0dec02ff5 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -8,6 +8,8 @@ editor/src-tauri/**/target/ editor/src-tauri/gen/ node_modules/ editor/public/vendor/ +# Auto-generated by MSW (`msw init`); regenerated verbatim, not hand-formatted. +portal/public/mockServiceWorker.js editor/public/pdfjs*/ editor/public/js/thirdParty/ editor/public/css/cookieconsent.css diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts new file mode 100644 index 000000000..7a53d8e9c --- /dev/null +++ b/frontend/.storybook/main.ts @@ -0,0 +1,42 @@ +import { resolve } from "node:path"; +import type { StorybookConfig } from "@storybook/react-vite"; + +/** + * Storybook 9 ships essentials, interactions, and docs as built-ins, so the + * addon list is just the extras we want: theme switching + a11y auditing. + * + * Story files live next to their components in shared/ and portal/src/. + * MDX docs pages live in portal/src/docs/. + */ +const config: StorybookConfig = { + stories: [ + "../portal/src/**/*.mdx", + "../portal/src/**/*.stories.@(ts|tsx)", + "../shared/**/*.mdx", + "../shared/**/*.stories.@(ts|tsx)", + ], + addons: ["@storybook/addon-themes", "@storybook/addon-a11y"], + framework: { + name: "@storybook/react-vite", + options: {}, + }, + typescript: { + reactDocgen: "react-docgen-typescript", + }, + // Serve the MSW worker file from portal/public so Storybook can intercept + // network calls the same way the dev portal does. + staticDirs: ["../portal/public"], + viteFinal: async (config) => { + // Wire @portal/* and @shared/* aliases directly on the Storybook bundler so + // story imports resolve without needing the portal's vite config. + config.resolve = config.resolve ?? {}; + config.resolve.alias = { + ...(config.resolve.alias ?? {}), + "@portal": resolve(__dirname, "../portal/src"), + "@shared": resolve(__dirname, "../shared"), + }; + return config; + }, +}; + +export default config; diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx new file mode 100644 index 000000000..c94b16973 --- /dev/null +++ b/frontend/.storybook/preview.tsx @@ -0,0 +1,142 @@ +// Storybook compiles .storybook/* with the classic JSX runtime, so the JSX in +// the decorators below transpiles to React.createElement and needs React in +// scope. (The app + story files use the automatic runtime via the portal vite +// config; this import is specifically for the preview config file.) +import React, { useEffect } from "react"; +import type { Decorator, Preview } from "@storybook/react-vite"; +import { initialize, mswLoader } from "msw-storybook-addon"; +import { MemoryRouter } from "react-router-dom"; +import { withThemeByDataAttribute } from "@storybook/addon-themes"; +import { MantineProvider } from "@mantine/core"; + +// Reference React so the import isn't dropped as unused by the bundler — the +// classic runtime needs it present even though it's not named in the JSX. +void React; + +import { TierProvider, type Tier } from "@portal/contexts/TierContext"; +import { ThemeProvider } from "@portal/contexts/ThemeContext"; +import { UIProvider } from "@portal/contexts/UIContext"; +import { mantineTheme } from "@portal/theme/mantineTheme"; +import { handlers } from "@portal/mocks/handlers"; + +import "@mantine/core/styles.css"; +import "@shared/tokens/tokens.css"; +import "@shared/tokens/base.css"; + +// Start MSW once. Storybook runs in a browser so this uses the service worker. +initialize({ onUnhandledRequest: "bypass" }, handlers); + +/** + * Bridge between Storybook's `tier` global toolbar and the actual TierProvider. + * Without this the toolbar would just change a label; with it, every story + * that calls useTier() reflects the active toolbar value. + */ +function TierBridge({ + tier, + children, +}: { + tier: Tier; + children: React.ReactNode; +}) { + return {children}; +} + +/** Forces the TierProvider to re-mount whenever the toolbar tier changes. */ +function TierKey({ + tier, + children, +}: { + tier: Tier; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} + +/** Keeps useTheme() and the data-theme attribute in sync. */ +function ThemeWatcher() { + useEffect(() => { + // The addon-themes decorator already sets data-theme on . + // We just read it on mount so ThemeProvider picks it up. + }, []); + return null; +} + +const withProviders: Decorator = (Story, context) => { + const tier = (context.globals.tier as Tier) ?? "pro"; + // withThemeByDataAttribute exposes the toolbar theme as the `theme` global. + // Bind Mantine's color scheme to it so Mantine chrome (inputs, focus rings, + // default surfaces) follows the dark toggle alongside the SUI CSS variables. + // The global initialises to "" (before any toolbar interaction), so treat + // anything that isn't "dark" as light — matching the addon's own + // `selected || defaultTheme` fallback where defaultTheme is light. + const colorScheme = context.globals.theme === "dark" ? "dark" : "light"; + return ( + + + + + + + + + + + + + ); +}; + +const preview: Preview = { + loaders: [mswLoader], + parameters: { + layout: "padded", + controls: { + matchers: { color: /(background|color)$/i, date: /Date$/i }, + }, + backgrounds: { + default: "app", + values: [ + { name: "app", value: "var(--color-bg)" }, + { name: "surface", value: "var(--color-surface)" }, + ], + }, + a11y: { + // Run axe automatically against the story root; violations show in the + // Accessibility panel. `context` replaced `element` in addon-a11y 9.x. + context: "#storybook-root", + config: {}, + options: {}, + test: "todo", + }, + }, + globalTypes: { + tier: { + name: "Tier", + description: "Subscription tier — drives useTier() everywhere", + defaultValue: "pro", + toolbar: { + icon: "star", + items: [ + { value: "free", title: "Free" }, + { value: "pro", title: "Pay-as-you-go" }, + { value: "enterprise", title: "Enterprise" }, + ], + dynamicTitle: true, + }, + }, + }, + decorators: [ + withProviders, + withThemeByDataAttribute({ + themes: { light: "light", dark: "dark" }, + defaultTheme: "light", + attributeName: "data-theme", + }), + ], +}; + +export default preview; diff --git a/frontend/editor/scripts/tsconfig.json b/frontend/editor/scripts/tsconfig.json index f6f5b9911..5f95926cb 100644 --- a/frontend/editor/scripts/tsconfig.json +++ b/frontend/editor/scripts/tsconfig.json @@ -5,6 +5,5 @@ "moduleResolution": "node16", "noEmit": true }, - "include": ["./**/*.ts", "./**/*.mts"], - "exclude": [] + "include": ["./**/*.ts", "./**/*.mts"] } diff --git a/frontend/editor/src/core/tsconfig.json b/frontend/editor/src/core/tsconfig.json index 51c970d00..655b1900f 100644 --- a/frontend/editor/src/core/tsconfig.json +++ b/frontend/editor/src/core/tsconfig.json @@ -3,7 +3,8 @@ "compilerOptions": { "baseUrl": "../../", "paths": { - "@app/*": ["src/core/*"] + "@app/*": ["src/core/*"], + "@shared/*": ["../shared/*"] } }, "include": ["../global.d.ts", "../*.js", "../*.ts", "../*.tsx", "."] diff --git a/frontend/editor/src/desktop/tsconfig.json b/frontend/editor/src/desktop/tsconfig.json index 7fa180a62..f50dc35a6 100644 --- a/frontend/editor/src/desktop/tsconfig.json +++ b/frontend/editor/src/desktop/tsconfig.json @@ -5,7 +5,8 @@ "paths": { "@app/*": ["src/desktop/*", "src/proprietary/*", "src/core/*"], "@proprietary/*": ["src/proprietary/*"], - "@core/*": ["src/core/*"] + "@core/*": ["src/core/*"], + "@shared/*": ["../shared/*"] } }, "include": [ diff --git a/frontend/editor/src/proprietary/tsconfig.json b/frontend/editor/src/proprietary/tsconfig.json index 14644446d..16a21a4c9 100644 --- a/frontend/editor/src/proprietary/tsconfig.json +++ b/frontend/editor/src/proprietary/tsconfig.json @@ -4,7 +4,8 @@ "baseUrl": "../../", "paths": { "@app/*": ["src/proprietary/*", "src/core/*"], - "@core/*": ["src/core/*"] + "@core/*": ["src/core/*"], + "@shared/*": ["../shared/*"] } }, "include": [ diff --git a/frontend/editor/src/prototypes/tsconfig.json b/frontend/editor/src/prototypes/tsconfig.json index 44e477e98..e632856a4 100644 --- a/frontend/editor/src/prototypes/tsconfig.json +++ b/frontend/editor/src/prototypes/tsconfig.json @@ -5,7 +5,8 @@ "paths": { "@app/*": ["src/prototypes/*", "src/proprietary/*", "src/core/*"], "@proprietary/*": ["src/proprietary/*"], - "@core/*": ["src/core/*"] + "@core/*": ["src/core/*"], + "@shared/*": ["../shared/*"] } }, "include": [ diff --git a/frontend/editor/src/saas/tsconfig.json b/frontend/editor/src/saas/tsconfig.json index e64e8b099..1e59f4590 100644 --- a/frontend/editor/src/saas/tsconfig.json +++ b/frontend/editor/src/saas/tsconfig.json @@ -5,7 +5,8 @@ "paths": { "@app/*": ["src/saas/*", "src/proprietary/*", "src/core/*"], "@proprietary/*": ["src/proprietary/*"], - "@core/*": ["src/core/*"] + "@core/*": ["src/core/*"], + "@shared/*": ["../shared/*"] } }, "include": ["../global.d.ts", "../*.js", "../*.ts", "../*.tsx", "."] diff --git a/frontend/editor/tsconfig.core.vite.json b/frontend/editor/tsconfig.core.vite.json index daa02a7f0..9d298ab5a 100644 --- a/frontend/editor/tsconfig.core.vite.json +++ b/frontend/editor/tsconfig.core.vite.json @@ -2,8 +2,9 @@ "extends": "./tsconfig.json", "compilerOptions": { "paths": { - "@app/*": ["src/core/*"] + "@app/*": ["src/core/*"], + "@shared/*": ["../shared/*"] } }, - "exclude": ["src/proprietary", "src/desktop", "node_modules"] + "exclude": ["src/proprietary", "src/desktop"] } diff --git a/frontend/editor/tsconfig.desktop.vite.json b/frontend/editor/tsconfig.desktop.vite.json index abdd28a85..ee4556ade 100644 --- a/frontend/editor/tsconfig.desktop.vite.json +++ b/frontend/editor/tsconfig.desktop.vite.json @@ -4,14 +4,14 @@ "paths": { "@app/*": ["src/desktop/*", "src/proprietary/*", "src/core/*"], "@proprietary/*": ["src/proprietary/*"], - "@core/*": ["src/core/*"] + "@core/*": ["src/core/*"], + "@shared/*": ["../shared/*"] } }, "exclude": [ "src/core/**/*.test.ts*", "src/core/**/*.spec.ts*", "src/proprietary/**/*.test.ts*", - "src/proprietary/**/*.spec.ts*", - "node_modules" + "src/proprietary/**/*.spec.ts*" ] } diff --git a/frontend/editor/tsconfig.json b/frontend/editor/tsconfig.json index 37fc14efd..ad5870a5f 100644 --- a/frontend/editor/tsconfig.json +++ b/frontend/editor/tsconfig.json @@ -34,7 +34,8 @@ /* Specify a set of entries that re-map imports to additional lookup locations. */ "@app/*": ["src/desktop/*", "src/proprietary/*", "src/core/*"], "@proprietary/*": ["src/proprietary/*"], - "@core/*": ["src/core/*"] + "@core/*": ["src/core/*"], + "@shared/*": ["../shared/*"] }, // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ diff --git a/frontend/editor/tsconfig.proprietary.vite.json b/frontend/editor/tsconfig.proprietary.vite.json index 2cde30d2b..78c638ddb 100644 --- a/frontend/editor/tsconfig.proprietary.vite.json +++ b/frontend/editor/tsconfig.proprietary.vite.json @@ -4,13 +4,9 @@ "paths": { "@app/*": ["src/proprietary/*", "src/core/*"], "@proprietary/*": ["src/proprietary/*"], - "@core/*": ["src/core/*"] + "@core/*": ["src/core/*"], + "@shared/*": ["../shared/*"] } }, - "exclude": [ - "src/core/**/*.test.ts*", - "src/core/**/*.spec.ts*", - "src/desktop", - "node_modules" - ] + "exclude": ["src/core/**/*.test.ts*", "src/core/**/*.spec.ts*", "src/desktop"] } diff --git a/frontend/editor/tsconfig.prototypes.vite.json b/frontend/editor/tsconfig.prototypes.vite.json index eae017ee6..2e14c541b 100644 --- a/frontend/editor/tsconfig.prototypes.vite.json +++ b/frontend/editor/tsconfig.prototypes.vite.json @@ -4,7 +4,8 @@ "paths": { "@app/*": ["src/prototypes/*", "src/proprietary/*", "src/core/*"], "@proprietary/*": ["src/proprietary/*"], - "@core/*": ["src/core/*"] + "@core/*": ["src/core/*"], + "@shared/*": ["../shared/*"] } }, "exclude": [ @@ -13,7 +14,6 @@ "src/proprietary/**/*.test.ts*", "src/proprietary/**/*.spec.ts*", "src/desktop", - "src/saas", - "node_modules" + "src/saas" ] } diff --git a/frontend/editor/tsconfig.saas.vite.json b/frontend/editor/tsconfig.saas.vite.json index c0cced8c0..0ab501bf9 100644 --- a/frontend/editor/tsconfig.saas.vite.json +++ b/frontend/editor/tsconfig.saas.vite.json @@ -4,7 +4,8 @@ "paths": { "@app/*": ["src/saas/*", "src/proprietary/*", "src/core/*"], "@proprietary/*": ["src/proprietary/*"], - "@core/*": ["src/core/*"] + "@core/*": ["src/core/*"], + "@shared/*": ["../shared/*"] } }, "exclude": [ @@ -12,7 +13,6 @@ "src/core/**/*.spec.ts*", "src/proprietary/**/*.test.ts*", "src/proprietary/**/*.spec.ts*", - "src/desktop", - "node_modules" + "src/desktop" ] } diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 8641e4120..372ad3685 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -5,19 +5,30 @@ import globals from "globals"; import { defineConfig } from "eslint/config"; import tseslint from "typescript-eslint"; -const srcGlobs = ["editor/src/**/*.{js,mjs,jsx,ts,tsx}"]; +const srcGlobs = [ + "editor/src/**/*.{js,mjs,jsx,ts,tsx}", + "portal/src/**/*.{js,mjs,jsx,ts,tsx}", + "portal/main.tsx", + "shared/**/*.{js,mjs,jsx,ts,tsx}", +]; const nodeGlobs = [ "scripts/**/*.{js,ts,mjs,mts}", "editor/scripts/**/*.{js,ts,mjs,mts}", "editor/*.config.{js,ts,mjs}", + "portal/*.config.{js,ts,mjs}", "*.config.{js,ts,mjs}", + ".storybook/*.{js,ts,mjs,mts,tsx}", ]; const baseRestrictedImportPatterns = [ - { regex: "^\\.", message: "Use @app/* imports instead of relative imports." }, + { + regex: "^\\.", + message: + "Use a workspace alias (@app/* for editor, @portal/* for portal, @shared/*) instead of relative imports.", + }, { regex: "^src/", - message: "Use @app/* imports instead of absolute src/ imports.", + message: "Use a workspace alias instead of absolute src/ imports.", }, ]; @@ -36,6 +47,7 @@ export default defineConfig( "editor/src-tauri", "editor/playwright-report", "editor/test-results", + "portal/public", ], }, eslint.configs.recommended, @@ -92,6 +104,45 @@ export default defineConfig( ], }, }, + // The shared/ layer is the seed of a future packages/shared-ui — it must + // only depend on third-party packages and on itself. If it ever imports + // from editor or portal layers, extraction to a standalone package later + // becomes a rewrite instead of a `git mv`. + { + files: ["shared/**/*.{js,mjs,jsx,ts,tsx}"], + rules: { + "no-restricted-imports": [ + "error", + { + patterns: [ + ...baseRestrictedImportPatterns, + { + regex: "^@app/", + message: + "shared/ must not depend on the editor layer (@app/* resolves into editor/src/).", + }, + { + regex: "^@portal/", + message: + "shared/ must not depend on the portal layer. Use @shared/* or third-party imports only.", + }, + { + regex: "^@core/", + message: "shared/ must not depend on editor/src/core/.", + }, + { + regex: "^@proprietary/", + message: "shared/ must not depend on editor/src/proprietary/.", + }, + { + regex: "^@tauri-apps/", + message: "shared/ must remain web-compatible (no Tauri APIs).", + }, + ], + }, + ], + }, + }, // Folders that have been cleaned up and are now conformant - stricter rules enforced here { files: [ @@ -99,6 +150,8 @@ export default defineConfig( "editor/src/proprietary/**/*.{js,mjs,jsx,ts,tsx}", "editor/src/saas/**/*.{js,mjs,jsx,ts,tsx}", "editor/src/prototypes/**/*.{js,mjs,jsx,ts,tsx}", + "portal/src/**/*.{js,mjs,jsx,ts,tsx}", + "shared/**/*.{js,mjs,jsx,ts,tsx}", ], languageOptions: { parserOptions: { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1543a1496..5c67488af 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -90,6 +90,10 @@ "@iconify-json/material-symbols": "^1.2.53", "@iconify/utils": "^3.1.0", "@playwright/test": "^1.55.0", + "@storybook/addon-a11y": "^9.1.20", + "@storybook/addon-docs": "^9.1.20", + "@storybook/addon-themes": "^9.1.20", + "@storybook/react-vite": "^9.1.20", "@tauri-apps/cli": "^2.9.6", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.8.0", @@ -114,6 +118,8 @@ "jsdom": "^27.0.0", "license-checker": "^25.0.1", "madge": "^8.0.0", + "msw": "^2.14.6", + "msw-storybook-addon": "^2.0.7", "postcss": "^8.5.12", "postcss-cli": "^11.0.1", "postcss-preset-mantine": "^1.18.0", @@ -121,6 +127,7 @@ "prettier": "^3.8.1", "puppeteer": "^24.25.0", "rollup-plugin-visualizer": "^7.0.1", + "storybook": "^9.1.20", "tsx": "^4.21.0", "typescript": "^5.9.2", "typescript-eslint": "^8.44.1", @@ -244,6 +251,64 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/generator": { "version": "7.29.1", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", @@ -260,6 +325,43 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/helper-globals": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", @@ -282,6 +384,24 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -300,6 +420,30 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/parser": { "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", @@ -1925,6 +2069,93 @@ "mlly": "^1.8.0" } }, + "node_modules/@inquirer/ansi": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.5.tgz", + "integrity": "sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.0.13", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.0.13.tgz", + "integrity": "sha512-wkGPC7yJ5WJk1DJ5SX7fzk+gfj4BM8cf5dDDi71B/551xHrdsZVRJOC0WyikXd0pEsb/9cLniuE4atbsMqmFkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.10", + "@inquirer/type": "^4.0.5" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "11.1.10", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.1.10.tgz", + "integrity": "sha512-a4Q5BXHQAHa9eO202sTaFCHFYVB3x5fauDuThEAdZ9gfn76pSxiKU7wWcEH0N1O0XmQvNfQNU6QXpiRxmYQx+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.5", + "@inquirer/figures": "^2.0.5", + "@inquirer/type": "^4.0.5", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.5.tgz", + "integrity": "sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + } + }, + "node_modules/@inquirer/type": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.5.tgz", + "integrity": "sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1953,6 +2184,27 @@ "node": ">=8" } }, + "node_modules/@joshwooding/vite-plugin-react-docgen-typescript": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@joshwooding/vite-plugin-react-docgen-typescript/-/vite-plugin-react-docgen-typescript-0.6.1.tgz", + "integrity": "sha512-J4BaTocTOYFkMHIra1JDWrMWpNmBl4EkplIwHEsV8aeUOtdWjwSnln9U7twjMFTAEB7mptNtSKyVi1Y2W9sDJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^10.0.0", + "magic-string": "^0.30.0", + "react-docgen-typescript": "^2.2.2" + }, + "peerDependencies": { + "typescript": ">= 4.3.x", + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -2091,6 +2343,24 @@ "@types/gapi.client.discovery-v1": "*" } }, + "node_modules/@mdx-js/react": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz", + "integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdx": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=16", + "react": ">=16" + } + }, "node_modules/@msgpack/msgpack": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-2.8.0.tgz", @@ -2100,6 +2370,31 @@ "node": ">= 10" } }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.9", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.9.tgz", + "integrity": "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@mswjs/interceptors/node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, "node_modules/@mui/core-downloads-tracker": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-9.0.0.tgz", @@ -2583,6 +2878,31 @@ "url": "https://github.com/sponsors/Brooooooklyn" } }, + "node_modules/@open-draft/deferred-promise": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz", + "integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, "node_modules/@opentelemetry/api": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", @@ -3094,6 +3414,49 @@ "dev": true, "license": "MIT" }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/pluginutils/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", @@ -3483,6 +3846,286 @@ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, + "node_modules/@storybook/addon-a11y": { + "version": "9.1.20", + "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-9.1.20.tgz", + "integrity": "sha512-VFZ34y4ApmFwIzPRs2OJrG6jtYhM5y91eCZLTlR/HMGQciKF4TdOJHjj+5vf91SOER5UDcLizXetpiUowiZSgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "axe-core": "^4.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^9.1.20" + } + }, + "node_modules/@storybook/addon-docs": { + "version": "9.1.20", + "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-9.1.20.tgz", + "integrity": "sha512-eUIOd4u/p9994Nkv8Avn6r/xmS7D+RNmhmu6KGROefN3myLe3JfhSdimal2wDFe/h/OUNZ/LVVKMZrya9oEfKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mdx-js/react": "^3.0.0", + "@storybook/csf-plugin": "9.1.20", + "@storybook/icons": "^1.4.0", + "@storybook/react-dom-shim": "9.1.20", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^9.1.20" + } + }, + "node_modules/@storybook/addon-themes": { + "version": "9.1.20", + "resolved": "https://registry.npmjs.org/@storybook/addon-themes/-/addon-themes-9.1.20.tgz", + "integrity": "sha512-GY9WKJMxbsI24CTj1PToWbjZGuXnwLasahv51wpIQC94Sn/8NxRta8K2BO2Pqb7U3sdtjH6htUasnQnaL0/ZBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^9.1.20" + } + }, + "node_modules/@storybook/builder-vite": { + "version": "9.1.20", + "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-9.1.20.tgz", + "integrity": "sha512-cdU3Q2/wEaT8h+mApFToRiF/0hYKH1eAkD0scQn67aODgp7xnkr0YHcdA+8w0Uxd2V7U8crV/cmT/HD0ELVOGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/csf-plugin": "9.1.20", + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^9.1.20", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@storybook/csf-plugin": { + "version": "9.1.20", + "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-9.1.20.tgz", + "integrity": "sha512-HHgk50YQhML7mT01Mzf9N7lNMFHWN4HwwRP90kPT9Ct+Jhx7h3LBDbdmWjI96HwujcpY7eoYdTfpB1Sw8Z7nBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "unplugin": "^1.3.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^9.1.20" + } + }, + "node_modules/@storybook/global": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@storybook/global/-/global-5.0.0.tgz", + "integrity": "sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@storybook/icons": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@storybook/icons/-/icons-1.6.0.tgz", + "integrity": "sha512-hcFZIjW8yQz8O8//2WTIXylm5Xsgc+lW9ISLgUk1xGmptIJQRdlhVIXCpSyLrQaaRiyhQRaVg7l3BD9S216BHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta" + } + }, + "node_modules/@storybook/react": { + "version": "9.1.20", + "resolved": "https://registry.npmjs.org/@storybook/react/-/react-9.1.20.tgz", + "integrity": "sha512-TJhqzggs7HCvLhTXKfx8HodnVq9YizsB2J31s9v6olU0UCxbCY+FYaCF+XdE8qUCyefGRZgHKzGBIczJ/q9e2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "@storybook/react-dom-shim": "9.1.20" + }, + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "storybook": "^9.1.20", + "typescript": ">= 4.9.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@storybook/react-dom-shim": { + "version": "9.1.20", + "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-9.1.20.tgz", + "integrity": "sha512-UYdZavfPwHEqCKMqPssUOlyFVZiJExLxnSHwkICSZBmw3gxXJcp1aXWs7PvoZdWz2K4ztl3IcKErXXHeiY6w+A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "storybook": "^9.1.20" + } + }, + "node_modules/@storybook/react-vite": { + "version": "9.1.20", + "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-9.1.20.tgz", + "integrity": "sha512-buXeNvEJ9kp4FKbGYV7zW4sh/KS01EAjeq8Z6AVxaXOh4W2CIRTKM9maWGz+Rr+YyqQIq/Gl+RqNwxctpxeuHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@joshwooding/vite-plugin-react-docgen-typescript": "0.6.1", + "@rollup/pluginutils": "^5.0.2", + "@storybook/builder-vite": "9.1.20", + "@storybook/react": "9.1.20", + "find-up": "^7.0.0", + "magic-string": "^0.30.0", + "react-docgen": "^8.0.0", + "resolve": "^1.22.8", + "tsconfig-paths": "^4.2.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "storybook": "^9.1.20", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@storybook/react-vite/node_modules/find-up": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-7.0.0.tgz", + "integrity": "sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^7.2.0", + "path-exists": "^5.0.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@storybook/react-vite/node_modules/locate-path": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", + "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^6.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@storybook/react-vite/node_modules/p-limit": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@storybook/react-vite/node_modules/p-locate": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", + "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@storybook/react-vite/node_modules/path-exists": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/@storybook/react-vite/node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@stripe/react-stripe-js": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-4.0.2.tgz", @@ -4670,6 +5313,51 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -4972,6 +5660,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/doctrine": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/@types/doctrine/-/doctrine-0.0.9.tgz", + "integrity": "sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -5080,6 +5775,13 @@ "@types/unist": "*" } }, + "node_modules/@types/mdx": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", + "integrity": "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -5135,6 +5837,30 @@ "@types/react": "*" } }, + "node_modules/@types/resolve": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.6.tgz", + "integrity": "sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/set-cookie-parser": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz", + "integrity": "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -5959,6 +6685,16 @@ "postcss": "^8.1.0" } }, + "node_modules/axe-core": { + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz", + "integrity": "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/axios": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", @@ -6175,6 +6911,76 @@ "node": ">=10.0.0" } }, + "node_modules/better-opn": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/better-opn/-/better-opn-3.0.2.tgz", + "integrity": "sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "open": "^8.0.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/better-opn/node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/better-opn/node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/better-opn/node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/better-opn/node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -6629,6 +7435,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -7814,6 +8630,19 @@ "wrappy": "1" } }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -8074,6 +8903,19 @@ "@esbuild/win32-x64": "0.27.4" } }, + "node_modules/esbuild-register": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz", + "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "peerDependencies": { + "esbuild": ">=0.12 <1" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -8482,6 +9324,33 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.0.tgz", + "integrity": "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", @@ -8749,6 +9618,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/get-amd-module-type": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-amd-module-type/-/get-amd-module-type-6.0.1.tgz", @@ -9038,6 +9917,16 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/graphql": { + "version": "16.14.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.0.tgz", + "integrity": "sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -9127,6 +10016,24 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/headers-polyfill": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-5.0.1.tgz", + "integrity": "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/set-cookie-parser": "^2.4.10", + "set-cookie-parser": "^3.0.1" + } + }, + "node_modules/headers-polyfill/node_modules/set-cookie-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "dev": true, + "license": "MIT" + }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -9616,6 +10523,13 @@ "node": ">=8" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -11635,6 +12549,90 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/msw": { + "version": "2.14.6", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.14.6.tgz", + "integrity": "sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@inquirer/confirm": "^6.0.11", + "@mswjs/interceptors": "^0.41.3", + "@open-draft/deferred-promise": "^3.0.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.1.1", + "graphql": "^16.13.2", + "headers-polyfill": "^5.0.1", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.11.11", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.1", + "type-fest": "^5.5.0", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/msw-storybook-addon": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/msw-storybook-addon/-/msw-storybook-addon-2.0.7.tgz", + "integrity": "sha512-TGmlxXy2TsaB6QcClVKRxqvay5f93xoLguHOihRFQ+gIEIyiyvcoQjkEeuOe7Y9qvddzGB1LyFomzPo9/EpnuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.0.1" + }, + "peerDependencies": { + "msw": "^2.0.0" + } + }, + "node_modules/msw/node_modules/type-fest": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", + "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -11920,6 +12918,13 @@ "os-tmpdir": "^1.0.0" } }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, "node_modules/p-cancelable": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-4.0.1.tgz", @@ -12165,6 +13170,13 @@ "dev": true, "license": "ISC" }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -13065,6 +14077,51 @@ "node": ">=0.10.0" } }, + "node_modules/react-docgen": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-8.0.3.tgz", + "integrity": "sha512-aEZ9qP+/M+58x2qgfSFEWH1BxLyHe5+qkLNJOZQb5iGS017jpbRnoKhNRrXPeA6RfBrZO5wZrT9DMC1UqE1f1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.2", + "@types/babel__core": "^7.20.5", + "@types/babel__traverse": "^7.20.7", + "@types/doctrine": "^0.0.9", + "@types/resolve": "^1.20.2", + "doctrine": "^3.0.0", + "resolve": "^1.22.1", + "strip-indent": "^4.0.0" + }, + "engines": { + "node": "^20.9.0 || >=22" + } + }, + "node_modules/react-docgen-typescript": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/react-docgen-typescript/-/react-docgen-typescript-2.4.0.tgz", + "integrity": "sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">= 4.3.x" + } + }, + "node_modules/react-docgen/node_modules/strip-indent": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.1.1.tgz", + "integrity": "sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/react-dom": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", @@ -13523,6 +14580,46 @@ "node": ">=8.10.0" } }, + "node_modules/recast": { + "version": "0.23.11", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", + "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/recast/node_modules/ast-types": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", + "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/recast/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/recharts": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz", @@ -13805,6 +14902,13 @@ "dev": true, "license": "ISC" }, + "node_modules/rettime": { + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.11.tgz", + "integrity": "sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==", + "dev": true, + "license": "MIT" + }, "node_modules/robust-predicates": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", @@ -14356,6 +15460,16 @@ "dev": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -14363,6 +15477,526 @@ "dev": true, "license": "MIT" }, + "node_modules/storybook": { + "version": "9.1.20", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-9.1.20.tgz", + "integrity": "sha512-6rME2tww6PFhm96iG2Xx44yzwLDWBiDWy+kJ2ub6x90werSTOiuo+tZJ94BgCfFutR0tEfLRIq59s+Zg6YyChA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/user-event": "^14.6.1", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/spy": "3.2.4", + "better-opn": "^3.0.2", + "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0", + "esbuild-register": "^3.5.0", + "recast": "^0.23.5", + "semver": "^7.6.2", + "ws": "^8.18.0" + }, + "bin": { + "storybook": "bin/index.cjs" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "prettier": "^2 || ^3" + }, + "peerDependenciesMeta": { + "prettier": { + "optional": true + } + } + }, + "node_modules/storybook/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/storybook/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, "node_modules/stream-to-array": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/stream-to-array/-/stream-to-array-2.3.0.tgz", @@ -14385,6 +16019,13 @@ "text-decoder": "^1.1.0" } }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -14815,6 +16456,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tailwindcss": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", @@ -15113,6 +16767,16 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-dedent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", + "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, "node_modules/ts-graphviz": { "version": "2.1.6", "resolved": "https://registry.npmjs.org/ts-graphviz/-/ts-graphviz-2.1.6.tgz", @@ -15299,6 +16963,19 @@ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "license": "MIT" }, + "node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -15396,6 +17073,30 @@ "node": ">= 10.0.0" } }, + "node_modules/unplugin": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-1.16.1.tgz", + "integrity": "sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.14.0", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -15980,6 +17681,13 @@ "node": ">=20" } }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + }, "node_modules/webrtc-adapter": { "version": "9.0.4", "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.4.tgz", @@ -16225,6 +17933,13 @@ "node": ">=10" } }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, "node_modules/yaml": { "version": "2.8.3", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4c9dfe8d5..68695be91 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -104,6 +104,10 @@ "@iconify-json/material-symbols": "^1.2.53", "@iconify/utils": "^3.1.0", "@playwright/test": "^1.55.0", + "@storybook/addon-a11y": "^9.1.20", + "@storybook/addon-docs": "^9.1.20", + "@storybook/addon-themes": "^9.1.20", + "@storybook/react-vite": "^9.1.20", "@tauri-apps/cli": "^2.9.6", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.8.0", @@ -128,6 +132,8 @@ "jsdom": "^27.0.0", "license-checker": "^25.0.1", "madge": "^8.0.0", + "msw": "^2.14.6", + "msw-storybook-addon": "^2.0.7", "postcss": "^8.5.12", "postcss-cli": "^11.0.1", "postcss-preset-mantine": "^1.18.0", @@ -135,6 +141,7 @@ "prettier": "^3.8.1", "puppeteer": "^24.25.0", "rollup-plugin-visualizer": "^7.0.1", + "storybook": "^9.1.20", "tsx": "^4.21.0", "typescript": "^5.9.2", "typescript-eslint": "^8.44.1", @@ -153,5 +160,10 @@ }, "overrides": { "devalue": "^5.8.1" + }, + "msw": { + "workerDirectory": [ + "portal/public" + ] } } diff --git a/frontend/portal/LICENSE b/frontend/portal/LICENSE new file mode 100644 index 000000000..d26855680 --- /dev/null +++ b/frontend/portal/LICENSE @@ -0,0 +1,51 @@ +Stirling PDF User License + +Copyright (c) 2025 Stirling PDF Inc. + +License Scope & Usage Rights + +Production use of the Stirling PDF Software is only permitted with a valid Stirling PDF User License. + +For purposes of this license, “the Software” refers to the Stirling PDF application and any associated documentation files +provided by Stirling PDF Inc. You or your organization may not use the Software in production, at scale, or for business-critical +processes unless you have agreed to, and remain in compliance with, the Stirling PDF Subscription Terms of Service +(https://www.stirlingpdf.com/terms) or another valid agreement with Stirling PDF, and hold an active User License subscription +covering the appropriate number of licensed users. + +Trial and Minimal Use + +You may use the Software without a paid subscription for the sole purposes of internal trial, evaluation, or minimal use, provided that: +* Use is limited to the capabilities and restrictions defined by the Software itself; +* You do not copy, distribute, sublicense, reverse-engineer, or use the Software in client-facing or commercial contexts. + +Continued use beyond this scope requires a valid Stirling PDF User License. + +Modifications and Derivative Works + +You may modify the Software only for development or internal testing purposes. Any such modifications or derivative works: + +* May not be deployed in production environments without a valid User License; +* May not be distributed or sublicensed; +* Remain the intellectual property of Stirling PDF and/or its licensors; +* May only be used, copied, or exploited in accordance with the terms of a valid Stirling PDF User License subscription. + +Prohibited Actions + +Unless explicitly permitted by a paid license or separate agreement, you may not: + +* Use the Software in production environments; +* Copy, merge, distribute, sublicense, or sell the Software; +* Remove or alter any licensing or copyright notices; +* Circumvent access restrictions or licensing requirements. + +Third-Party Components + +The Stirling PDF Software may include components subject to separate open source licenses. Such components remain governed by +their original license terms as provided by their respective owners. + +Disclaimer + +THE SOFTWARE IS PROVIDED “AS IS,” WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/frontend/portal/index.html b/frontend/portal/index.html new file mode 100644 index 000000000..69328bad8 --- /dev/null +++ b/frontend/portal/index.html @@ -0,0 +1,15 @@ + + + + + + + + Stirling — Developer Portal + + + +
+ + + diff --git a/frontend/portal/main.tsx b/frontend/portal/main.tsx new file mode 100644 index 000000000..76e802312 --- /dev/null +++ b/frontend/portal/main.tsx @@ -0,0 +1,34 @@ +/// +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "@portal/App"; +import { readMocksPreference } from "@portal/mocks/preference"; + +// Mantine's prebuilt styles load first so SUI tokens/base can override on +// conflicts — SUI is the primary design language, Mantine the escape hatch. +import "@mantine/core/styles.css"; +import "@shared/tokens/tokens.css"; +import "@shared/tokens/base.css"; + +const root = document.getElementById("root"); +if (!root) throw new Error("No #root element"); + +// Start MSW before React mounts when the user has mocks enabled. The +// preference defaults to ON in dev / OFF in production. The toggle in the +// header flips it at runtime. +async function bootstrap(): Promise { + if (readMocksPreference()) { + // Dynamic import keeps MSW + every handler + every fixture out of any + // chunk that doesn't need to actually run the worker. + const { startMockWorker } = await import("@portal/mocks/browser"); + await startMockWorker(); + } + + createRoot(root!).render( + + + , + ); +} + +void bootstrap(); diff --git a/frontend/portal/public/mockServiceWorker.js b/frontend/portal/public/mockServiceWorker.js new file mode 100644 index 000000000..71a94b1b4 --- /dev/null +++ b/frontend/portal/public/mockServiceWorker.js @@ -0,0 +1,349 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + */ + +const PACKAGE_VERSION = "2.14.6"; +const INTEGRITY_CHECKSUM = "4db4a41e972cec1b64cc569c66952d82"; +const IS_MOCKED_RESPONSE = Symbol("isMockedResponse"); +const activeClientIds = new Set(); + +addEventListener("install", function () { + self.skipWaiting(); +}); + +addEventListener("activate", function (event) { + event.waitUntil(self.clients.claim()); +}); + +addEventListener("message", async function (event) { + const clientId = Reflect.get(event.source || {}, "id"); + + if (!clientId || !self.clients) { + return; + } + + const client = await self.clients.get(clientId); + + if (!client) { + return; + } + + const allClients = await self.clients.matchAll({ + type: "window", + }); + + switch (event.data) { + case "KEEPALIVE_REQUEST": { + sendToClient(client, { + type: "KEEPALIVE_RESPONSE", + }); + break; + } + + case "INTEGRITY_CHECK_REQUEST": { + sendToClient(client, { + type: "INTEGRITY_CHECK_RESPONSE", + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }); + break; + } + + case "MOCK_ACTIVATE": { + activeClientIds.add(clientId); + + sendToClient(client, { + type: "MOCKING_ENABLED", + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }); + break; + } + + case "CLIENT_CLOSED": { + activeClientIds.delete(clientId); + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId; + }); + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister(); + } + + break; + } + } +}); + +addEventListener("fetch", function (event) { + const requestInterceptedAt = Date.now(); + + // Bypass navigation requests. + if (event.request.mode === "navigate") { + return; + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if ( + event.request.cache === "only-if-cached" && + event.request.mode !== "same-origin" + ) { + return; + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been terminated (still remains active until the next reload). + if (activeClientIds.size === 0) { + return; + } + + const requestId = crypto.randomUUID(); + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)); +}); + +/** + * @param {FetchEvent} event + * @param {string} requestId + * @param {number} requestInterceptedAt + */ +async function handleRequest(event, requestId, requestInterceptedAt) { + const client = await resolveMainClient(event); + const requestCloneForEvents = event.request.clone(); + const response = await getResponse( + event, + client, + requestId, + requestInterceptedAt, + ); + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + const serializedRequest = await serializeRequest(requestCloneForEvents); + + // Clone the response so both the client and the library could consume it. + const responseClone = response.clone(); + + sendToClient( + client, + { + type: "RESPONSE", + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + headers: Object.fromEntries(responseClone.headers.entries()), + body: responseClone.body, + }, + }, + }, + responseClone.body ? [serializedRequest.body, responseClone.body] : [], + ); + } + + return response; +} + +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise} + */ +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId); + + if (activeClientIds.has(event.clientId)) { + return client; + } + + if (client?.frameType === "top-level") { + return client; + } + + const allClients = await self.clients.matchAll({ + type: "window", + }); + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === "visible"; + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id); + }); +} + +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @param {number} requestInterceptedAt + * @returns {Promise} + */ +async function getResponse(event, client, requestId, requestInterceptedAt) { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = event.request.clone(); + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers); + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get("accept"); + if (acceptHeader) { + const values = acceptHeader.split(",").map((value) => value.trim()); + const filteredValues = values.filter( + (value) => value !== "msw/passthrough", + ); + + if (filteredValues.length > 0) { + headers.set("accept", filteredValues.join(", ")); + } else { + headers.delete("accept"); + } + } + + return fetch(requestClone, { headers }); + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough(); + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough(); + } + + // Notify the client that a request has been intercepted. + const serializedRequest = await serializeRequest(event.request); + const clientMessage = await sendToClient( + client, + { + type: "REQUEST", + payload: { + id: requestId, + interceptedAt: requestInterceptedAt, + ...serializedRequest, + }, + }, + [serializedRequest.body], + ); + + switch (clientMessage.type) { + case "MOCK_RESPONSE": { + return respondWithMock(clientMessage.data); + } + + case "PASSTHROUGH": { + return passthrough(); + } + } + + return passthrough(); +} + +/** + * @param {Client} client + * @param {any} message + * @param {Array} transferrables + * @returns {Promise} + */ +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel(); + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error); + } + + resolve(event.data); + }; + + client.postMessage(message, [ + channel.port2, + ...transferrables.filter(Boolean), + ]); + }); +} + +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error(); + } + + const mockedResponse = new Response(response.body, response); + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }); + + return mockedResponse; +} + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + }; +} diff --git a/frontend/portal/src/App.tsx b/frontend/portal/src/App.tsx new file mode 100644 index 000000000..f51868e64 --- /dev/null +++ b/frontend/portal/src/App.tsx @@ -0,0 +1,75 @@ +import { useEffect, type ReactNode } from "react"; +import { BrowserRouter } from "react-router-dom"; +import { MantineProvider } from "@mantine/core"; +import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext"; +import { TierProvider } from "@portal/contexts/TierContext"; +import { UIProvider, useUI } from "@portal/contexts/UIContext"; +import { mantineTheme } from "@portal/theme/mantineTheme"; +import { AppShell } from "@portal/components/AppShell"; +import { AssistantButton } from "@portal/components/AssistantButton"; +import { AssistantPanel } from "@portal/components/AssistantPanel"; +import { SearchModal } from "@portal/components/SearchModal"; +import { ViewRouter } from "@portal/ViewRouter"; + +/** + * Binds Mantine's colour scheme to the portal's own ThemeProvider so Mantine + * components follow the same light/dark switch as the SUI primitives. Must sit + * inside to read useTheme(). + */ +function PortalMantineProvider({ children }: { children: ReactNode }) { + const { theme } = useTheme(); + return ( + + {children} + + ); +} + +/** + * Global keyboard shortcuts. Lives below the UIProvider so it can dispatch + * into the overlay state. Currently just ⌘K / Ctrl+K to toggle the search + * palette. + */ +function GlobalShortcuts() { + const { toggleSearch, closeSearch } = useUI(); + + useEffect(() => { + function onKey(e: KeyboardEvent) { + const isCmdK = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k"; + if (isCmdK) { + e.preventDefault(); + toggleSearch(); + return; + } + if (e.key === "Escape") { + closeSearch(); + } + } + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [toggleSearch, closeSearch]); + + return null; +} + +export function App() { + return ( + + + + + + + + + + + + + + + + + + ); +} diff --git a/frontend/portal/src/ViewRouter.tsx b/frontend/portal/src/ViewRouter.tsx new file mode 100644 index 000000000..f8e0005e5 --- /dev/null +++ b/frontend/portal/src/ViewRouter.tsx @@ -0,0 +1,67 @@ +import { Navigate, Route, Routes } from "react-router-dom"; +import { Home } from "@portal/views/Home"; +import { Documents } from "@portal/views/Documents"; +import { Placeholder } from "@portal/views/Placeholder"; +import { VIEW_PATHS, type ViewId } from "@portal/contexts/ViewContext"; + +const PLACEHOLDER_PHASES: Partial> = { + editor: "Phase 8 — Editor", + sources: "Phase 5 — Sources & Agents", + pipelines: "Phase 4 — Pipelines", + infrastructure: "Phase 7 — Infrastructure", + usage: "Phase 8 — Usage & Billing", + docs: "Phase 8 — Developer Docs", + settings: "Settings — modal overlay", +}; + +export function ViewRouter() { + return ( + + } /> + + } + /> + + } + /> + + } + /> + } /> + + } + /> + } + /> + } + /> + + } + /> + {/* Unknown paths land on Home. */} + } /> + + ); +} diff --git a/frontend/portal/src/api/assistant.ts b/frontend/portal/src/api/assistant.ts new file mode 100644 index 000000000..a6f10597f --- /dev/null +++ b/frontend/portal/src/api/assistant.ts @@ -0,0 +1,15 @@ +import { httpJson } from "@portal/api/http"; + +/** GET /v1/assistant/suggestions */ +export async function fetchAssistantSuggestions(): Promise { + return httpJson("/v1/assistant/suggestions"); +} + +/** POST /v1/assistant/messages */ +export async function getAssistantReply(input: string): Promise { + const res = await httpJson<{ reply: string }>("/v1/assistant/messages", { + method: "POST", + body: { input }, + }); + return res.reply; +} diff --git a/frontend/portal/src/api/endpoints.ts b/frontend/portal/src/api/endpoints.ts new file mode 100644 index 000000000..95bab777b --- /dev/null +++ b/frontend/portal/src/api/endpoints.ts @@ -0,0 +1,15 @@ +import { httpJson } from "@portal/api/http"; +import type { Vertical } from "@shared/data/endpoints"; + +export type { + Endpoint, + EndpointSchema, + EndpointTierGate, + Vertical, + VerticalKey, +} from "@shared/data/endpoints"; + +/** GET /v1/endpoints — verticals plus their endpoints. */ +export async function fetchVerticals(): Promise { + return httpJson("/v1/endpoints"); +} diff --git a/frontend/portal/src/api/home.ts b/frontend/portal/src/api/home.ts new file mode 100644 index 000000000..b7b8fee64 --- /dev/null +++ b/frontend/portal/src/api/home.ts @@ -0,0 +1,44 @@ +import { httpJson } from "@portal/api/http"; +import type { + ActivityEvent, + KpiEntry, + OnboardingStep, + RegionHealth, + UsageSeriesResponse, +} from "@portal/mocks/home"; +import type { Tier } from "@portal/contexts/TierContext"; + +export type { + ActivityEvent, + ActivityKind, + KpiEntry, + OnboardingStep, + RegionHealth, + UsagePoint, + UsageSeriesResponse, +} from "@portal/mocks/home"; + +/** GET /v1/analytics/usage?window=30d */ +export async function fetchUsageSeries(): Promise { + return httpJson("/v1/analytics/usage?window=30d"); +} + +/** GET /v1/activity?limit=8 */ +export async function fetchRecentActivity(): Promise { + return httpJson("/v1/activity?limit=8"); +} + +/** GET /v1/home/kpis?tier=… */ +export async function fetchHomeKpis(tier: Tier): Promise { + return httpJson(`/v1/home/kpis?tier=${encodeURIComponent(tier)}`); +} + +/** GET /v1/regions/health (Enterprise) */ +export async function fetchRegionHealth(): Promise { + return httpJson("/v1/regions/health"); +} + +/** GET /v1/onboarding (Free) */ +export async function fetchOnboarding(): Promise { + return httpJson("/v1/onboarding"); +} diff --git a/frontend/portal/src/api/http.ts b/frontend/portal/src/api/http.ts new file mode 100644 index 000000000..7b4f35170 --- /dev/null +++ b/frontend/portal/src/api/http.ts @@ -0,0 +1,59 @@ +/** + * Shared HTTP plumbing for the portal's service layer. + * + * Every `api/*.ts` module calls {@link httpJson}, which issues a real `fetch`. + * In dev and Storybook those requests are intercepted by the MSW handlers in + * `mocks/` and answered with fixture data; pointing at a real backend is just + * a matter of not registering MSW. Consumers don't change either way. + */ + +export interface HttpRequestOptions { + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + body?: unknown; + /** Extra headers; Content-Type and Accept are set automatically. */ + headers?: Record; + signal?: AbortSignal; +} + +export class HttpError extends Error { + constructor( + public readonly status: number, + public readonly statusText: string, + public readonly body: unknown, + ) { + super(`${status} ${statusText}`); + this.name = "HttpError"; + } +} + +/** + * Thin JSON fetch wrapper used by every api module. In dev/Storybook the + * request is served by MSW; against a real backend it hits the network. + */ +export async function httpJson( + path: string, + options: HttpRequestOptions = {}, +): Promise { + const res = await fetch(path, { + method: options.method ?? "GET", + headers: { + Accept: "application/json", + ...(options.body !== undefined + ? { "Content-Type": "application/json" } + : {}), + ...options.headers, + }, + body: options.body !== undefined ? JSON.stringify(options.body) : undefined, + signal: options.signal, + }); + if (!res.ok) { + let body: unknown = null; + try { + body = await res.json(); + } catch { + // ignore — non-JSON error response + } + throw new HttpError(res.status, res.statusText, body); + } + return (await res.json()) as T; +} diff --git a/frontend/portal/src/api/notifications.ts b/frontend/portal/src/api/notifications.ts new file mode 100644 index 000000000..21b6e33f6 --- /dev/null +++ b/frontend/portal/src/api/notifications.ts @@ -0,0 +1,19 @@ +import { httpJson } from "@portal/api/http"; +import type { + Notification, + NotificationCategory, +} from "@portal/mocks/notifications"; + +export type { Notification, NotificationCategory }; + +/** GET /v1/notifications */ +export async function fetchNotifications(): Promise { + return httpJson("/v1/notifications"); +} + +/** POST /v1/notifications/mark-all-read */ +export async function markAllNotificationsRead(): Promise { + await httpJson<{ ok: true }>("/v1/notifications/mark-all-read", { + method: "POST", + }); +} diff --git a/frontend/portal/src/api/ops.ts b/frontend/portal/src/api/ops.ts new file mode 100644 index 000000000..e741793bf --- /dev/null +++ b/frontend/portal/src/api/ops.ts @@ -0,0 +1,37 @@ +import { HttpError, httpJson } from "@portal/api/http"; +import type { FeaturedOp, OpResultMap } from "@portal/mocks/ops"; + +export type { FeaturedOp, OpResultMap }; + +/** GET /v1/ops/featured */ +export async function fetchFeaturedOps(): Promise { + return httpJson("/v1/ops/featured"); +} + +export class UnknownOpError extends Error { + constructor(public readonly opId: string) { + super(`Unknown op: ${opId}`); + this.name = "UnknownOpError"; + } +} + +/** POST /v1/ops/{opId}/run */ +export async function runSingleOp( + opId: string, + sample: string, +): Promise<{ result: OpResultMap; durationMs: number }> { + try { + return await httpJson<{ result: OpResultMap; durationMs: number }>( + `/v1/ops/${encodeURIComponent(opId)}/run`, + { + method: "POST", + body: { sample }, + }, + ); + } catch (err) { + if (err instanceof HttpError && err.status === 404) { + throw new UnknownOpError(opId); + } + throw err; + } +} diff --git a/frontend/portal/src/api/search.ts b/frontend/portal/src/api/search.ts new file mode 100644 index 000000000..6a7b600df --- /dev/null +++ b/frontend/portal/src/api/search.ts @@ -0,0 +1,9 @@ +import { httpJson } from "@portal/api/http"; +import type { QuickAction } from "@portal/mocks/search"; + +export type { QuickAction }; + +/** GET /v1/search/quick-actions */ +export async function fetchQuickActions(): Promise { + return httpJson("/v1/search/quick-actions"); +} diff --git a/frontend/portal/src/components/AppShell.css b/frontend/portal/src/components/AppShell.css new file mode 100644 index 000000000..1c7491d9a --- /dev/null +++ b/frontend/portal/src/components/AppShell.css @@ -0,0 +1,19 @@ +.portal-shell { + display: flex; + min-height: 100vh; + background: var(--color-bg); + color: var(--color-text-2); +} + +.portal-shell__main { + flex: 1 1 auto; + display: flex; + flex-direction: column; + min-width: 0; /* prevent grid blowout on narrow content */ +} + +.portal-shell__view { + flex: 1 1 auto; + overflow-y: auto; + animation: fadeInUp var(--motion-enter) both; +} diff --git a/frontend/portal/src/components/AppShell.tsx b/frontend/portal/src/components/AppShell.tsx new file mode 100644 index 000000000..606f021c0 --- /dev/null +++ b/frontend/portal/src/components/AppShell.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from "react"; +import { Sidebar } from "@portal/components/Sidebar"; +import { Header } from "@portal/components/Header"; +import "@portal/components/AppShell.css"; + +/** + * Two-column layout: fixed-width sidebar on the left, sticky header + scrolling + * main column on the right. The Sidebar and Header read their state from + * context, so this shell stays prop-free. + */ +export function AppShell({ children }: { children: ReactNode }) { + return ( +
+ +
+
+
{children}
+
+
+ ); +} diff --git a/frontend/portal/src/components/AssistantButton.css b/frontend/portal/src/components/AssistantButton.css new file mode 100644 index 000000000..107ff7c1a --- /dev/null +++ b/frontend/portal/src/components/AssistantButton.css @@ -0,0 +1,35 @@ +.portal-assistant-btn { + position: fixed; + bottom: 1.5rem; + right: 1.5rem; + width: 3rem; + height: 3rem; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + color: #fff; + background: linear-gradient( + 135deg, + var(--color-blue) 0%, + var(--color-purple) 100% + ); + box-shadow: + 0 0.5rem 1.25rem rgba(59, 130, 246, 0.35), + inset 0 0.0625rem 0 rgba(255, 255, 255, 0.2); + transition: + transform var(--motion-base), + box-shadow var(--motion-base); + z-index: 40; +} + +.portal-assistant-btn:hover { + transform: scale(1.08); + box-shadow: + 0 0.75rem 1.75rem rgba(59, 130, 246, 0.45), + inset 0 0.0625rem 0 rgba(255, 255, 255, 0.25); +} + +.portal-assistant-btn:active { + transform: scale(1.02); +} diff --git a/frontend/portal/src/components/AssistantButton.tsx b/frontend/portal/src/components/AssistantButton.tsx new file mode 100644 index 000000000..126d84ce6 --- /dev/null +++ b/frontend/portal/src/components/AssistantButton.tsx @@ -0,0 +1,36 @@ +import { useUI } from "@portal/contexts/UIContext"; +import { SparklesIcon } from "@portal/components/icons"; +import "@portal/components/AssistantButton.css"; + +export function AssistantButton() { + const { assistantOpen, toggleAssistant } = useUI(); + return ( + + ); +} diff --git a/frontend/portal/src/components/AssistantPanel.css b/frontend/portal/src/components/AssistantPanel.css new file mode 100644 index 000000000..e16f84f6c --- /dev/null +++ b/frontend/portal/src/components/AssistantPanel.css @@ -0,0 +1,189 @@ +.portal-assistant { + position: fixed; + bottom: 5.5rem; + right: 1.5rem; + width: 23.75rem; + height: 32.5rem; + max-height: calc(100vh - 7rem); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-lg); + display: flex; + flex-direction: column; + overflow: hidden; + z-index: 35; + animation: fadeInUp var(--motion-enter) both; +} + +.portal-assistant__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.625rem 0.875rem; + color: var(--color-text-on-accent); + background: linear-gradient( + 135deg, + var(--color-blue) 0%, + var(--color-purple) 100% + ); +} + +.portal-assistant__header-left { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.portal-assistant__title { + font-size: 0.875rem; + font-weight: 600; +} + +.portal-assistant__close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + border-radius: var(--radius-sm); + color: rgba(255, 255, 255, 0.85); + transition: + background var(--motion-fast), + color var(--motion-fast); +} +.portal-assistant__close:hover { + background: rgba(255, 255, 255, 0.15); + color: var(--color-text-on-accent); +} + +.portal-assistant__messages { + flex: 1 1 auto; + overflow-y: auto; + padding: 0.875rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + background: var(--color-bg-subtle); +} + +.portal-assistant__suggestions { + margin-bottom: 0.25rem; +} + +.portal-assistant__suggestions-eyebrow { + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-section-label); + margin-bottom: 0.5rem; +} + +.portal-assistant__suggestions-list { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.portal-assistant__suggestion { + padding: 0.4375rem 0.625rem; + font-size: 0.8125rem; + text-align: left; + color: var(--color-text-2); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + transition: + border-color var(--motion-fast), + background var(--motion-fast); +} +.portal-assistant__suggestion:hover { + background: var(--color-bg-hover); + border-color: var(--color-blue-border); +} + +.portal-assistant__bubble { + max-width: 80%; + padding: 0.5rem 0.75rem; + font-size: 0.8125rem; + line-height: 1.5; + border-radius: var(--radius-md); + animation: fadeInUp var(--motion-enter) both; + word-wrap: break-word; +} + +.portal-assistant__bubble--user { + align-self: flex-end; + background: var(--color-blue); + color: var(--color-text-on-accent); + border-bottom-right-radius: 0.25rem; +} + +.portal-assistant__bubble--assistant { + align-self: flex-start; + background: var(--color-surface); + color: var(--color-text-2); + border: 1px solid var(--color-border); + border-bottom-left-radius: 0.25rem; +} + +.portal-assistant__typing { + display: inline-flex; + align-items: center; + gap: 0.25rem; +} +.portal-assistant__typing span { + width: 0.375rem; + height: 0.375rem; + border-radius: 50%; + background: var(--color-text-5); + animation: pulse 1.2s ease-in-out infinite; +} +.portal-assistant__typing span:nth-child(2) { + animation-delay: 0.15s; +} +.portal-assistant__typing span:nth-child(3) { + animation-delay: 0.3s; +} + +.portal-assistant__input-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.625rem 0.75rem; + border-top: 1px solid var(--color-border); + background: var(--color-surface); +} + +.portal-assistant__input { + flex: 1 1 auto; + font: inherit; + font-size: 0.8125rem; + background: transparent; + border: 1px solid var(--color-border-input); + border-radius: var(--radius-md); + padding: 0.4375rem 0.625rem; + color: var(--color-text-1); + outline: none; + transition: border-color var(--motion-fast); +} +.portal-assistant__input:focus { + border-color: var(--color-blue); +} + +.portal-assistant__send { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: var(--radius-md); + background: var(--grad-blue-btn); + color: var(--color-text-on-accent); + transition: opacity var(--motion-fast); +} +.portal-assistant__send:disabled { + opacity: 0.4; + cursor: not-allowed; +} diff --git a/frontend/portal/src/components/AssistantPanel.stories.tsx b/frontend/portal/src/components/AssistantPanel.stories.tsx new file mode 100644 index 000000000..92fe3e0bd --- /dev/null +++ b/frontend/portal/src/components/AssistantPanel.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useEffect } from "react"; +import { http, HttpResponse, delay } from "msw"; +import { AssistantPanel } from "@portal/components/AssistantPanel"; +import { useUI } from "@portal/contexts/UIContext"; + +function ForceOpen() { + const { openAssistant } = useUI(); + useEffect(() => { + openAssistant(); + }, [openAssistant]); + return null; +} + +const meta: Meta = { + title: "Portal/Assistant/AssistantPanel", + component: AssistantPanel, + parameters: { layout: "fullscreen" }, + decorators: [ + (S) => ( +
+ + +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const SuggestionsOnly: Story = {}; + +export const SlowReply: Story = { + parameters: { + msw: { + handlers: [ + http.post("/v1/assistant/messages", async () => { + await delay(3000); + return HttpResponse.json({ + reply: + "After a long pause: here's a deliberately slow reply to demo the typing indicator under real network latency.", + }); + }), + ], + }, + }, +}; + +export const ReplyFails: Story = { + parameters: { + msw: { + handlers: [ + http.post("/v1/assistant/messages", () => + HttpResponse.json({ error: "rate limit exceeded" }, { status: 429 }), + ), + ], + }, + }, +}; diff --git a/frontend/portal/src/components/AssistantPanel.tsx b/frontend/portal/src/components/AssistantPanel.tsx new file mode 100644 index 000000000..412131461 --- /dev/null +++ b/frontend/portal/src/components/AssistantPanel.tsx @@ -0,0 +1,169 @@ +import { useEffect, useRef, useState } from "react"; +import { useUI } from "@portal/contexts/UIContext"; +import { useAsync } from "@portal/hooks/useAsync"; +import { + fetchAssistantSuggestions, + getAssistantReply, +} from "@portal/api/assistant"; +import { CloseIcon, SendIcon, SparklesIcon } from "@portal/components/icons"; +import "@portal/components/AssistantPanel.css"; + +interface Message { + id: number; + role: "user" | "assistant"; + text: string; +} + +export function AssistantPanel() { + const { assistantOpen, closeAssistant } = useUI(); + const { data: suggestions } = useAsync( + () => fetchAssistantSuggestions(), + [], + ); + + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [typing, setTyping] = useState(false); + const messagesRef = useRef(null); + const inputRef = useRef(null); + const nextIdRef = useRef(1); + + useEffect(() => { + if (assistantOpen) { + const t = setTimeout(() => inputRef.current?.focus(), 60); + return () => clearTimeout(t); + } + return undefined; + }, [assistantOpen]); + + useEffect(() => { + messagesRef.current?.scrollTo({ + top: messagesRef.current.scrollHeight, + behavior: "smooth", + }); + }, [messages, typing]); + + async function send(text: string) { + const trimmed = text.trim(); + if (!trimmed || typing) return; + const userMsg: Message = { + id: nextIdRef.current++, + role: "user", + text: trimmed, + }; + setMessages((prev) => [...prev, userMsg]); + setInput(""); + setTyping(true); + + try { + const reply = await getAssistantReply(trimmed); + const assistantMsg: Message = { + id: nextIdRef.current++, + role: "assistant", + text: reply, + }; + setMessages((prev) => [...prev, assistantMsg]); + } catch (err) { + const failMsg: Message = { + id: nextIdRef.current++, + role: "assistant", + text: + err instanceof Error + ? `Couldn't reach the assistant: ${err.message}` + : "Couldn't reach the assistant.", + }; + setMessages((prev) => [...prev, failMsg]); + } finally { + setTyping(false); + } + } + + if (!assistantOpen) return null; + + return ( + + ); +} diff --git a/frontend/portal/src/components/DocumentTypeGrid.css b/frontend/portal/src/components/DocumentTypeGrid.css new file mode 100644 index 000000000..9d97efd0c --- /dev/null +++ b/frontend/portal/src/components/DocumentTypeGrid.css @@ -0,0 +1,227 @@ +.portal-doctype { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.portal-doctype__head { + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.portal-doctype__title { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + color: var(--color-text-1); +} + +.portal-doctype__sub { + margin: 0; + font-size: 0.8125rem; + color: var(--color-text-4); +} + +/* Tabs */ +.portal-doctype__tabs { + display: flex; + gap: 0.375rem; + flex-wrap: wrap; +} + +.portal-doctype__tab { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.3125rem 0.625rem; + font-size: 0.75rem; + color: var(--color-text-3); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-pill); + transition: + border-color var(--motion-fast), + background var(--motion-fast), + color var(--motion-fast); +} + +.portal-doctype__tab:hover { + background: var(--color-bg-hover); + color: var(--color-text-1); +} + +.portal-doctype__tab.is-active { + color: var(--portal-tab-accent, var(--color-blue)); + border-color: var(--portal-tab-accent, var(--color-blue)); + background: var(--color-surface); + font-weight: 500; +} + +.portal-doctype__tab-dot { + width: 0.4375rem; + height: 0.4375rem; + border-radius: 50%; +} + +.portal-doctype__tab-count { + font-size: 0.6875rem; + color: var(--color-text-5); +} + +/* Vertical groups */ +.portal-doctype__groups { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.portal-doctype__group-head { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.625rem; +} + +.portal-doctype__group-title { + margin: 0; + font-size: 0.875rem; + font-weight: 600; + color: var(--color-text-2); +} + +.portal-doctype__group-count { + font-size: 0.75rem; + color: var(--color-text-5); +} + +.portal-doctype__scroller { + display: flex; + gap: 0.75rem; + overflow-x: auto; + padding-bottom: 0.5rem; + scroll-snap-type: x mandatory; + scrollbar-width: thin; +} + +.portal-doctype__scroller::-webkit-scrollbar { + height: 0.375rem; +} +.portal-doctype__scroller::-webkit-scrollbar-thumb { + background: var(--color-border-hover); + border-radius: var(--radius-pill); +} + +/* Cards */ +.portal-doctype__card { + flex: 0 0 17rem; + display: flex; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + overflow: hidden; + scroll-snap-align: start; + transition: + border-color var(--motion-fast), + box-shadow var(--motion-fast), + transform var(--motion-fast); +} + +.portal-doctype__card:hover { + border-color: var(--color-border-hover); + box-shadow: var(--shadow-md); + transform: translateY(-0.0625rem); +} + +.portal-doctype__card-accent { + width: 0.25rem; + flex-shrink: 0; +} + +.portal-doctype__card-body { + flex: 1 1 auto; + padding: 0.875rem 1rem; + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; +} + +.portal-doctype__card-eyebrow { + font-size: 0.625rem; + font-weight: 600; + letter-spacing: 0.08em; + color: var(--color-text-5); +} + +.portal-doctype__card-title { + margin: 0.125rem 0 0; + font-size: 0.9375rem; + font-weight: 600; + color: var(--color-text-1); +} + +.portal-doctype__card-desc { + margin: 0; + font-size: 0.75rem; + line-height: 1.45; + color: var(--color-text-4); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.portal-doctype__card-meta { + margin-top: 0.5rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + font-size: 0.6875rem; + color: var(--color-text-4); +} + +.portal-doctype__regions { + font-family: var(--font-mono); +} + +.portal-doctype__regions-more { + color: var(--color-text-5); +} + +.portal-doctype__card-cta { + margin-top: 0.5rem; + font-size: 0.75rem; + font-weight: 500; + text-decoration: none; + transition: opacity var(--motion-fast); +} + +.portal-doctype__card-cta:hover { + opacity: 0.8; +} + +.portal-doctype__skel, +.portal-doctype__card-skel { + border-radius: var(--radius-md); + background: linear-gradient( + 90deg, + var(--color-bg-muted) 0%, + var(--color-bg-hover) 50%, + var(--color-bg-muted) 100% + ); + background-size: 200% 100%; + animation: shimmer 1.4s linear infinite; +} + +.portal-doctype__skel { + display: inline-block; + height: 0.75rem; +} + +.portal-doctype__card-skel { + flex: 0 0 17rem; + height: 8rem; + border: 1px solid var(--color-border-light); +} diff --git a/frontend/portal/src/components/DocumentTypeGrid.stories.tsx b/frontend/portal/src/components/DocumentTypeGrid.stories.tsx new file mode 100644 index 000000000..56d0f33f3 --- /dev/null +++ b/frontend/portal/src/components/DocumentTypeGrid.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse, delay } from "msw"; +import { DocumentTypeGrid } from "@portal/components/DocumentTypeGrid"; + +const meta: Meta = { + title: "Portal/Home/DocumentTypeGrid", + component: DocumentTypeGrid, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Loading: Story = { + parameters: { + msw: { + handlers: [ + http.get("/v1/endpoints", async () => { + await delay("infinite"); + return HttpResponse.json([]); + }), + ], + }, + }, +}; + +export const Empty: Story = { + parameters: { + msw: { + handlers: [http.get("/v1/endpoints", () => HttpResponse.json([]))], + }, + }, +}; diff --git a/frontend/portal/src/components/DocumentTypeGrid.tsx b/frontend/portal/src/components/DocumentTypeGrid.tsx new file mode 100644 index 000000000..8aec2784a --- /dev/null +++ b/frontend/portal/src/components/DocumentTypeGrid.tsx @@ -0,0 +1,181 @@ +import { useState } from "react"; +import { + EmptyState, + Skeleton, + StatusBadge, + Tabs, + type TabItem, +} from "@shared/components"; +import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; +import { + fetchVerticals, + type Endpoint, + type Vertical, + type VerticalKey, +} from "@portal/api/endpoints"; +import "@portal/components/DocumentTypeGrid.css"; + +type ActiveTab = VerticalKey | "all"; + +const TIER_LABEL = ["Free", "Paid", "Enterprise"] as const; +const TIER_TONE = ["success", "info", "purple"] as const; + +function tierBadge(tier: 0 | 1 | 2) { + return ( + + {TIER_LABEL[tier]} + + ); +} + +function EndpointCard({ + endpoint, + vertical, +}: { + endpoint: Endpoint; + vertical: Vertical; +}) { + const visibleRegions = endpoint.regions.slice(0, 2); + const extraRegions = endpoint.regions.length - visibleRegions.length; + return ( +
+
+
+
+ {vertical.label.toUpperCase()} +
+

{endpoint.name}

+

{endpoint.desc}

+
+ + {visibleRegions.join(" · ")} + {extraRegions > 0 && ( + + {" "} + +{extraRegions} more + + )} + + {tierBadge(endpoint.tier)} +
+ e.preventDefault()} + > + Explore + +
+
+ ); +} + +function GridSkeleton() { + return ( +
+ {Array.from({ length: 2 }).map((_, gi) => ( +
+
+ + +
+
+ {Array.from({ length: 4 }).map((_, ci) => ( + + ))} +
+
+ ))} +
+ ); +} + +export function DocumentTypeGrid() { + const [tab, setTab] = useState("all"); + const state = useAsync(() => fetchVerticals(), []); + const { data: verticals } = state; + const { isLoading, isEmpty } = useSectionFlags(state); + const hasVerticals = verticals !== null && verticals.length > 0; + + const tabItems: TabItem[] = hasVerticals + ? [ + { key: "all", label: "All" }, + ...verticals.map>((v) => ({ + key: v.key, + label: v.label, + count: v.endpoints.length, + accentColor: v.color, + dotColor: v.color, + })), + ] + : []; + + return ( +
+
+

Document types

+

+ Typed endpoints across every supported vertical — each carries a + schema, region availability and tier gate. +

+
+ + {hasVerticals && ( + + items={tabItems} + activeKey={tab} + onChange={setTab} + ariaLabel="Document type verticals" + /> + )} + + {isLoading && } + + {isEmpty && ( + + )} + + {hasVerticals && ( +
+ {(tab === "all" + ? verticals + : verticals.filter((v) => v.key === tab) + ).map((v) => ( +
+ {tab === "all" && ( +
+ +

{v.label}

+ + {v.endpoints.length} endpoints + +
+ )} +
+ {v.endpoints.map((endpoint) => ( + + ))} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/portal/src/components/Header.css b/frontend/portal/src/components/Header.css new file mode 100644 index 000000000..047009d2f --- /dev/null +++ b/frontend/portal/src/components/Header.css @@ -0,0 +1,182 @@ +.portal-header { + height: 3.25rem; + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0 1rem; + background: var(--color-header-bg); + border-bottom: 1px solid var(--color-header-border); + position: sticky; + top: 0; + z-index: 10; +} + +.portal-header__left { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 12.5rem; +} + +.portal-header__breadcrumb { + font-size: 0.8125rem; + font-weight: 500; + color: var(--color-header-text); +} + +/* Search trigger */ +.portal-header__search { + flex: 0 1 20rem; + margin: 0 auto; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0.625rem; + background: var(--color-search-bg); + border: 1px solid var(--color-search-border); + border-radius: var(--radius-md); + color: var(--color-search-text); + font-size: 0.75rem; + transition: + border-color var(--motion-fast), + background var(--motion-fast); +} +.portal-header__search:hover { + border-color: var(--color-search-border-hover); +} + +.portal-header__search-placeholder { + flex: 1 1 auto; + text-align: left; +} + +.portal-header__search-kbd { + font-family: var(--font-mono); + font-size: 0.6875rem; + padding: 0.0625rem 0.25rem; + border-radius: var(--radius-xs); + background: var(--color-bg-muted); + color: var(--color-text-4); +} + +/* Right cluster */ +.portal-header__right { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 0.375rem; +} + +.portal-header__icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: var(--radius-md); + color: var(--color-bell-stroke, var(--color-text-3)); + position: relative; + transition: + background var(--motion-fast), + color var(--motion-fast); +} +.portal-header__icon-btn:hover { + background: var(--color-bg-hover); + color: var(--color-text-1); +} + +.portal-header__bell-dot { + position: absolute; + top: 0.375rem; + right: 0.4375rem; + width: 0.4375rem; + height: 0.4375rem; + border-radius: 50%; + background: var(--color-red); + border: 2px solid var(--color-header-bg); + box-sizing: content-box; +} + +/* Tier switcher */ +.portal-header__tier { + position: relative; +} + +.portal-header__tier-btn { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.3125rem 0.625rem; + font-size: 0.75rem; + color: var(--color-text-2); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + transition: + border-color var(--motion-fast), + background var(--motion-fast); +} +.portal-header__tier-btn:hover { + background: var(--color-bg-hover); + border-color: var(--color-border-hover); +} + +.portal-header__tier-dot { + width: 0.4375rem; + height: 0.4375rem; + border-radius: 50%; +} + +.portal-header__tier-label { + font-weight: 500; +} + +.portal-header__tier-menu { + position: absolute; + top: calc(100% + 0.25rem); + right: 0; + min-width: 12rem; + margin: 0; + padding: 0.25rem; + list-style: none; + background: var(--color-dropdown-bg); + border: 1px solid var(--color-dropdown-border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + z-index: 20; + animation: fadeInUp var(--motion-enter) both; +} + +.portal-header__tier-option { + width: 100%; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.4375rem 0.625rem; + font-size: 0.8125rem; + color: var(--color-text-2); + border-radius: var(--radius-sm); + text-align: left; +} +.portal-header__tier-option:hover { + background: var(--color-dropdown-hover); +} +.portal-header__tier-option.is-active { + background: var(--color-nav-active); + color: var(--color-nav-active-text); +} + +/* Avatar */ +.portal-header__avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 50%; + background: var(--grad-blue-btn); + color: #fff; + font-size: 0.8125rem; + font-weight: 600; +} diff --git a/frontend/portal/src/components/Header.stories.tsx b/frontend/portal/src/components/Header.stories.tsx new file mode 100644 index 000000000..25f2b9c2b --- /dev/null +++ b/frontend/portal/src/components/Header.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Header } from "@portal/components/Header"; + +const meta: Meta = { + title: "Portal/Shell/Header", + component: Header, + parameters: { layout: "fullscreen" }, + decorators: [ + (S) => ( +
+ +
+ Page body would render below the header. +
+
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const FreeTier: Story = { globals: { tier: "free" } }; +export const ProTier: Story = { globals: { tier: "pro" } }; +export const EnterpriseTier: Story = { globals: { tier: "enterprise" } }; diff --git a/frontend/portal/src/components/Header.tsx b/frontend/portal/src/components/Header.tsx new file mode 100644 index 000000000..10c1607a0 --- /dev/null +++ b/frontend/portal/src/components/Header.tsx @@ -0,0 +1,104 @@ +import { Avatar, Dropdown } from "@shared/components"; +import { useTheme } from "@portal/contexts/ThemeContext"; +import { useTier, TIER_INFO, type Tier } from "@portal/contexts/TierContext"; +import { useView, VIEW_LABELS } from "@portal/contexts/ViewContext"; +import { useUI } from "@portal/contexts/UIContext"; +import { + SearchIcon, + SunIcon, + MoonIcon, + ChevronDownIcon, +} from "@portal/components/icons"; +import { NotificationsDropdown } from "@portal/components/NotificationsDropdown"; +import { MocksToggle } from "@portal/components/MocksToggle"; +import "@portal/components/Header.css"; + +function ThemeToggle() { + const { theme, toggle } = useTheme(); + return ( + + ); +} + +function TierSwitcher() { + const { tier, setTier } = useTier(); + const info = TIER_INFO[tier]; + return ( + + + + + + {(Object.keys(TIER_INFO) as Tier[]).map((id) => ( + setTier(id)} + leading={ + + } + > + {TIER_INFO[id].label} + + ))} + + + ); +} + +export function Header() { + const { activeView } = useView(); + const { openSearch } = useUI(); + return ( +
+
+ + {VIEW_LABELS[activeView]} + +
+ + + +
+ + + + + +
+
+ ); +} diff --git a/frontend/portal/src/components/MocksToggle.css b/frontend/portal/src/components/MocksToggle.css new file mode 100644 index 000000000..68e5262ed --- /dev/null +++ b/frontend/portal/src/components/MocksToggle.css @@ -0,0 +1,51 @@ +.portal-mocks-toggle { + display: inline-flex; + align-items: center; + gap: var(--space-1_5); + padding: var(--space-1) var(--space-2); + font-family: var(--font-mono); + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + border-radius: var(--radius-sm); + border: 1px dashed transparent; + transition: + background var(--motion-fast), + border-color var(--motion-fast), + color var(--motion-fast); +} + +.portal-mocks-toggle.is-on { + color: var(--color-amber-dark); + background: var(--color-amber-light); + border-color: var(--color-amber-border); +} + +.portal-mocks-toggle.is-off { + color: var(--color-text-4); + background: var(--color-bg-muted); + border-color: var(--color-border); +} + +.portal-mocks-toggle:hover { + filter: brightness(1.04); +} + +.portal-mocks-toggle.is-pending { + opacity: 0.6; + cursor: progress; +} + +.portal-mocks-toggle__dot { + width: 0.4375rem; + height: 0.4375rem; + border-radius: 50%; + background: currentColor; + box-shadow: 0 0 0 2px color-mix(in srgb, currentColor 28%, transparent); +} + +.portal-mocks-toggle.is-off .portal-mocks-toggle__dot { + background: var(--color-text-5); + box-shadow: none; +} diff --git a/frontend/portal/src/components/MocksToggle.tsx b/frontend/portal/src/components/MocksToggle.tsx new file mode 100644 index 000000000..2293102fa --- /dev/null +++ b/frontend/portal/src/components/MocksToggle.tsx @@ -0,0 +1,54 @@ +/// +import { useState } from "react"; +import { + readMocksPreference, + writeMocksPreference, +} from "@portal/mocks/preference"; +import "@portal/components/MocksToggle.css"; + +/** + * Dev-only header chip that flips MSW interception on and off. Persists the + * preference to localStorage so it survives reloads. Hidden entirely in + * production builds — there's no MSW worker to toggle there. + * + * Toggling reloads the page. Without a reload, components that already + * fetched data via useAsync keep showing the cached result, which makes the + * toggle feel like it does nothing. A reload gives a clean view of what the + * app looks like with/without mocks. + */ +export function MocksToggle() { + const [enabled] = useState(() => readMocksPreference()); + const [pending, setPending] = useState(false); + + if (!import.meta.env.DEV) return null; + + function toggle() { + if (pending) return; + setPending(true); + writeMocksPreference(!enabled); + window.location.reload(); + } + + return ( + + ); +} diff --git a/frontend/portal/src/components/NotificationsDropdown.css b/frontend/portal/src/components/NotificationsDropdown.css new file mode 100644 index 000000000..325f8f4fb --- /dev/null +++ b/frontend/portal/src/components/NotificationsDropdown.css @@ -0,0 +1,137 @@ +.portal-notif { + position: relative; +} + +.portal-notif__panel { + position: absolute; + top: calc(100% + 0.25rem); + right: 0; + width: 22.5rem; + background: var(--color-notif-bg, var(--color-surface)); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + overflow: hidden; + z-index: 25; + animation: fadeInUp var(--motion-enter) both; +} + +.portal-notif__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.625rem 0.875rem; + border-bottom: 1px solid var(--color-border-light); + font-size: 0.8125rem; +} + +.portal-notif__title { + font-weight: 600; + color: var(--color-text-1); +} + +.portal-notif__count { + font-size: 0.6875rem; + font-weight: 500; + color: var(--color-blue); + padding: 0.125rem 0.375rem; + background: var(--color-blue-light); + border-radius: var(--radius-pill); +} + +.portal-notif__list { + list-style: none; + margin: 0; + padding: 0; + max-height: 22rem; + overflow-y: auto; +} + +.portal-notif__item { + display: flex; + align-items: flex-start; + gap: 0.625rem; + padding: 0.625rem 0.875rem; + border-bottom: 1px solid var(--color-border-light); + cursor: pointer; + transition: background var(--motion-fast); +} + +.portal-notif__item:last-child { + border-bottom: none; +} + +.portal-notif__item:hover { + background: var(--color-bg-hover); +} + +.portal-notif__dot { + flex: 0 0 auto; + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + margin-top: 0.4375rem; +} + +.portal-notif__body { + flex: 1 1 auto; + min-width: 0; +} + +.portal-notif__item-title { + font-size: 0.8125rem; + font-weight: 500; + color: var(--color-text-1); + margin-bottom: 0.125rem; +} + +.portal-notif__desc { + font-size: 0.75rem; + color: var(--color-text-4); + line-height: 1.4; + margin-bottom: 0.25rem; +} + +.portal-notif__time { + font-size: 0.6875rem; + color: var(--color-text-5); +} + +.portal-notif__footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0.625rem; + border-top: 1px solid var(--color-border-light); + background: var(--color-bg-subtle); +} + +.portal-notif__action { + font-size: 0.75rem; + font-weight: 500; + color: var(--color-blue); + padding: 0.25rem 0.5rem; + border-radius: var(--radius-sm); + transition: background var(--motion-fast); +} + +.portal-notif__action:hover:not(:disabled) { + background: var(--color-bg-hover); +} + +.portal-notif__action:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.portal-notif__count--quiet { + background: var(--color-bg-muted); + color: var(--color-text-5); +} + +.portal-notif__empty { + padding: 1.5rem 1rem; + text-align: center; + font-size: 0.8125rem; + color: var(--color-text-4); +} diff --git a/frontend/portal/src/components/NotificationsDropdown.stories.tsx b/frontend/portal/src/components/NotificationsDropdown.stories.tsx new file mode 100644 index 000000000..1470f5e44 --- /dev/null +++ b/frontend/portal/src/components/NotificationsDropdown.stories.tsx @@ -0,0 +1,76 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse, delay } from "msw"; +import { NotificationsDropdown } from "@portal/components/NotificationsDropdown"; +import { NOTIFICATIONS } from "@portal/mocks/notifications"; + +const meta: Meta = { + title: "Portal/Header/NotificationsDropdown", + component: NotificationsDropdown, + parameters: { layout: "centered" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Empty: Story = { + parameters: { + msw: { + handlers: [http.get("/v1/notifications", () => HttpResponse.json([]))], + }, + }, +}; + +export const Loading: Story = { + parameters: { + msw: { + handlers: [ + http.get("/v1/notifications", async () => { + await delay("infinite"); + return HttpResponse.json([]); + }), + ], + }, + }, +}; + +export const HighVolume: Story = { + parameters: { + msw: { + handlers: [ + http.get("/v1/notifications", () => { + const items = Array.from({ length: 24 }, (_, i) => ({ + ...NOTIFICATIONS[i % NOTIFICATIONS.length], + id: `n${i + 1}`, + })); + return HttpResponse.json(items); + }), + ], + }, + }, +}; + +export const NetworkError: Story = { + parameters: { + msw: { + handlers: [ + http.get("/v1/notifications", () => + HttpResponse.json({ error: "Service unavailable" }, { status: 503 }), + ), + ], + }, + }, +}; diff --git a/frontend/portal/src/components/NotificationsDropdown.tsx b/frontend/portal/src/components/NotificationsDropdown.tsx new file mode 100644 index 000000000..2445798cc --- /dev/null +++ b/frontend/portal/src/components/NotificationsDropdown.tsx @@ -0,0 +1,125 @@ +import { useState } from "react"; +import { Dropdown, EmptyState, Skeleton } from "@shared/components"; +import { BellIcon } from "@portal/components/icons"; +import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; +import { + fetchNotifications, + markAllNotificationsRead, + type Notification, + type NotificationCategory, +} from "@portal/api/notifications"; +import "@portal/components/NotificationsDropdown.css"; + +const CATEGORY_COLOUR: Record = { + pipeline: "var(--color-blue)", + deploy: "var(--color-green)", + billing: "var(--color-amber)", + audit: "var(--color-purple)", + agent: "var(--color-cat-insurance)", + doc: "var(--color-cat-healthcare)", +}; + +export function NotificationsDropdown() { + const state = useAsync(() => fetchNotifications(), []); + const { data: items } = state; + const { isLoading } = useSectionFlags(state); + + // Optimistically clear on "mark all read"; revert if the request fails. + const [cleared, setCleared] = useState(false); + const visible = cleared ? [] : (items ?? []); + + async function onMarkAllRead() { + setCleared(true); + try { + await markAllNotificationsRead(); + } catch { + setCleared(false); + } + } + + const isEmpty = !isLoading && visible.length === 0; + const hasUnread = visible.length > 0; + + return ( + + + + + +
+ Notifications + {hasUnread ? ( + {visible.length} new + ) : isLoading ? ( + + loading + + ) : ( + + all read + + )} +
+ {isLoading && ( +
+ + + + +
+ )} + {isEmpty && ( + + )} + {!isLoading && !isEmpty && ( +
    + {visible.map((item) => ( +
  • + +
    +
    {item.title}
    +
    {item.description}
    +
    {item.time}
    +
    +
  • + ))} +
+ )} +
+ + +
+
+
+ ); +} diff --git a/frontend/portal/src/components/PopularUseCases.css b/frontend/portal/src/components/PopularUseCases.css new file mode 100644 index 000000000..af79deb2a --- /dev/null +++ b/frontend/portal/src/components/PopularUseCases.css @@ -0,0 +1,87 @@ +.portal-usecases { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.portal-usecases__head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem; +} + +.portal-usecases__title { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + color: var(--color-text-1); +} + +.portal-usecases__viewall, +.portal-usecases__cta { + background: none; + border: none; + padding: 0; + cursor: pointer; + font-family: inherit; + font-weight: 500; + display: inline-flex; + align-items: center; + gap: 0.25rem; + transition: opacity var(--motion-fast); +} + +.portal-usecases__viewall { + font-size: 0.8125rem; + color: var(--color-blue); +} + +.portal-usecases__viewall:hover, +.portal-usecases__cta:hover { + opacity: 0.8; +} + +.portal-usecases__grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.875rem; +} + +@media (max-width: 50rem) { + .portal-usecases__grid { + grid-template-columns: 1fr; + } +} + +.portal-usecases__card { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.portal-usecases__eyebrow { + font-size: 0.625rem; + font-weight: 600; + letter-spacing: 0.08em; +} + +.portal-usecases__card-title { + margin: 0.125rem 0 0; + font-size: 1rem; + font-weight: 600; + color: var(--color-text-1); +} + +.portal-usecases__blurb { + margin: 0; + font-size: 0.8125rem; + line-height: 1.5; + color: var(--color-text-3); +} + +.portal-usecases__cta { + margin-top: 0.5rem; + align-self: flex-start; + font-size: 0.8125rem; +} diff --git a/frontend/portal/src/components/PopularUseCases.tsx b/frontend/portal/src/components/PopularUseCases.tsx new file mode 100644 index 000000000..bdbeaff82 --- /dev/null +++ b/frontend/portal/src/components/PopularUseCases.tsx @@ -0,0 +1,107 @@ +import { Card, type CardProps } from "@shared/components"; +import { useView } from "@portal/contexts/ViewContext"; +import "@portal/components/PopularUseCases.css"; + +type Accent = NonNullable; + +interface UseCase { + eyebrow: string; + title: string; + blurb: string; + cta: string; + accent: Accent; +} + +const ACCENT_COLOR: Record = { + blue: "var(--color-blue)", + purple: "var(--color-purple)", + green: "var(--color-green)", + amber: "var(--color-amber)", + red: "var(--color-red)", +}; + +/** + * Curated landing-page use cases — a teaser, not the full catalogue. The + * exhaustive per-vertical endpoint list lives on the Documents view; here we + * surface the four cross-cutting pipelines people reach for first. Copy mirrors + * the prototype's "Popular use cases" block. + */ +const USE_CASES: UseCase[] = [ + { + eyebrow: "AUTO-ROUTING", + title: "Auto-classify and route incoming documents", + blurb: + "One classifier reads what arrived — KYC form, invoice, contract, COI — and routes to the right downstream pipeline. No manual triage, no docs in the wrong workflow.", + cta: "Build a classifier pipeline", + accent: "blue", + }, + { + eyebrow: "PII REDACTION", + title: "Redact PII before it leaves your stack", + blurb: + "Strip sensitive fields before storage, indexing, or LLM processing. Schema-aware, per-field audit, BYOK or HYOK keys. Compliance at the document boundary, not per pipeline.", + cta: "See redaction pipelines", + accent: "red", + }, + { + eyebrow: "TRAINING DATA", + title: "Turn PDFs into training data", + blurb: + "Batch-import an archive, redact PII, classify, chunk, and emit ready-to-load JSON for fine-tuning, eval sets, or RAG. Self-completing and replayable.", + cta: "Build a training-data pipeline", + accent: "purple", + }, + { + eyebrow: "AUTHENTICITY", + title: "Verify signatures and detect tampering", + blurb: + "Cryptographic checks at the document boundary — signature validation, tamper detection, signing flows for outbound documents. Trust decisions in the pipeline, not your app code.", + cta: "Try authenticity check", + accent: "green", + }, +]; + +export function PopularUseCases() { + const { setActiveView } = useView(); + return ( +
+
+

Popular use cases

+ +
+
+ {USE_CASES.map((uc) => ( + + + {uc.eyebrow} + +

{uc.title}

+

{uc.blurb}

+ +
+ ))} +
+
+ ); +} diff --git a/frontend/portal/src/components/RecentActivity.css b/frontend/portal/src/components/RecentActivity.css new file mode 100644 index 000000000..9fceadfe8 --- /dev/null +++ b/frontend/portal/src/components/RecentActivity.css @@ -0,0 +1,138 @@ +.portal-activity { + display: flex; + flex-direction: column; +} + +.portal-activity__head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.875rem 1rem; + border-bottom: 1px solid var(--color-border-light); +} + +.portal-activity__title { + margin: 0; + font-size: 0.9375rem; + font-weight: 600; + color: var(--color-text-1); +} + +.portal-activity__more { + font-size: 0.75rem; + font-weight: 500; + color: var(--color-blue); + padding: 0.25rem 0.5rem; + border-radius: var(--radius-sm); + transition: background var(--motion-fast); +} + +.portal-activity__more:hover { + background: var(--color-bg-hover); +} + +.portal-activity__list { + list-style: none; + margin: 0; + padding: 0; +} + +.portal-activity__item { + display: flex; + gap: 0.75rem; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--color-border-light); + transition: background var(--motion-fast); +} + +.portal-activity__item:last-child { + border-bottom: none; +} + +.portal-activity__item:hover { + background: var(--color-bg-hover); +} + +.portal-activity__rail { + width: 0.1875rem; + border-radius: var(--radius-pill); + flex-shrink: 0; +} + +.portal-activity__body { + flex: 1 1 auto; + min-width: 0; +} + +.portal-activity__row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem; +} + +.portal-activity__action { + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-text-1); +} + +.portal-activity__time { + font-size: 0.6875rem; + color: var(--color-text-5); +} + +.portal-activity__subject { + font-size: 0.75rem; + color: var(--color-text-3); + margin-top: 0.0625rem; +} + +.portal-activity__detail-row { + margin-top: 0.375rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.portal-activity__detail { + font-size: 0.75rem; + color: var(--color-text-4); + font-family: var(--font-mono); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.portal-activity__item--skeleton { + cursor: default; +} + +.portal-activity__item--skeleton .portal-activity__rail { + background: var(--color-border-light); +} + +.portal-activity__skel { + height: 0.75rem; + border-radius: var(--radius-pill); + background: linear-gradient( + 90deg, + var(--color-bg-muted) 0%, + var(--color-bg-hover) 50%, + var(--color-bg-muted) 100% + ); + background-size: 200% 100%; + animation: shimmer 1.4s linear infinite; + margin-bottom: 0.375rem; +} + +.portal-activity__skel--md { + width: 70%; +} + +.portal-activity__skel--sm { + width: 50%; + height: 0.625rem; +} diff --git a/frontend/portal/src/components/RecentActivity.stories.tsx b/frontend/portal/src/components/RecentActivity.stories.tsx new file mode 100644 index 000000000..5b33617ea --- /dev/null +++ b/frontend/portal/src/components/RecentActivity.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse, delay } from "msw"; +import { RecentActivity } from "@portal/components/RecentActivity"; + +const meta: Meta = { + title: "Portal/Home/RecentActivity", + component: RecentActivity, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Loading: Story = { + parameters: { + msw: { + handlers: [ + http.get("/v1/activity", async () => { + await delay("infinite"); + return HttpResponse.json([]); + }), + ], + }, + }, +}; + +export const Empty: Story = { + parameters: { + msw: { + handlers: [http.get("/v1/activity", () => HttpResponse.json([]))], + }, + }, +}; diff --git a/frontend/portal/src/components/RecentActivity.tsx b/frontend/portal/src/components/RecentActivity.tsx new file mode 100644 index 000000000..74a11f042 --- /dev/null +++ b/frontend/portal/src/components/RecentActivity.tsx @@ -0,0 +1,94 @@ +import { Card, EmptyState, Skeleton, StatusBadge } from "@shared/components"; +import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; +import { + fetchRecentActivity, + type ActivityEvent, + type ActivityKind, +} from "@portal/api/home"; +import "@portal/components/RecentActivity.css"; + +const KIND_COLOUR: Record = { + "pipeline-run": "var(--color-blue)", + deploy: "var(--color-green)", + drift: "var(--color-amber)", + eval: "var(--color-purple)", + agent: "var(--color-cat-insurance)", + billing: "var(--color-red)", +}; + +export function RecentActivity() { + const state = useAsync(() => fetchRecentActivity(), []); + const { data: events } = state; + const { isLoading, isEmpty } = useSectionFlags(state); + + return ( + +
+

Recent activity

+ +
+ + {isLoading && ( +
    + {Array.from({ length: 5 }).map((_, i) => ( +
  • + +
    + + +
    +
  • + ))} +
+ )} + + {isEmpty && ( + + )} + + {events && events.length > 0 && ( +
    + {events.map((event) => ( +
  1. + +
    +
    + + {event.action} + + {event.time} +
    +
    {event.subject}
    +
    + + {event.detail} + + + {event.status} + +
    +
    +
  2. + ))} +
+ )} +
+ ); +} diff --git a/frontend/portal/src/components/SearchModal.css b/frontend/portal/src/components/SearchModal.css new file mode 100644 index 000000000..6b139c975 --- /dev/null +++ b/frontend/portal/src/components/SearchModal.css @@ -0,0 +1,70 @@ +.portal-search__input-row { + display: flex; + align-items: center; + gap: 0.625rem; + padding: 0.25rem 0.25rem 0.75rem; + border-bottom: 1px solid var(--color-border-light); + color: var(--color-text-4); +} + +.portal-search__input { + flex: 1 1 auto; + font: inherit; + font-size: 0.9375rem; + background: transparent; + border: none; + outline: none; + color: var(--color-text-1); +} + +.portal-search__input::placeholder { + color: var(--color-text-placeholder); +} + +.portal-search__esc { + font-family: var(--font-mono); + font-size: 0.6875rem; + padding: 0.0625rem 0.375rem; + border-radius: var(--radius-xs); + background: var(--color-bg-muted); + color: var(--color-text-4); +} + +.portal-search__results { + padding-top: 0.75rem; + display: flex; + flex-direction: column; + gap: 1rem; + max-height: 24rem; + overflow-y: auto; +} + +.portal-search__group-label { + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-section-label); + padding: 0 0.5rem 0.375rem; +} + +.portal-search__item { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0.625rem; + border-radius: var(--radius-sm); + color: var(--color-text-2); + font-size: 0.8125rem; + text-align: left; +} + +.portal-search__item:hover { + background: var(--color-bg-hover); +} + +.portal-search__item-hint { + font-family: var(--font-mono); + font-size: 0.6875rem; + color: var(--color-text-5); +} diff --git a/frontend/portal/src/components/SearchModal.stories.tsx b/frontend/portal/src/components/SearchModal.stories.tsx new file mode 100644 index 000000000..16de3449e --- /dev/null +++ b/frontend/portal/src/components/SearchModal.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useEffect } from "react"; +import { http, HttpResponse } from "msw"; +import { SearchModal } from "@portal/components/SearchModal"; +import { useUI } from "@portal/contexts/UIContext"; + +function ForceOpen() { + const { openSearch } = useUI(); + useEffect(() => { + openSearch(); + }, [openSearch]); + return null; +} + +const meta: Meta = { + title: "Portal/Header/SearchModal", + component: SearchModal, + parameters: { layout: "fullscreen" }, + decorators: [ + (S) => ( +
+ + +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const EmptyCatalogue: Story = { + parameters: { + msw: { + handlers: [ + http.get("/v1/search/quick-actions", () => HttpResponse.json([])), + ], + }, + }, +}; diff --git a/frontend/portal/src/components/SearchModal.tsx b/frontend/portal/src/components/SearchModal.tsx new file mode 100644 index 000000000..f0da1abef --- /dev/null +++ b/frontend/portal/src/components/SearchModal.tsx @@ -0,0 +1,121 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { EmptyState, Modal, Skeleton } from "@shared/components"; +import { useUI } from "@portal/contexts/UIContext"; +import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; +import { fetchQuickActions, type QuickAction } from "@portal/api/search"; +import { SearchIcon } from "@portal/components/icons"; +import "@portal/components/SearchModal.css"; + +export function SearchModal() { + const { searchOpen, closeSearch } = useUI(); + const inputRef = useRef(null); + const [query, setQuery] = useState(""); + const state = useAsync(() => fetchQuickActions(), []); + const { data: actions } = state; + const { isLoading } = useSectionFlags(state); + + useEffect(() => { + if (searchOpen) { + setQuery(""); + const t = setTimeout(() => inputRef.current?.focus(), 0); + return () => clearTimeout(t); + } + return undefined; + }, [searchOpen]); + + const filtered = useMemo(() => { + if (!actions) return [] as QuickAction[]; + if (!query.trim()) return actions; + const needle = query.trim().toLowerCase(); + return actions.filter((item) => item.label.toLowerCase().includes(needle)); + }, [actions, query]); + + const groups = useMemo( + () => + filtered.reduce>((acc, item) => { + if (!acc[item.group]) acc[item.group] = []; + acc[item.group].push(item); + return acc; + }, {}), + [filtered], + ); + + const groupKeys = Object.keys(groups); + const isEmpty = !isLoading && groupKeys.length === 0; + + return ( + +
+
+ + setQuery(e.target.value)} + placeholder="Search Stirling — endpoints, pipelines, docs…" + aria-label="Search" + className="portal-search__input" + autoComplete="off" + spellCheck={false} + /> + + ESC + +
+ +
+ {isLoading && ( +
+ + + + +
+ )} + {isEmpty && ( + + )} + {!isLoading && + !isEmpty && + Object.entries(groups).map(([group, items]) => ( +
+
{group}
+ {items.map((item) => ( + + ))} +
+ ))} +
+
+
+ ); +} diff --git a/frontend/portal/src/components/Sidebar.css b/frontend/portal/src/components/Sidebar.css new file mode 100644 index 000000000..e422d92ed --- /dev/null +++ b/frontend/portal/src/components/Sidebar.css @@ -0,0 +1,130 @@ +.portal-sidebar { + width: 15rem; + height: 100vh; + background: var(--color-sidebar-bg); + border-right: 1px solid var(--color-sidebar-border); + display: flex; + flex-direction: column; + flex-shrink: 0; + position: sticky; + top: 0; +} + +/* Logo block */ +.portal-sidebar__logo { + height: 3.1875rem; /* 51px */ + padding: 0 0.875rem; + display: flex; + align-items: center; + gap: 0.5rem; + border-bottom: 1px solid var(--color-sidebar-divider); +} + +.portal-sidebar__wordmark { + font-family: var(--font-brand); + font-size: 1.125rem; + font-weight: 700; + color: var(--color-logo-text); + letter-spacing: 0.02em; +} + +.portal-sidebar__workspace { + margin-left: auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + border-radius: var(--radius-sm); + color: var(--color-text-4); + transition: + background var(--motion-fast), + color var(--motion-fast); +} +.portal-sidebar__workspace:hover { + background: var(--color-bg-hover); + color: var(--color-text-2); +} + +/* Nav body */ +.portal-sidebar__nav { + flex: 1 1 auto; + overflow-y: auto; + padding: 0.75rem 0.625rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.portal-sidebar__group { + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.portal-sidebar__divider { + height: 1px; + background: var(--color-sidebar-divider); + margin: 0.25rem 0; +} + +/* Footer */ +.portal-sidebar__footer { + border-top: 1px solid var(--color-sidebar-divider); + padding: 0.5rem 0.625rem 0.75rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.portal-sidebar__usage { + padding: 0.5rem 0.625rem; + font-size: 0.75rem; + color: var(--color-usage-text); +} + +.portal-sidebar__usage-line { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.portal-sidebar__usage-label { + color: var(--color-text-4); +} + +.portal-sidebar__usage-value { + color: var(--color-usage-value); + font-weight: 500; +} + +.portal-sidebar__usage-track { + margin-top: 0.5rem; + height: 0.25rem; + background: var(--color-usage-track); + border-radius: var(--radius-pill); + overflow: hidden; +} + +.portal-sidebar__usage-fill { + height: 100%; + background: var(--grad-blue-btn); + transition: width var(--motion-slow); +} + +.portal-sidebar__plan { + display: inline-flex; + align-items: center; + gap: 0.375rem; + color: var(--color-text-2); + font-weight: 500; +} + +.portal-sidebar__plan-dot { + width: 0.4375rem; + height: 0.4375rem; + border-radius: 50%; + background: var(--color-green); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-green) 30%, transparent); +} diff --git a/frontend/portal/src/components/Sidebar.stories.tsx b/frontend/portal/src/components/Sidebar.stories.tsx new file mode 100644 index 000000000..df4d6c1cd --- /dev/null +++ b/frontend/portal/src/components/Sidebar.stories.tsx @@ -0,0 +1,29 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Sidebar } from "@portal/components/Sidebar"; + +const meta: Meta = { + title: "Portal/Shell/Sidebar", + component: Sidebar, + parameters: { layout: "fullscreen" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const FreeTier: Story = { globals: { tier: "free" } }; + +export const EnterpriseTier: Story = { globals: { tier: "enterprise" } }; diff --git a/frontend/portal/src/components/Sidebar.tsx b/frontend/portal/src/components/Sidebar.tsx new file mode 100644 index 000000000..5196c677c --- /dev/null +++ b/frontend/portal/src/components/Sidebar.tsx @@ -0,0 +1,174 @@ +import { NavItem } from "@shared/components"; +import { useView, type ViewId } from "@portal/contexts/ViewContext"; +import { useTier } from "@portal/contexts/TierContext"; +import { useAsync } from "@portal/hooks/useAsync"; +import { fetchHomeKpis, type KpiEntry } from "@portal/api/home"; +import { + HomeIcon, + EditorIcon, + SourcesIcon, + PipelinesIcon, + DocumentsIcon, + InfrastructureIcon, + UsageIcon, + DocsIcon, + SettingsIcon, + ChevronDownIcon, +} from "@portal/components/icons"; +import "@portal/components/Sidebar.css"; + +interface NavEntry { + id: ViewId; + label: string; + icon: React.ReactNode; +} + +const GROUP_PRIMARY: NavEntry[] = [ + { id: "home", label: "Home", icon: }, +]; + +const GROUP_OPERATIONAL: NavEntry[] = [ + { id: "editor", label: "Editor", icon: }, + { id: "sources", label: "Sources", icon: }, + { id: "pipelines", label: "Pipelines", icon: }, + { id: "documents", label: "Documents", icon: }, +]; + +const GROUP_PLATFORM: NavEntry[] = [ + { + id: "infrastructure", + label: "Infrastructure", + icon: , + }, + { id: "usage", label: "Usage & Billing", icon: }, + { id: "docs", label: "Developer Docs", icon: }, +]; + +function StirlingMark() { + // The Stirling brand mark — two stacked parallelograms in the brand blues. + return ( + + + + + ); +} + +function UsageFooter() { + const { tier } = useTier(); + // Read the same endpoint Home's KPI strip uses so the doc count here can't + // drift from the headline figure. The first KPI is always the doc total. + const { data: kpis, loading } = useAsync( + () => fetchHomeKpis(tier), + [tier], + ); + const docs = loading ? undefined : kpis?.[0]?.value; + + if (tier === "free") { + // The free doc KPI is formatted "used / cap"; parse it for the meter. + const [used, cap] = + typeof docs === "string" + ? docs.split("/").map((s) => Number(s.replace(/[^\d]/g, ""))) + : []; + const pct = used && cap ? (used / cap) * 100 : 0; + return ( +
+
+ Docs processed + {docs ?? "—"} +
+
+
+
+
+ ); + } + + const planLabel = tier === "pro" ? "Pay-as-you-go" : "Enterprise Plan"; + + return ( +
+
+ + + {planLabel} + + + {docs != null ? `${docs} docs` : "—"} + +
+
+ ); +} + +export function Sidebar() { + const { activeView, setActiveView } = useView(); + + function renderGroup(entries: NavEntry[]) { + return entries.map((entry) => ( + setActiveView(id as ViewId)} + /> + )); + } + + return ( + + ); +} diff --git a/frontend/portal/src/components/SingleOpRunner.css b/frontend/portal/src/components/SingleOpRunner.css new file mode 100644 index 000000000..47b998388 --- /dev/null +++ b/frontend/portal/src/components/SingleOpRunner.css @@ -0,0 +1,345 @@ +.portal-runner__layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr); + gap: 1rem; + min-height: 26rem; +} + +@media (max-width: 50rem) { + .portal-runner__layout { + grid-template-columns: 1fr; + } +} + +.portal-runner__left, +.portal-runner__right { + display: flex; + flex-direction: column; + gap: 0.875rem; + min-width: 0; +} + +/* Drop zone */ +.portal-runner__drop { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.625rem; + padding: 1.25rem; + border: 1.5px dashed var(--color-border-input); + border-radius: var(--radius-md); + background: var(--color-bg-subtle); + color: var(--color-text-3); + text-align: center; + transition: + border-color var(--motion-fast), + background var(--motion-fast); +} + +.portal-runner__drop.is-armed, +.portal-runner__drop:hover { + border-color: var(--color-blue); + background: var(--color-blue-light); + color: var(--color-blue); +} + +.portal-runner__drop.has-file { + border-color: var(--color-green); + background: var(--color-green-light); + color: var(--color-text-2); +} + +.portal-runner__drop-icon { + color: var(--color-text-4); +} + +.portal-runner__drop.is-armed .portal-runner__drop-icon, +.portal-runner__drop.has-file .portal-runner__drop-icon { + color: inherit; +} + +.portal-runner__drop-text { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.125rem; + font-size: 0.8125rem; +} + +.portal-runner__drop-text strong { + font-size: 0.875rem; + color: var(--color-text-1); + font-family: var(--font-mono); +} + +.portal-runner__drop-text span { + font-size: 0.75rem; + color: var(--color-text-4); +} + +.portal-runner__sample-btn { + font-size: 0.75rem; + font-weight: 500; + color: var(--color-blue); + padding: 0.25rem 0.5rem; + border-radius: var(--radius-sm); + transition: background var(--motion-fast); +} + +.portal-runner__sample-btn:hover { + background: var(--color-bg-hover); +} + +/* Op picker */ +.portal-runner__section-title { + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-section-label); + margin-bottom: 0.5rem; +} + +.portal-runner__ops { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.375rem; +} + +.portal-runner__op { + position: relative; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.125rem; + padding: 0.5rem 0.625rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + text-align: left; + transition: + border-color var(--motion-fast), + background var(--motion-fast); +} + +.portal-runner__op:hover { + border-color: var(--color-border-hover); + background: var(--color-bg-hover); +} + +.portal-runner__op.is-selected { + border-color: var(--color-blue); + background: var(--color-blue-light); +} + +.portal-runner__op[data-accent="purple"].is-selected { + border-color: var(--color-purple); + background: var(--color-purple-light); +} +.portal-runner__op[data-accent="green"].is-selected { + border-color: var(--color-green); + background: var(--color-green-light); +} +.portal-runner__op[data-accent="amber"].is-selected { + border-color: var(--color-amber); + background: var(--color-amber-light); +} +.portal-runner__op[data-accent="red"].is-selected { + border-color: var(--color-red); + background: var(--color-red-light); +} + +.portal-runner__op-label { + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-text-1); +} + +.portal-runner__op-endpoint { + font-family: var(--font-mono); + font-size: 0.6875rem; + color: var(--color-text-4); +} + +.portal-runner__op-blurb { + font-size: 0.6875rem; + color: var(--color-text-3); + line-height: 1.35; +} + +.portal-runner__op--skeleton { + display: flex; + flex-direction: column; + gap: 0.375rem; + cursor: default; +} +.portal-runner__op--skeleton:hover { + border-color: var(--color-border-light); + background: transparent; +} + +/* Right column states */ +.portal-runner__right { + border: 1px solid var(--color-border-light); + border-radius: var(--radius-md); + background: var(--color-bg-subtle); + padding: 1.125rem; + overflow: hidden; + min-height: 22rem; +} + +.portal-runner__hint { + color: var(--color-text-3); +} + +.portal-runner__hint-eyebrow { + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-blue); + margin-bottom: 0.5rem; +} + +.portal-runner__hint h3 { + margin: 0 0 0.5rem; + font-size: 1rem; + font-weight: 600; + color: var(--color-text-1); +} + +.portal-runner__hint h3 code { + font-family: var(--font-mono); + font-size: 0.9375rem; + color: var(--color-blue); + background: var(--color-blue-light); + padding: 0 0.25rem; + border-radius: var(--radius-xs); +} + +.portal-runner__hint p { + margin: 0; + font-size: 0.8125rem; + line-height: 1.5; +} + +.portal-runner__hint p code { + font-family: var(--font-mono); + font-size: 0.75rem; + color: var(--color-text-2); + background: var(--color-bg-muted); + padding: 0 0.25rem; + border-radius: var(--radius-xs); +} + +.portal-runner__hint kbd { + font-family: var(--font-mono); + font-size: 0.6875rem; + background: var(--color-bg-muted); + padding: 0.0625rem 0.3125rem; + border-radius: var(--radius-xs); + border: 1px solid var(--color-border); +} + +/* Running state */ +.portal-runner__running { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem; + background: var(--color-blue-light); + border: 1px solid var(--color-blue-border); + border-radius: var(--radius-md); + color: var(--color-blue); +} + +.portal-runner__running-title { + font-size: 0.875rem; + font-weight: 600; + color: var(--color-text-1); + margin-bottom: 0.25rem; +} + +.portal-runner__running code { + font-family: var(--font-mono); + font-size: 0.75rem; +} + +.portal-runner__spinner, +.portal-runner__spinner-lg { + border-radius: 50%; + border: 2px solid currentColor; + border-right-color: transparent; + animation: spin 0.7s linear infinite; + flex-shrink: 0; +} + +.portal-runner__spinner { + width: 0.875rem; + height: 0.875rem; +} + +.portal-runner__spinner-lg { + width: 1.5rem; + height: 1.5rem; +} + +/* Done state */ +.portal-runner__result { + display: flex; + flex-direction: column; + height: 100%; +} + +.portal-runner__result-head { + display: flex; + align-items: center; + gap: 0.625rem; + padding: 0.5rem 0.75rem; + background: var(--code-bg-header); + border-bottom: 1px solid var(--code-border); + border-radius: var(--radius-md) var(--radius-md) 0 0; + color: var(--code-muted); + font-family: var(--font-mono); + font-size: 0.75rem; +} + +.portal-runner__result-head code { + flex: 1; + font-family: inherit; + color: var(--code-keyword); +} + +.portal-runner__result-meta { + font-size: 0.6875rem; + color: var(--code-dim); +} + +.portal-runner__result-code { + margin: 0; + padding: 0.875rem 1rem; + background: var(--code-bg); + color: var(--code-text); + font-family: var(--font-mono); + font-size: 0.75rem; + line-height: 1.6; + border-radius: 0 0 var(--radius-md) var(--radius-md); + overflow: auto; + white-space: pre; + max-height: 22rem; +} + +/* Footer */ +.portal-runner__footer-status { + margin-right: auto; + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.75rem; + color: var(--color-text-4); + font-family: var(--font-mono); +} + +.portal-runner__footer-status code { + font-family: inherit; +} diff --git a/frontend/portal/src/components/SingleOpRunner.stories.tsx b/frontend/portal/src/components/SingleOpRunner.stories.tsx new file mode 100644 index 000000000..7f696d194 --- /dev/null +++ b/frontend/portal/src/components/SingleOpRunner.stories.tsx @@ -0,0 +1,53 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse, delay } from "msw"; +import { SingleOpRunner } from "@portal/components/SingleOpRunner"; + +const meta: Meta = { + title: "Portal/Home/SingleOpRunner", + component: SingleOpRunner, + parameters: { layout: "fullscreen" }, + args: { open: true, onClose: () => console.log("close") }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Idle: Story = {}; + +export const PreSelectedOp: Story = { + args: { initialOpId: "redact" }, +}; + +export const RunFailsWithUnknownOp: Story = { + parameters: { + msw: { + handlers: [ + http.post("/v1/ops/:opId/run", () => + HttpResponse.json({ error: "Unknown op" }, { status: 404 }), + ), + ], + }, + }, +}; + +export const SlowOp: Story = { + parameters: { + msw: { + handlers: [ + http.post("/v1/ops/:opId/run", async () => { + await delay(4000); + return HttpResponse.json({ + result: { schema: "demo", note: "took its time" }, + durationMs: 4000, + }); + }), + ], + }, + }, +}; diff --git a/frontend/portal/src/components/SingleOpRunner.tsx b/frontend/portal/src/components/SingleOpRunner.tsx new file mode 100644 index 000000000..e383652c4 --- /dev/null +++ b/frontend/portal/src/components/SingleOpRunner.tsx @@ -0,0 +1,355 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + Button, + EmptyState, + Modal, + Skeleton, + StatusBadge, +} from "@shared/components"; +import { useView } from "@portal/contexts/ViewContext"; +import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; +import { + fetchFeaturedOps, + runSingleOp, + type FeaturedOp, + type OpResultMap, +} from "@portal/api/ops"; +import "@portal/components/SingleOpRunner.css"; + +type Phase = "idle" | "running" | "done" | "error"; + +interface SingleOpRunnerProps { + open: boolean; + onClose: () => void; + /** Optional pre-selected op id (e.g. carousel deep-link). */ + initialOpId?: string; +} + +const SAMPLE_DOCS = [ + "certificate-of-insurance.pdf", + "loss-run-2025.pdf", + "invoice-acme-corp-q1.pdf", + "prior-auth-cigna-12471.pdf", + "contract-acme-msa-v3.pdf", +]; + +interface RunResult { + result: OpResultMap; + durationMs: number; + opId: string; +} + +export function SingleOpRunner({ + open, + onClose, + initialOpId, +}: SingleOpRunnerProps) { + const { setActiveView } = useView(); + const opsState = useAsync(() => fetchFeaturedOps(), []); + const { data: ops } = opsState; + const { isLoading: opsIsLoading, isEmpty: opsIsEmpty } = + useSectionFlags(opsState); + + const [selectedOpId, setSelectedOpId] = useState( + initialOpId ?? null, + ); + const [phase, setPhase] = useState("idle"); + const [sample, setSample] = useState(null); + const [dragOver, setDragOver] = useState(false); + const [runResult, setRunResult] = useState(null); + const [errorMsg, setErrorMsg] = useState(null); + + // Default selection once the catalogue loads. + useEffect(() => { + if (!selectedOpId && ops && ops.length > 0) { + setSelectedOpId(ops[0].id); + } + }, [ops, selectedOpId]); + + const selectedOp = useMemo( + () => ops?.find((o) => o.id === selectedOpId) ?? null, + [ops, selectedOpId], + ); + + useEffect(() => { + if (open) { + setSelectedOpId(initialOpId ?? ops?.[0]?.id ?? null); + setPhase("idle"); + setSample(null); + setRunResult(null); + setErrorMsg(null); + } + // ops is intentionally not in deps — we don't want to reset state when + // the catalogue arrives while the modal is open. + }, [open, initialOpId]); + + const pickSample = useCallback(() => { + if (!ops) return; + const idx = ops.findIndex((o) => o.id === selectedOpId); + const safeIdx = idx < 0 ? 0 : idx; + setSample(SAMPLE_DOCS[safeIdx % SAMPLE_DOCS.length]); + }, [ops, selectedOpId]); + + async function run() { + if (!selectedOp) return; + let activeSample = sample; + if (!activeSample) { + pickSample(); + // pickSample updates state; use the synchronously-derived value for the + // call so we don't race. + const idx = ops?.findIndex((o) => o.id === selectedOpId) ?? 0; + activeSample = SAMPLE_DOCS[idx % SAMPLE_DOCS.length]; + } + setPhase("running"); + setErrorMsg(null); + try { + const res = await runSingleOp(selectedOp.id, activeSample); + setRunResult({ ...res, opId: selectedOp.id }); + setPhase("done"); + } catch (err) { + setPhase("error"); + setErrorMsg(err instanceof Error ? err.message : String(err)); + } + } + + function reset() { + setPhase("idle"); + setRunResult(null); + setErrorMsg(null); + } + + function buildPipelineWithOp() { + onClose(); + setActiveView("pipelines"); + } + + function onDragOver(e: React.DragEvent) { + e.preventDefault(); + setDragOver(true); + } + function onDragLeave() { + setDragOver(false); + } + function onDrop(e: React.DragEvent) { + e.preventDefault(); + setDragOver(false); + const file = e.dataTransfer.files[0]; + if (file) setSample(file.name); + else pickSample(); + } + + return ( + +
+ {phase === "running" && selectedOp && ( + <> + + POST {selectedOp.endpoint} + + )} + {phase === "done" && ( + + 200 OK + + )} + {phase === "error" && ( + + {errorMsg ?? "Failed"} + + )} +
+ + {phase === "done" ? ( + <> + + + + ) : ( + + )} + + } + > +
+ {/* Left column: file + op picker */} +
+
+
+ + + + +
+
+ {sample ? ( + <> + {sample} + Drop again or pick another sample to replace. + + ) : ( + <> + Drop a PDF here + or use a sample document. + + )} +
+ +
+ +
+
Featured ops
+
+ {opsIsLoading && + Array.from({ length: 4 }).map((_, i) => ( +
+ + + +
+ ))} + {opsIsEmpty && ( + + )} + {ops?.map((op) => ( + + ))} +
+
+
+ + {/* Right column: state-driven result panel */} +
+ {phase === "idle" && selectedOp && ( +
+
Ready
+

+ Run {selectedOp.label} on{" "} + {sample ?? "a sample"} +

+

+ {selectedOp.blurb}. Press Run operation to invoke{" "} + POST {selectedOp.endpoint}. +

+
+ )} + {phase === "running" && selectedOp && ( +
+
+
+
+ Running {selectedOp.label}… +
+ POST {selectedOp.endpoint} +
+
+ )} + {phase === "done" && selectedOp && runResult && ( +
+
+ + Completed + + POST {selectedOp.endpoint} + + {runResult.durationMs} ms + +
+
+                {JSON.stringify(runResult.result, null, 2)}
+              
+
+ )} + {phase === "error" && ( +
+
+ Failed +
+

The operation didn’t complete

+

{errorMsg ?? "Unknown error"}

+
+ )} +
+
+
+ ); +} diff --git a/frontend/portal/src/components/UsageAreaChart.css b/frontend/portal/src/components/UsageAreaChart.css new file mode 100644 index 000000000..c6d326c50 --- /dev/null +++ b/frontend/portal/src/components/UsageAreaChart.css @@ -0,0 +1,115 @@ +.portal-chart { + position: relative; + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 1.125rem 1.25rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); +} + +.portal-chart__head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 0.75rem; +} + +.portal-chart__label { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-section-label); +} + +.portal-chart__value { + margin-top: 0.125rem; + font-size: 1.5rem; + font-weight: 700; + color: var(--color-text-1); + font-variant-numeric: tabular-nums; +} + +.portal-chart__delta { + font-size: 0.75rem; + font-weight: 500; + padding: 0.1875rem 0.5rem; + border-radius: var(--radius-pill); +} + +.portal-chart__delta.is-up { + color: var(--color-green-dark); + background: var(--color-green-light); +} + +.portal-chart__delta.is-down { + color: var(--color-red); + background: var(--color-red-light); +} + +.portal-chart__svg { + width: 100%; + height: 15rem; + display: block; + overflow: visible; +} + +.portal-chart__gridline { + stroke: var(--color-border-light); + stroke-width: 1; + stroke-dasharray: 3 3; +} + +.portal-chart__axis-label { + font-family: var(--font-sans); + font-size: 10.5px; + fill: var(--color-text-5); +} + +.portal-chart__line { + fill: none; + stroke: var(--color-blue); + stroke-width: 1.75; + stroke-linecap: round; + stroke-linejoin: round; +} + +.portal-chart__scrub { + stroke: var(--color-text-5); + stroke-width: 1; + stroke-dasharray: 2 3; + opacity: 0.6; +} + +.portal-chart__dot { + fill: var(--color-blue); + stroke: var(--color-surface); + stroke-width: 2; +} + +.portal-chart__tooltip { + position: absolute; + transform: translateX(-50%); + top: 4rem; + padding: 0.4375rem 0.625rem; + background: var(--color-tooltip-bg); + color: var(--color-tooltip-text); + border-radius: var(--radius-sm); + font-size: 0.75rem; + white-space: nowrap; + pointer-events: none; + box-shadow: var(--shadow-md); + z-index: 2; +} + +.portal-chart__tooltip-date { + font-size: 0.6875rem; + opacity: 0.7; +} + +.portal-chart__tooltip-value { + font-weight: 600; + font-variant-numeric: tabular-nums; +} diff --git a/frontend/portal/src/components/UsageAreaChart.stories.tsx b/frontend/portal/src/components/UsageAreaChart.stories.tsx new file mode 100644 index 000000000..5c1f3e3dd --- /dev/null +++ b/frontend/portal/src/components/UsageAreaChart.stories.tsx @@ -0,0 +1,53 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { UsageAreaChart } from "@portal/components/UsageAreaChart"; +import { buildUsageSeries } from "@portal/mocks/home"; + +const meta: Meta = { + title: "Portal/Home/UsageAreaChart", + component: UsageAreaChart, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +const series = buildUsageSeries(); +const total = series.reduce((sum, p) => sum + p.value, 0); + +export const Default: Story = { + args: { data: series, totalValue: total.toLocaleString(), deltaPct: 0.12 }, +}; + +export const NegativeDelta: Story = { + args: { data: series, totalValue: total.toLocaleString(), deltaPct: -0.08 }, +}; + +export const NoDelta: Story = { + args: { data: series }, +}; + +export const Flat: Story = { + args: { + data: series.map((p) => ({ ...p, value: 1200 })), + totalValue: "36,000", + }, +}; + +export const HighVolatility: Story = { + args: { + data: series.map((p, i) => ({ + ...p, + value: Math.round(p.value * (1 + Math.sin(i) * 0.6)), + })), + }, +}; + +export const Empty: Story = { + args: { data: [], totalValue: "—" }, +}; diff --git a/frontend/portal/src/components/UsageAreaChart.tsx b/frontend/portal/src/components/UsageAreaChart.tsx new file mode 100644 index 000000000..778932e9b --- /dev/null +++ b/frontend/portal/src/components/UsageAreaChart.tsx @@ -0,0 +1,278 @@ +import { useMemo, useRef, useState, type KeyboardEvent } from "react"; +import type { UsagePoint } from "@portal/api/home"; +import "@portal/components/UsageAreaChart.css"; + +interface UsageAreaChartProps { + data: UsagePoint[]; + /** Headline metric shown above the chart (e.g. total 30d, current value). */ + totalLabel?: string; + totalValue?: string; + deltaPct?: number; +} + +const PADDING = { top: 16, right: 20, bottom: 28, left: 32 } as const; +const VIEW = { width: 800, height: 240 } as const; + +function formatTick(date: string): string { + return new Date(date).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); +} + +function formatNumber(value: number): string { + if (value >= 1000) { + return `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`; + } + return value.toLocaleString(); +} + +export function UsageAreaChart({ + data, + totalLabel = "Docs processed · last 30 days", + totalValue, + deltaPct, +}: UsageAreaChartProps) { + const [hoverIndex, setHoverIndex] = useState(null); + const svgRef = useRef(null); + + const { points, areaPath, linePath, yMax, yTicks, xTickIndices } = + useMemo(() => { + if (data.length === 0) { + return { + points: [] as Array<{ x: number; y: number; raw: UsagePoint }>, + areaPath: "", + linePath: "", + yMax: 0, + yTicks: [] as number[], + xTickIndices: [] as number[], + }; + } + const max = Math.max(...data.map((d) => d.value)); + // Round yMax up to a "nice" number for ticks. Floor at 500 so an + // all-zero series doesn't divide by zero (which would NaN every point). + const niceMax = Math.max(Math.ceil(max / 500) * 500, 500); + const yTicksCalc = [0, niceMax * 0.5, niceMax]; + + const innerW = VIEW.width - PADDING.left - PADDING.right; + const innerH = VIEW.height - PADDING.top - PADDING.bottom; + const xStep = innerW / Math.max(data.length - 1, 1); + + const pts = data.map((raw, i) => ({ + x: PADDING.left + i * xStep, + y: PADDING.top + innerH - (raw.value / niceMax) * innerH, + raw, + })); + + const linePathStr = pts + .map( + (p, i) => + `${i === 0 ? "M" : "L"} ${p.x.toFixed(2)} ${p.y.toFixed(2)}`, + ) + .join(" "); + const baseY = PADDING.top + innerH; + const areaPathStr = `${linePathStr} L ${pts[pts.length - 1].x.toFixed(2)} ${baseY} L ${pts[0].x.toFixed(2)} ${baseY} Z`; + + // X ticks: 5 evenly spread. + const xIdx: number[] = []; + const tickCount = 5; + for (let t = 0; t < tickCount; t++) { + xIdx.push(Math.round((t * (data.length - 1)) / (tickCount - 1))); + } + + return { + points: pts, + areaPath: areaPathStr, + linePath: linePathStr, + yMax: niceMax, + yTicks: yTicksCalc, + xTickIndices: xIdx, + }; + }, [data]); + + function onMouseMove(e: React.MouseEvent) { + if (!svgRef.current || points.length === 0) return; + const rect = svgRef.current.getBoundingClientRect(); + const xRatio = (e.clientX - rect.left) / rect.width; + const svgX = xRatio * VIEW.width; + // Find nearest point + let nearestIdx = 0; + let nearestDist = Infinity; + for (let i = 0; i < points.length; i++) { + const d = Math.abs(points[i].x - svgX); + if (d < nearestDist) { + nearestDist = d; + nearestIdx = i; + } + } + setHoverIndex(nearestIdx); + } + + const hovered = hoverIndex !== null ? points[hoverIndex] : null; + + function onKeyDown(e: KeyboardEvent) { + if (points.length === 0) return; + if (e.key === "ArrowRight") { + e.preventDefault(); + setHoverIndex((idx) => + idx === null ? 0 : Math.min(points.length - 1, idx + 1), + ); + } else if (e.key === "ArrowLeft") { + e.preventDefault(); + setHoverIndex((idx) => + idx === null ? points.length - 1 : Math.max(0, idx - 1), + ); + } else if (e.key === "Home") { + e.preventDefault(); + setHoverIndex(0); + } else if (e.key === "End") { + e.preventDefault(); + setHoverIndex(points.length - 1); + } else if (e.key === "Escape") { + setHoverIndex(null); + } + } + + const displayTotal = + totalValue ?? data.reduce((sum, p) => sum + p.value, 0).toLocaleString(); + + return ( +
+
+
+
{totalLabel}
+
{displayTotal}
+
+ {deltaPct !== undefined && ( +
= 0 ? "is-up" : "is-down") + } + > + {deltaPct >= 0 ? "↑" : "↓"} + {Math.abs(Math.round(deltaPct * 100))}% vs prior 30d +
+ )} +
+ + 0 ? 0 : -1} + onMouseMove={onMouseMove} + onMouseLeave={() => setHoverIndex(null)} + onKeyDown={onKeyDown} + onBlur={() => setHoverIndex(null)} + > + + + + + + + + {/* Y gridlines + labels */} + {yTicks.map((tick) => { + const innerH = VIEW.height - PADDING.top - PADDING.bottom; + const y = PADDING.top + innerH - (tick / yMax) * innerH; + return ( + + + + {formatNumber(tick)} + + + ); + })} + + {/* Area + line */} + + + + {/* X ticks */} + {xTickIndices.map((idx) => { + const p = points[idx]; + if (!p) return null; + return ( + + {formatTick(p.raw.date)} + + ); + })} + + {/* Hover scrub + dot + tooltip */} + {hovered && ( + <> + + + + )} + + + {hovered && ( +
+
+ {new Date(hovered.raw.date).toLocaleDateString(undefined, { + weekday: "short", + month: "short", + day: "numeric", + })} +
+
+ {hovered.raw.value.toLocaleString()} docs +
+
+ )} +
+ {hovered + ? `${new Date(hovered.raw.date).toLocaleDateString(undefined, { + weekday: "short", + month: "short", + day: "numeric", + })}: ${hovered.raw.value.toLocaleString()} docs` + : ""} +
+
+ ); +} diff --git a/frontend/portal/src/components/WelcomeCarousel.css b/frontend/portal/src/components/WelcomeCarousel.css new file mode 100644 index 000000000..49a7d5fcc --- /dev/null +++ b/frontend/portal/src/components/WelcomeCarousel.css @@ -0,0 +1,234 @@ +.portal-carousel { + position: relative; + overflow: hidden; + padding: 2rem; + border-radius: var(--radius-xl); + background: var(--grad-banner); + border: 1px solid var(--color-border); + isolation: isolate; +} + +.portal-carousel__inner { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 20rem); + gap: 2rem; + align-items: center; + animation: fadeInUp var(--motion-enter) both; +} + +@media (max-width: 50rem) { + .portal-carousel__inner { + grid-template-columns: 1fr; + } +} + +.portal-carousel__text { + min-width: 0; +} + +.portal-carousel__eyebrow { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-blue); + margin-bottom: 0.5rem; +} + +.portal-carousel__title { + margin: 0 0 0.75rem; + font-size: 1.75rem; + font-weight: 700; + line-height: 1.15; + color: var(--color-text-1); +} + +.portal-carousel__sub { + margin: 0 0 1.25rem; + font-size: 0.9375rem; + line-height: 1.55; + color: var(--color-text-3); + max-width: 36rem; +} + +.portal-carousel__cta { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +/* Ornament container */ +.portal-carousel__ornament { + display: flex; + align-items: center; + justify-content: center; +} + +/* Doc card ornament (slide 0) */ +.portal-carousel__doc { + width: 100%; + padding: 1rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-md); +} +.portal-carousel__doc-title { + margin-top: 0.5rem; + font-size: 0.9375rem; + font-weight: 600; + color: var(--color-text-1); +} +.portal-carousel__doc-sub { + margin-top: 0.125rem; + font-size: 0.75rem; + color: var(--color-text-4); +} +.portal-carousel__doc-meta { + margin-top: 0.75rem; + font-size: 0.75rem; + color: var(--color-text-4); + display: flex; + gap: 0.375rem; + flex-wrap: wrap; +} + +/* Code ornament (slide 1) */ +.portal-carousel__code { + width: 100%; + margin: 0; + padding: 0.875rem 1rem; + background: var(--code-bg); + color: var(--code-text); + border: 1px solid var(--code-border); + border-radius: var(--radius-md); + font-family: var(--font-mono); + font-size: 0.75rem; + line-height: 1.65; + white-space: pre-wrap; + word-break: break-word; + box-shadow: var(--shadow-lg); +} +.portal-carousel__code-method { + color: var(--code-fn); + font-weight: 600; +} +.portal-carousel__code-path { + color: var(--code-keyword); +} +.portal-carousel__code-key { + color: var(--code-property); +} +.portal-carousel__code-string { + color: var(--code-string); +} + +/* Eval ornament (slide 2) */ +.portal-carousel__eval { + width: 100%; + padding: 1rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-md); +} +.portal-carousel__eval-head { + display: flex; + align-items: baseline; + justify-content: space-between; +} +.portal-carousel__eval-title { + font-size: 0.875rem; + font-weight: 600; + color: var(--color-text-1); +} +.portal-carousel__eval-score { + font-size: 1.125rem; + font-weight: 700; + color: var(--color-green); +} +.portal-carousel__eval-sub { + font-size: 0.75rem; + color: var(--color-text-4); + margin-top: 0.125rem; +} +.portal-carousel__eval-bar { + margin-top: 0.75rem; + height: 0.375rem; + background: var(--color-bg-muted); + border-radius: var(--radius-pill); + overflow: hidden; +} +.portal-carousel__eval-fill { + height: 100%; + background: var(--grad-green-btn); +} +.portal-carousel__eval-meta { + margin-top: 0.625rem; + display: flex; + justify-content: space-between; + font-size: 0.6875rem; + color: var(--color-text-4); +} + +/* Decoration blobs */ +.portal-carousel__decor { + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; +} +.portal-carousel__blob { + position: absolute; + border-radius: 50%; + filter: blur(2.5rem); +} +.portal-carousel__blob--1 { + top: -3rem; + right: -2rem; + width: 16rem; + height: 16rem; + background: radial-gradient(circle, var(--color-purple), transparent 70%); + opacity: 0.4; +} +.portal-carousel__blob--2 { + bottom: -4rem; + right: 8rem; + width: 12rem; + height: 12rem; + background: radial-gradient(circle, var(--color-blue), transparent 70%); + opacity: 0.3; +} + +/* Pagination dots */ +.portal-carousel__dots { + position: absolute; + bottom: 0.875rem; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 0.375rem; + z-index: 2; +} +.portal-carousel__dot { + width: 0.4375rem; + height: 0.4375rem; + border-radius: 50%; + background: var(--color-text-5); + opacity: 0.4; + transition: + opacity var(--motion-fast), + background var(--motion-fast), + width var(--motion-fast); +} +.portal-carousel__dot:hover { + opacity: 0.7; +} +.portal-carousel__dot.is-active { + width: 1rem; + border-radius: var(--radius-pill); + background: var(--color-blue); + opacity: 1; +} diff --git a/frontend/portal/src/components/WelcomeCarousel.stories.tsx b/frontend/portal/src/components/WelcomeCarousel.stories.tsx new file mode 100644 index 000000000..1db5790fc --- /dev/null +++ b/frontend/portal/src/components/WelcomeCarousel.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { WelcomeCarousel } from "@portal/components/WelcomeCarousel"; + +const meta: Meta = { + title: "Portal/Home/WelcomeCarousel", + component: WelcomeCarousel, + args: { onTryOp: () => console.log("try op") }, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const AutoRotating: Story = {}; diff --git a/frontend/portal/src/components/WelcomeCarousel.tsx b/frontend/portal/src/components/WelcomeCarousel.tsx new file mode 100644 index 000000000..e62320085 --- /dev/null +++ b/frontend/portal/src/components/WelcomeCarousel.tsx @@ -0,0 +1,215 @@ +import { useEffect, useState, type ReactNode } from "react"; +import { Button, StatusBadge } from "@shared/components"; +import { useView, type ViewId } from "@portal/contexts/ViewContext"; +import "@portal/components/WelcomeCarousel.css"; + +type SlideAction = + | { label: string; target: ViewId } + | { label: string; action: "try-op" }; + +interface Slide { + id: string; + eyebrow: string; + title: string; + sub: string; + durationMs: number; + primary: SlideAction; + secondary: SlideAction; + ornament: ReactNode; +} + +function EditorOrnament() { + return ( +
+ + Critical + +
+ Vulnerability Assessment Report +
+
CVE-2026-1847 · 12 pages
+
+ signed + · + OCR-clean + · + schema match 0.97 +
+
+ ); +} + +function PlatformOrnament() { + return ( +
+      POST{" "}
+      /v1/secure
+      {"\n"}
+      Authorization:{" "}
+      Bearer sk_live_a3f8…
+      {"\n"}
+      file:{" "}
+      
+        federal_CUI_contract.pdf
+      
+      {"\n"}
+      pipeline:{" "}
+      cui-redact-v2
+    
+ ); +} + +function AgentOrnament() { + return ( +
+
+ KYC Processor + 94% +
+
+ Eval pass rate · 28 test cases +
+
+
+
+
+ 26 passed + 2 review + MCP · Claude +
+
+ ); +} + +const SLIDES: Slide[] = [ + { + id: "editor", + eyebrow: "PDF Editor", + title: "The #1 PDF Editor on GitHub", + sub: "Annotate, sign, redact, and review locally or in the cloud. Brought to the platform as the credibility anchor of the Stirling control plane.", + durationMs: 12000, + primary: { label: "Install PDF Editor", target: "editor" }, + secondary: { label: "Connect an instance", target: "editor" }, + ornament: , + }, + { + id: "platform", + eyebrow: "Platform", + title: "PDF Infrastructure for Developers", + sub: "Ingest from agents, APIs and connectors. Run composable pipelines with evals and golden sets. Land in a vault with zero-standing-access controls.", + durationMs: 8000, + primary: { label: "Try a PDF operation", action: "try-op" }, + secondary: { label: "Get an API key", target: "infrastructure" }, + ornament: , + }, + { + id: "agents", + eyebrow: "AI Agents", + title: "PDF Processor for AI Agents", + sub: "Wire your agent via MCP, REST or tool definitions. Deterministic operations and guardrails — test with scenarios and evals before you ship.", + durationMs: 8000, + primary: { label: "Try PDF Processor", target: "sources" }, + secondary: { label: "View MCP docs", target: "docs" }, + ornament: , + }, +]; + +interface WelcomeCarouselProps { + /** Called when a slide CTA wants to invoke the single-op runner. */ + onTryOp: () => void; +} + +export function WelcomeCarousel({ onTryOp }: WelcomeCarouselProps) { + const [index, setIndex] = useState(0); + const [paused, setPaused] = useState(false); + const { setActiveView } = useView(); + + function runAction(act: SlideAction) { + if ("action" in act && act.action === "try-op") { + onTryOp(); + } else if ("target" in act) { + setActiveView(act.target); + } + } + + useEffect(() => { + if (paused) return; + const duration = SLIDES[index].durationMs; + const t = window.setTimeout(() => { + setIndex((i) => (i + 1) % SLIDES.length); + }, duration); + return () => window.clearTimeout(t); + }, [index, paused]); + + const slide = SLIDES[index]; + + return ( +
setPaused(true)} + onMouseLeave={() => setPaused(false)} + onFocus={() => setPaused(true)} + onBlur={(e) => { + // onBlur bubbles from children; only unpause when focus actually + // leaves the carousel, not when tabbing between the dot buttons. + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) { + setPaused(false); + } + }} + aria-label="Stirling product highlights" + aria-roledescription="carousel" + > +
+
+
{slide.eyebrow}
+

{slide.title}

+

{slide.sub}

+
+ + +
+
+
{slide.ornament}
+
+ +
+
+
+
+ +
+ {SLIDES.map((s, i) => ( +
+
+ ); +} diff --git a/frontend/portal/src/components/icons.tsx b/frontend/portal/src/components/icons.tsx new file mode 100644 index 000000000..0018850c5 --- /dev/null +++ b/frontend/portal/src/components/icons.tsx @@ -0,0 +1,202 @@ +import type { ReactNode } from "react"; + +interface IconProps { + size?: number; + className?: string; +} + +function Svg({ + size = 18, + className, + children, +}: IconProps & { children: ReactNode }) { + return ( + + {children} + + ); +} + +export function HomeIcon(props: IconProps) { + return ( + + + + + ); +} + +export function EditorIcon(props: IconProps) { + return ( + + + + + + + ); +} + +export function SourcesIcon(props: IconProps) { + return ( + + + + + + + ); +} + +export function PipelinesIcon(props: IconProps) { + return ( + + + + + + + + ); +} + +export function DocumentsIcon(props: IconProps) { + return ( + + + + + + + + ); +} + +export function InfrastructureIcon(props: IconProps) { + return ( + + + + + + + ); +} + +export function UsageIcon(props: IconProps) { + return ( + + + + + + + ); +} + +export function DocsIcon(props: IconProps) { + return ( + + + + + ); +} + +export function SettingsIcon(props: IconProps) { + return ( + + + + + ); +} + +export function SearchIcon(props: IconProps) { + return ( + + + + + ); +} + +export function SunIcon(props: IconProps) { + return ( + + + + + + + + + + + + ); +} + +export function MoonIcon(props: IconProps) { + return ( + + + + ); +} + +export function BellIcon(props: IconProps) { + return ( + + + + + ); +} + +export function ChevronDownIcon(props: IconProps) { + return ( + + + + ); +} + +export function SparklesIcon(props: IconProps) { + return ( + + + + + + ); +} + +export function CloseIcon(props: IconProps) { + return ( + + + + + ); +} + +export function SendIcon(props: IconProps) { + return ( + + + + + ); +} diff --git a/frontend/portal/src/contexts/ThemeContext.tsx b/frontend/portal/src/contexts/ThemeContext.tsx new file mode 100644 index 000000000..ad979f5e9 --- /dev/null +++ b/frontend/portal/src/contexts/ThemeContext.tsx @@ -0,0 +1,57 @@ +import { + createContext, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; + +export type Theme = "light" | "dark"; + +interface ThemeContextValue { + theme: Theme; + setTheme: (theme: Theme) => void; + toggle: () => void; +} + +const ThemeContext = createContext(null); + +const STORAGE_KEY = "stirling.portal.theme"; + +function readInitialTheme(): Theme { + if (typeof window === "undefined") return "light"; + const stored = window.localStorage.getItem(STORAGE_KEY); + if (stored === "light" || stored === "dark") return stored; + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; +} + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [theme, setTheme] = useState(readInitialTheme); + + useEffect(() => { + document.documentElement.setAttribute("data-theme", theme); + window.localStorage.setItem(STORAGE_KEY, theme); + }, [theme]); + + const value = useMemo( + () => ({ + theme, + setTheme, + toggle: () => setTheme((t) => (t === "light" ? "dark" : "light")), + }), + [theme], + ); + + return ( + {children} + ); +} + +export function useTheme(): ThemeContextValue { + const v = useContext(ThemeContext); + if (!v) throw new Error("useTheme must be used inside "); + return v; +} diff --git a/frontend/portal/src/contexts/TierContext.tsx b/frontend/portal/src/contexts/TierContext.tsx new file mode 100644 index 000000000..4b69419a6 --- /dev/null +++ b/frontend/portal/src/contexts/TierContext.tsx @@ -0,0 +1,45 @@ +import { + createContext, + useContext, + useMemo, + useState, + type ReactNode, +} from "react"; + +export type Tier = "free" | "pro" | "enterprise"; + +export interface TierInfo { + label: string; + dotColor: string; +} + +export const TIER_INFO: Record = { + free: { label: "Free Plan", dotColor: "var(--color-text-4)" }, + pro: { label: "Pay-as-you-go", dotColor: "var(--color-blue)" }, + enterprise: { label: "Enterprise Plan", dotColor: "var(--color-purple)" }, +}; + +interface TierContextValue { + tier: Tier; + setTier: (tier: Tier) => void; +} + +const TierContext = createContext(null); + +export function TierProvider({ + children, + initialTier = "pro", +}: { + children: ReactNode; + initialTier?: Tier; +}) { + const [tier, setTier] = useState(initialTier); + const value = useMemo(() => ({ tier, setTier }), [tier]); + return {children}; +} + +export function useTier(): TierContextValue { + const v = useContext(TierContext); + if (!v) throw new Error("useTier must be used inside "); + return v; +} diff --git a/frontend/portal/src/contexts/UIContext.tsx b/frontend/portal/src/contexts/UIContext.tsx new file mode 100644 index 000000000..a2b5249f7 --- /dev/null +++ b/frontend/portal/src/contexts/UIContext.tsx @@ -0,0 +1,49 @@ +import { + createContext, + useContext, + useMemo, + useState, + type ReactNode, +} from "react"; + +interface UIContextValue { + searchOpen: boolean; + openSearch: () => void; + closeSearch: () => void; + toggleSearch: () => void; + + assistantOpen: boolean; + openAssistant: () => void; + closeAssistant: () => void; + toggleAssistant: () => void; +} + +const UIContext = createContext(null); + +export function UIProvider({ children }: { children: ReactNode }) { + const [searchOpen, setSearchOpen] = useState(false); + const [assistantOpen, setAssistantOpen] = useState(false); + + const value = useMemo( + () => ({ + searchOpen, + openSearch: () => setSearchOpen(true), + closeSearch: () => setSearchOpen(false), + toggleSearch: () => setSearchOpen((o) => !o), + + assistantOpen, + openAssistant: () => setAssistantOpen(true), + closeAssistant: () => setAssistantOpen(false), + toggleAssistant: () => setAssistantOpen((o) => !o), + }), + [searchOpen, assistantOpen], + ); + + return {children}; +} + +export function useUI(): UIContextValue { + const v = useContext(UIContext); + if (!v) throw new Error("useUI must be used inside "); + return v; +} diff --git a/frontend/portal/src/contexts/ViewContext.tsx b/frontend/portal/src/contexts/ViewContext.tsx new file mode 100644 index 000000000..28aa118e5 --- /dev/null +++ b/frontend/portal/src/contexts/ViewContext.tsx @@ -0,0 +1,73 @@ +import { useCallback, useMemo } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; + +export type ViewId = + | "home" + | "editor" + | "sources" + | "pipelines" + | "documents" + | "infrastructure" + | "usage" + | "docs" + | "settings"; + +export const VIEW_LABELS: Record = { + home: "Home", + editor: "Editor", + sources: "Sources", + pipelines: "Pipelines", + documents: "Documents", + infrastructure: "Infrastructure", + usage: "Usage & Billing", + docs: "Developer Docs", + settings: "Settings", +}; + +export const VIEW_PATHS: Record = { + home: "/", + editor: "/editor", + sources: "/sources", + pipelines: "/pipelines", + documents: "/documents", + infrastructure: "/infrastructure", + usage: "/usage", + docs: "/docs", + settings: "/settings", +}; + +const PATH_TO_VIEW: Record = Object.fromEntries( + (Object.entries(VIEW_PATHS) as Array<[ViewId, string]>).map( + ([view, path]) => [path, view], + ), +); + +function deriveActiveView(pathname: string): ViewId { + // Exact match first; otherwise treat the first segment as the view. + if (PATH_TO_VIEW[pathname]) return PATH_TO_VIEW[pathname]; + const firstSegment = "/" + pathname.split("/").filter(Boolean)[0]; + return PATH_TO_VIEW[firstSegment] ?? "home"; +} + +interface ViewContextValue { + activeView: ViewId; + setActiveView: (view: ViewId) => void; +} + +/** + * Backwards-compatible facade over react-router. Components keep using + * useView()/setActiveView the way they did before; URLs are now real. + * + * No wrapper exists any more — the router is the provider. + * App.tsx wraps its children in . + */ +export function useView(): ViewContextValue { + const { pathname } = useLocation(); + const navigate = useNavigate(); + const activeView = useMemo(() => deriveActiveView(pathname), [pathname]); + const setActiveView = useCallback( + (view: ViewId) => navigate(VIEW_PATHS[view]), + [navigate], + ); + return { activeView, setActiveView }; +} diff --git a/frontend/portal/src/hooks/useAsync.ts b/frontend/portal/src/hooks/useAsync.ts new file mode 100644 index 000000000..6837a0860 --- /dev/null +++ b/frontend/portal/src/hooks/useAsync.ts @@ -0,0 +1,97 @@ +import { useEffect, useMemo, useState } from "react"; + +export interface AsyncState { + data: T | null; + loading: boolean; + error: Error | null; +} + +/** + * Derived render flags for a panel backed by {@link useAsync}. + * + * Every async-driven section in the portal renders the same three-state shape: + * + * - `isLoading` — first load, no data yet → render skeletons + * - `isEmpty` — fetch failed OR data is genuinely empty → render + * - ready (neither flag set) — data is present → render the real UI + * + * Error and empty collapse into one branch on purpose: when there's no + * backend yet, fetch failures should surface as an empty page rather than + * an alarming error banner. The section header always renders regardless of + * which branch is active. + * + * The `ready` state is intentionally NOT exposed as a flag — callers should + * gate the ready branch on the actual data (`{events && events.length > 0 + * && …}`) so TypeScript can narrow `events` from `T[] | null` to `T[]`. + */ +export interface SectionFlags { + isLoading: boolean; + isEmpty: boolean; +} + +export function deriveSectionFlags(state: AsyncState): SectionFlags { + const { data, loading, error } = state; + const dataIsEmpty = + data === null || (Array.isArray(data) && data.length === 0); + return { + isLoading: loading && data === null, + isEmpty: !loading && (error !== null || dataIsEmpty), + }; +} + +/** Memoised variant for use inside components. */ +export function useSectionFlags(state: AsyncState): SectionFlags { + return useMemo(() => deriveSectionFlags(state), [state]); +} + +/** + * Lightweight loading-state hook for async functions. Cancels the in-flight + * effect on unmount and on dependency change, so race conditions don't + * resolve into stale state. + * + * Intentionally minimal — when we want caching, retries, and revalidation + * we'll move this to TanStack Query or similar. For mocked data with sub- + * second latency, this is enough. + * + * @example + * const { data, loading } = useAsync(() => fetchDeployedPipelines(), []); + * if (loading) return ; + * if (!data) return null; + */ +export function useAsync( + fn: (signal: AbortSignal) => Promise, + deps: ReadonlyArray, +): AsyncState { + const [state, setState] = useState>({ + data: null, + loading: true, + error: null, + }); + + useEffect(() => { + const controller = new AbortController(); + let cancelled = false; + + setState((prev) => ({ ...prev, loading: true, error: null })); + + fn(controller.signal) + .then((data) => { + if (cancelled) return; + setState({ data, loading: false, error: null }); + }) + .catch((error: unknown) => { + if (cancelled || controller.signal.aborted) return; + const err = error instanceof Error ? error : new Error(String(error)); + setState({ data: null, loading: false, error: err }); + }); + + return () => { + cancelled = true; + controller.abort(); + }; + // Callers own the dependency array — `fn` is intentionally not listed + // here so the effect only re-runs when the caller asks it to. + }, deps); + + return state; +} diff --git a/frontend/portal/src/mocks/assistant.ts b/frontend/portal/src/mocks/assistant.ts new file mode 100644 index 000000000..c5d9ed9ca --- /dev/null +++ b/frontend/portal/src/mocks/assistant.ts @@ -0,0 +1,61 @@ +/** Mock assistant: routing rules + canned replies + suggested prompts. */ + +export interface AssistantRoute { + patterns: readonly RegExp[]; + reply: string; +} + +export const ASSISTANT_ROUTES: AssistantRoute[] = [ + { + patterns: [/extract/i, /schema/i], + reply: + "For extraction, point the doc at /v1/ and the schema-aware op infers fields from your typed catalogue. Confidence-check gates anything under 0.85 by default — adjust the threshold in the composer's per-op config.", + }, + { + patterns: [/mcp/i, /\bagent\b/i, /connect.*agent/i], + reply: + "MCP wiring lives under Sources → Add → Agent. You'll get an MCP URL of the shape mcp://stirling.com/agents/{id} plus scoped credentials. Scenarios drive the eval set the agent must pass before it can take real traffic.", + }, + { + patterns: [/redact/i, /pii/i], + reply: + "The redact op runs PII enforcement across SSN / DOB / accounts / contacts / names / addresses by default. Toggle categories in the composer; auto-detected PII supports blackout, replace, and mask styles.", + }, + { + patterns: [/deploy/i, /docker/i, /self.host/i], + reply: + "Self-host via the docker variant from /editor → Self-Hosted. Air-gapped tarballs ship on Enterprise. Helm charts cover us-east-1, eu-west-1, ap-southeast-1; multi-region requires the Enterprise plan.", + }, + { + patterns: [/rate.*limit/i, /quota/i, /throttl/i], + reply: + "Free is 4.2 req/min. Pro scales to 342 req/min with burst tolerance. Enterprise lifts to 2.4k req/min with per-region pools. The 429 response carries Retry-After in seconds.", + }, + { + patterns: [/webhook/i, /callback/i], + reply: + "Outbound webhooks deliver via 3× exponential backoff. We sign every payload with HMAC-SHA256 — verify via the X-Stirling-Signature header. Inbound webhooks support Bearer, HMAC, or mTLS auth.", + }, +]; + +export const ASSISTANT_DEFAULT_REPLY = + "I can help with extraction, MCP / agents, redaction, deployment, rate limits, and webhooks. Try one of the suggestions, or ask something specific."; + +export const ASSISTANT_SUGGESTIONS: readonly string[] = [ + "How do I wire an MCP agent?", + "Extract fields from a COI", + "Redact PII before storage", + "Self-host with Docker", + "What are the rate limits?", + "Build a pipeline from a sample", +]; + +/** Resolve an input to a canned reply by walking the route table. */ +export function routeAssistantReply(input: string): string { + for (const route of ASSISTANT_ROUTES) { + for (const pattern of route.patterns) { + if (pattern.test(input)) return route.reply; + } + } + return ASSISTANT_DEFAULT_REPLY; +} diff --git a/frontend/portal/src/mocks/browser.ts b/frontend/portal/src/mocks/browser.ts new file mode 100644 index 000000000..c69f3f85b --- /dev/null +++ b/frontend/portal/src/mocks/browser.ts @@ -0,0 +1,23 @@ +import { setupWorker } from "msw/browser"; +import { handlers } from "@portal/mocks/handlers"; + +export const worker = setupWorker(...handlers); + +let workerStarted = false; + +/** + * Start the MSW worker. Idempotent — calling repeatedly is safe. + * + * The toggle flips MSW by writing the preference to localStorage and + * reloading the page, so there's no need for a `stopMockWorker` counterpart: + * the next boot just decides whether to call this or not. + */ +export async function startMockWorker(): Promise { + if (workerStarted) return; + await worker.start({ + onUnhandledRequest: "bypass", + serviceWorker: { url: "/mockServiceWorker.js" }, + quiet: true, + }); + workerStarted = true; +} diff --git a/frontend/portal/src/mocks/handlers/assistant.ts b/frontend/portal/src/mocks/handlers/assistant.ts new file mode 100644 index 000000000..afbfea4c5 --- /dev/null +++ b/frontend/portal/src/mocks/handlers/assistant.ts @@ -0,0 +1,21 @@ +import { http, HttpResponse, delay } from "msw"; +import { + ASSISTANT_SUGGESTIONS, + routeAssistantReply, +} from "@portal/mocks/assistant"; + +interface AssistantMessageBody { + input: string; +} + +export const assistantHandlers = [ + http.get("/v1/assistant/suggestions", async () => { + return HttpResponse.json(ASSISTANT_SUGGESTIONS); + }), + + http.post("/v1/assistant/messages", async ({ request }) => { + const body = (await request.json()) as AssistantMessageBody; + await delay(600 + Math.random() * 300); + return HttpResponse.json({ reply: routeAssistantReply(body.input) }); + }), +]; diff --git a/frontend/portal/src/mocks/handlers/endpoints.ts b/frontend/portal/src/mocks/handlers/endpoints.ts new file mode 100644 index 000000000..0596b3bea --- /dev/null +++ b/frontend/portal/src/mocks/handlers/endpoints.ts @@ -0,0 +1,15 @@ +import { http, HttpResponse, delay } from "msw"; +import { VERTICALS } from "@shared/data/endpoints"; + +export const endpointsHandlers = [ + http.get("/v1/endpoints", async ({ request }) => { + await delay(120); + const url = new URL(request.url); + const verticalKey = url.searchParams.get("vertical"); + if (verticalKey) { + const v = VERTICALS.find((x) => x.key === verticalKey); + return HttpResponse.json(v ? v.endpoints : []); + } + return HttpResponse.json(VERTICALS); + }), +]; diff --git a/frontend/portal/src/mocks/handlers/home.ts b/frontend/portal/src/mocks/handlers/home.ts new file mode 100644 index 000000000..d5ba2825a --- /dev/null +++ b/frontend/portal/src/mocks/handlers/home.ts @@ -0,0 +1,49 @@ +import { http, HttpResponse, delay } from "msw"; +import type { Tier } from "@portal/contexts/TierContext"; +import { + buildUsageSeries, + buildUsageSeriesResponse, + enterpriseKpisFor, + FREE_KPIS, + FREE_ONBOARDING, + proKpisFor, + RECENT_ACTIVITY, + REGION_HEALTH, + type KpiEntry, +} from "@portal/mocks/home"; + +function kpisFor(tier: Tier): KpiEntry[] { + if (tier === "free") return FREE_KPIS; + const docs30d = buildUsageSeries().reduce((sum, p) => sum + p.value, 0); + if (tier === "enterprise") return enterpriseKpisFor(docs30d); + return proKpisFor(docs30d); +} + +export const homeHandlers = [ + http.get("/v1/analytics/usage", async () => { + await delay(120); + return HttpResponse.json(buildUsageSeriesResponse()); + }), + + http.get("/v1/activity", async () => { + await delay(120); + return HttpResponse.json(RECENT_ACTIVITY); + }), + + http.get("/v1/home/kpis", async ({ request }) => { + await delay(120); + const url = new URL(request.url); + const tier = (url.searchParams.get("tier") ?? "pro") as Tier; + return HttpResponse.json(kpisFor(tier)); + }), + + http.get("/v1/regions/health", async () => { + await delay(120); + return HttpResponse.json(REGION_HEALTH); + }), + + http.get("/v1/onboarding", async () => { + await delay(120); + return HttpResponse.json(FREE_ONBOARDING); + }), +]; diff --git a/frontend/portal/src/mocks/handlers/index.ts b/frontend/portal/src/mocks/handlers/index.ts new file mode 100644 index 000000000..5f8afcf03 --- /dev/null +++ b/frontend/portal/src/mocks/handlers/index.ts @@ -0,0 +1,17 @@ +import { assistantHandlers } from "@portal/mocks/handlers/assistant"; +import { endpointsHandlers } from "@portal/mocks/handlers/endpoints"; +import { homeHandlers } from "@portal/mocks/handlers/home"; +import { notificationsHandlers } from "@portal/mocks/handlers/notifications"; +import { opsHandlers } from "@portal/mocks/handlers/ops"; +import { searchHandlers } from "@portal/mocks/handlers/search"; + +export const handlers = [ + ...homeHandlers, + ...opsHandlers, + ...notificationsHandlers, + ...assistantHandlers, + ...searchHandlers, + ...endpointsHandlers, +]; + +export { resetNotificationsStore } from "@portal/mocks/handlers/notifications"; diff --git a/frontend/portal/src/mocks/handlers/notifications.ts b/frontend/portal/src/mocks/handlers/notifications.ts new file mode 100644 index 000000000..e9c809ac5 --- /dev/null +++ b/frontend/portal/src/mocks/handlers/notifications.ts @@ -0,0 +1,21 @@ +import { http, HttpResponse, delay } from "msw"; +import { NOTIFICATIONS, type Notification } from "@portal/mocks/notifications"; + +let store: Notification[] = [...NOTIFICATIONS]; + +export function resetNotificationsStore(seed?: Notification[]): void { + store = seed ? [...seed] : [...NOTIFICATIONS]; +} + +export const notificationsHandlers = [ + http.get("/v1/notifications", async () => { + await delay(120); + return HttpResponse.json(store); + }), + + http.post("/v1/notifications/mark-all-read", async () => { + await delay(120); + store = []; + return HttpResponse.json({ ok: true }); + }), +]; diff --git a/frontend/portal/src/mocks/handlers/ops.ts b/frontend/portal/src/mocks/handlers/ops.ts new file mode 100644 index 000000000..e963cfcef --- /dev/null +++ b/frontend/portal/src/mocks/handlers/ops.ts @@ -0,0 +1,30 @@ +import { http, HttpResponse, delay } from "msw"; +import { FEATURED_OPS, OP_RESULTS } from "@portal/mocks/ops"; + +export const opsHandlers = [ + http.get("/v1/ops/featured", async () => { + await delay(120); + return HttpResponse.json(FEATURED_OPS); + }), + + http.post("/v1/ops/:opId/run", async ({ params }) => { + const start = performance.now(); + await delay(800 + Math.random() * 200); + const opId = String(params.opId); + const result = OP_RESULTS[opId]; + if (!result) { + return HttpResponse.json( + { error: `Unknown op: ${opId}` }, + { status: 404 }, + ); + } + const enriched = + opId === "sign-output" + ? { ...result, signed_at: new Date().toISOString() } + : result; + return HttpResponse.json({ + result: enriched, + durationMs: Math.round(performance.now() - start), + }); + }), +]; diff --git a/frontend/portal/src/mocks/handlers/search.ts b/frontend/portal/src/mocks/handlers/search.ts new file mode 100644 index 000000000..b62e8bc91 --- /dev/null +++ b/frontend/portal/src/mocks/handlers/search.ts @@ -0,0 +1,8 @@ +import { http, HttpResponse } from "msw"; +import { QUICK_ACTIONS } from "@portal/mocks/search"; + +export const searchHandlers = [ + http.get("/v1/search/quick-actions", () => { + return HttpResponse.json(QUICK_ACTIONS); + }), +]; diff --git a/frontend/portal/src/mocks/home.ts b/frontend/portal/src/mocks/home.ts new file mode 100644 index 000000000..13b5f731f --- /dev/null +++ b/frontend/portal/src/mocks/home.ts @@ -0,0 +1,255 @@ +/** + * Home dashboard fixtures and the types api/home.ts shares with them. + * api/home.ts imports the types; the MSW handlers in mocks/handlers/ serve the + * fixture data over the intercepted httpJson() calls. Components never reach + * into this module directly. + * + * Once a real backend exists, the MSW handlers stop being registered and these + * fixtures can be deleted (or kept as test seeds). + */ + +export interface UsagePoint { + /** ISO date (YYYY-MM-DD). */ + date: string; + /** Docs processed on that day. */ + value: number; +} + +/** + * Server response for the usage-series endpoint. Returning the prior window's + * total alongside the points lets the client derive the headline delta + * deterministically from real data rather than carrying a hardcoded figure. + */ +export interface UsageSeriesResponse { + points: UsagePoint[]; + /** Equivalent docs total from the immediately prior 30-day window. */ + priorTotal: number; +} + +/** Builds 30 daily points ending today. Deterministic per day. */ +export function buildUsageSeries(): UsagePoint[] { + const points: UsagePoint[] = []; + const now = new Date(); + for (let i = 29; i >= 0; i--) { + const d = new Date(now); + d.setDate(now.getDate() - i); + const day = d.getDay(); + // Weekend dip + slow uptrend + bounded noise. + const weekend = day === 0 || day === 6 ? 0.55 : 1; + const trend = 1 + (30 - i) * 0.012; + const wobble = 1 + Math.sin(i * 1.3) * 0.18 + Math.cos(i * 0.6) * 0.09; + const base = 1450 * weekend * trend * wobble; + points.push({ + date: d.toISOString().slice(0, 10), + value: Math.round(base), + }); + } + return points; +} + +/** Builds the full usage payload with a plausible prior-window total. */ +export function buildUsageSeriesResponse(): UsageSeriesResponse { + const points = buildUsageSeries(); + const currentTotal = points.reduce((sum, p) => sum + p.value, 0); + // The current window's series simulates ~12% growth over the prior one. + const priorTotal = Math.round(currentTotal / 1.12); + return { points, priorTotal }; +} + +export type ActivityKind = + | "pipeline-run" + | "deploy" + | "drift" + | "eval" + | "agent" + | "billing"; + +export interface ActivityEvent { + id: string; + kind: ActivityKind; + /** Short action verb shown at the top of the row. */ + action: string; + /** Subject of the action (pipeline / endpoint / agent name). */ + subject: string; + /** One-line detail line under the action. */ + detail: string; + /** Relative-time string. */ + time: string; + status: "success" | "warning" | "danger" | "info"; +} + +export const RECENT_ACTIVITY: ActivityEvent[] = [ + { + id: "act-1", + kind: "pipeline-run", + action: "Pipeline run completed", + subject: "COI Compliance", + detail: "1,287 docs · 0.4% errors · P95 412 ms", + time: "2m ago", + status: "success", + }, + { + id: "act-2", + kind: "deploy", + action: "Deployed", + subject: "Prior Auth v3.1.0", + detail: "Promoted to us-east-1, eu-west-1 · golden set 36/36", + time: "14m ago", + status: "success", + }, + { + id: "act-3", + kind: "drift", + action: "Schema drift detected", + subject: "Invoice v3", + detail: "12 docs in 1h didn't match — confidence ↓ 0.07", + time: "1h ago", + status: "warning", + }, + { + id: "act-4", + kind: "eval", + action: "Eval set passed", + subject: "KYC Processor", + detail: "94% (26/28) — 2 cases sent to review", + time: "3h ago", + status: "success", + }, + { + id: "act-5", + kind: "agent", + action: "Agent escalated", + subject: "Contract Router", + detail: "Low-confidence DPA routed to L2 reviewer pool", + time: "5h ago", + status: "info", + }, + { + id: "act-6", + kind: "pipeline-run", + action: "Pipeline run failed", + subject: "Contract Review", + detail: "8% error rate · 14 docs sent to review queue", + time: "8h ago", + status: "danger", + }, + { + id: "act-7", + kind: "billing", + action: "Approaching cap", + subject: "Monthly usage", + detail: "389k of 500k docs · auto-upgrade disabled", + time: "yesterday", + status: "warning", + }, + { + id: "act-8", + kind: "deploy", + action: "Rolled back", + subject: "COI Compliance v2.3.7", + detail: "Confidence regressions on Carrier supplement", + time: "2d ago", + status: "warning", + }, +]; + +/** + * KPI labels are owned by the client (see `KPI_LABELS_BY_TIER` in Home.tsx) + * because they're product copy that should stay stable across loading / empty + * / ready states. The API only ships values + deltas. + */ +export interface KpiEntry { + value: string | number; + delta?: number; + description?: string; + deltaDirection?: "up" | "down" | "flat"; +} + +export const FREE_KPIS: KpiEntry[] = [ + { value: "247 / 500" }, + { value: 189 }, + { value: 3 }, + { value: 1 }, +]; + +export function proKpisFor(docs30d: number): KpiEntry[] { + return [ + { value: docs30d.toLocaleString(), delta: 0.12 }, + { value: 12, delta: 0.16 }, + { value: 7, delta: 0.4 }, + { value: "94.6%", delta: 0.02 }, + ]; +} + +export function enterpriseKpisFor(docs30d: number): KpiEntry[] { + return [ + { value: docs30d.toLocaleString(), delta: 0.18 }, + { value: "412 ms", delta: -0.05 }, + { value: "96.2%", delta: 0.01 }, + { value: "99.987%" }, + ]; +} + +export interface RegionHealth { + name: string; + status: "healthy" | "degraded" | "down"; + meta: string; +} + +export const REGION_HEALTH: RegionHealth[] = [ + { + name: "us-east-1", + status: "healthy", + meta: "2.1k/min · P95 287ms · 99.99% uptime", + }, + { + name: "eu-west-1", + status: "healthy", + meta: "1.4k/min · P95 312ms · 99.98% uptime", + }, + { + name: "ap-southeast-1", + status: "degraded", + meta: "412/min · P95 521ms · 99.92% uptime · degraded", + }, +]; + +export interface OnboardingStep { + id: string; + title: string; + blurb: string; + done: boolean; + /** What to render in the per-step CTA slot. */ + cta?: { kind: "try-op" } | { kind: "navigate"; target: string }; +} + +export const FREE_ONBOARDING: OnboardingStep[] = [ + { + id: "first-op", + title: "Run your first operation", + blurb: "Try extract, redact, or OCR on a sample document.", + done: true, + cta: { kind: "try-op" }, + }, + { + id: "connect-source", + title: "Connect a source", + blurb: "Attach an S3 bucket, webhook, or email inbox.", + done: false, + cta: { kind: "navigate", target: "sources" }, + }, + { + id: "build-pipeline", + title: "Build a pipeline", + blurb: "Compose ops into a repeatable workflow.", + done: false, + cta: { kind: "navigate", target: "pipelines" }, + }, + { + id: "wire-agent", + title: "Wire an agent", + blurb: "Expose Stirling via MCP or REST tool definitions.", + done: false, + cta: { kind: "navigate", target: "sources" }, + }, +]; diff --git a/frontend/portal/src/mocks/notifications.ts b/frontend/portal/src/mocks/notifications.ts new file mode 100644 index 000000000..b267bbe34 --- /dev/null +++ b/frontend/portal/src/mocks/notifications.ts @@ -0,0 +1,63 @@ +/** Mock notifications for the header dropdown. */ + +export type NotificationCategory = + | "pipeline" + | "deploy" + | "billing" + | "audit" + | "agent" + | "doc"; + +export interface Notification { + id: string; + category: NotificationCategory; + title: string; + description: string; + /** Relative-time string. */ + time: string; +} + +export const NOTIFICATIONS: Notification[] = [ + { + id: "n1", + category: "pipeline", + title: "COI Compliance — golden set passed", + description: "48/48 docs reproduced expected outputs after schema change", + time: "2m ago", + }, + { + id: "n2", + category: "deploy", + title: "Deploy succeeded — Prior Auth v2.4", + description: "Rolled to us-east-1 + eu-west-1. P99 142ms.", + time: "14m ago", + }, + { + id: "n3", + category: "billing", + title: "Approaching 80% of monthly cap", + description: "389k of 500k docs processed. Auto-upgrade not enabled.", + time: "1h ago", + }, + { + id: "n4", + category: "audit", + title: "Four-eyes elevation requested", + description: "Compliance Lead approval pending for redacted doc #28471", + time: "3h ago", + }, + { + id: "n5", + category: "agent", + title: "KYC Processor — eval drop", + description: "Pass rate fell to 87% on the latest 72h window", + time: "5h ago", + }, + { + id: "n6", + category: "doc", + title: "Schema drift detected — Invoice v3", + description: "12 docs in last 24h didn't match expected schema", + time: "yesterday", + }, +]; diff --git a/frontend/portal/src/mocks/ops.ts b/frontend/portal/src/mocks/ops.ts new file mode 100644 index 000000000..fc1bd203d --- /dev/null +++ b/frontend/portal/src/mocks/ops.ts @@ -0,0 +1,166 @@ +/** + * Mock op catalogue + canned operation results for the single-op runner. + * Only api/ops.ts imports from this file. + */ + +export type OpResultMap = Record; + +export interface FeaturedOp { + id: string; + label: string; + endpoint: string; + accent: "blue" | "purple" | "green" | "amber" | "red"; + /** Shown in the picker — short single line. */ + blurb: string; +} + +export const FEATURED_OPS: FeaturedOp[] = [ + { + id: "extract", + label: "Extract", + endpoint: "/v1/extract", + accent: "blue", + blurb: "Pull structured fields into a typed schema", + }, + { + id: "redact", + label: "Redact PII", + endpoint: "/v1/redact", + accent: "red", + blurb: "Mask SSN, DOB, addresses, accounts before storage", + }, + { + id: "classify", + label: "Classify", + endpoint: "/v1/classify", + accent: "purple", + blurb: "Identify document type with a confidence score", + }, + { + id: "ocr", + label: "OCR", + endpoint: "/v1/ocr", + accent: "green", + blurb: "Text-recognize scanned or image pages", + }, + { + id: "validate", + label: "Schema validate", + endpoint: "/v1/validate", + accent: "blue", + blurb: "Check fields, rules, and coverage against the schema", + }, + { + id: "sign-output", + label: "Sign output", + endpoint: "/v1/sign", + accent: "green", + blurb: "Tamper-evident signature over artifact + run metadata", + }, + { + id: "authenticity", + label: "Authenticity", + endpoint: "/v1/authenticity", + accent: "blue", + blurb: "Verify issuer signature, watermark, and metadata", + }, + { + id: "tamper-check", + label: "Tamper check", + endpoint: "/v1/tamper-check", + accent: "amber", + blurb: "Detect modifications since signing or last-known-good state", + }, + { + id: "encrypt-rest", + label: "Encrypt at rest", + endpoint: "/v1/encrypt", + accent: "purple", + blurb: "AES-256 with Stirling-managed, BYOK, or HYOK keys", + }, + { + id: "smart-redact", + label: "Smart redact", + endpoint: "/v1/smart-redact", + accent: "red", + blurb: "Schema-aware redaction with confidence gating", + }, +]; + +/** Canned JSON for each featured op's runner "done" state. */ +export const OP_RESULTS: Record = { + extract: { + schema: "coi.v2", + fields: { + carrier: "Travelers Casualty", + policy_number: "PHB-1108-2025", + gl_limit: 1_000_000, + umbrella_limit: 5_000_000, + effective: "2026-01-15", + expiry: "2027-01-15", + }, + confidence_avg: 0.96, + }, + redact: { + redacted_pages: 4, + pii_types: ["SSN", "DOB", "ADDRESS", "EMAIL"], + occurrences: 19, + redaction_style: "blackout", + audit_id: "rdct_01HVQ7K3ZA9YJ8C", + }, + classify: { + schema: "invoice.v3", + confidence: 0.94, + alternatives: [ + { schema: "credit_memo.v1", confidence: 0.04 }, + { schema: "purchase_order.v2", confidence: 0.02 }, + ], + processing_ms: 287, + }, + ocr: { + pages: 12, + characters_recognized: 28471, + confidence_avg: 0.987, + languages_detected: ["en"], + processing_ms: 1840, + }, + validate: { + schema: "coi.v2", + passed: true, + checks_run: 14, + warnings: [ + { field: "additional_insured", message: "Optional field empty" }, + ], + }, + "sign-output": { + algorithm: "Ed25519", + key_id: "kx-prod-2026", + manifest_hash: + "0xb3f0c1a9d54fa1c0b8fd4eebd7fa11b1b16c9a3e2d2cc6f1f5a2f0a87e1b7a04", + }, + authenticity: { + verified: true, + issuer: "State of California DMV", + signed_at: "2025-11-04T17:22:00Z", + watermark_match: true, + }, + "tamper-check": { + tampered: false, + hash_match: true, + modifications_detected: 0, + last_known_good: "2026-04-22T09:14:00Z", + }, + "encrypt-rest": { + algorithm: "AES-256-GCM", + key_mode: "BYOK", + key_id: "arn:aws:kms:us-east-1:123:key/abc-…", + object_id: "obj_01HVQ7M9B2", + }, + "smart-redact": { + schema: "coi.v2", + redacted_fields: ["named_insured", "dob"], + occurrences: 7, + confidence_gate: 0.85, + gated_by_confidence: 1, + }, +}; diff --git a/frontend/portal/src/mocks/preference.ts b/frontend/portal/src/mocks/preference.ts new file mode 100644 index 000000000..466a62e65 --- /dev/null +++ b/frontend/portal/src/mocks/preference.ts @@ -0,0 +1,23 @@ +/// + +/** + * Lightweight preference helpers — pulled out of mocks/browser.ts so they + * don't drag MSW + every handler + every fixture into any chunk that just + * needs to *read* the user's choice. Loading the actual worker stays a + * dynamic import. + */ + +const STORAGE_KEY = "stirling.portal.mocks-enabled"; + +export function readMocksPreference(): boolean { + if (typeof window === "undefined") return false; + const stored = window.localStorage.getItem(STORAGE_KEY); + if (stored === "true") return true; + if (stored === "false") return false; + return import.meta.env.DEV; +} + +export function writeMocksPreference(enabled: boolean): void { + if (typeof window === "undefined") return; + window.localStorage.setItem(STORAGE_KEY, String(enabled)); +} diff --git a/frontend/portal/src/mocks/search.ts b/frontend/portal/src/mocks/search.ts new file mode 100644 index 000000000..f8c5990c7 --- /dev/null +++ b/frontend/portal/src/mocks/search.ts @@ -0,0 +1,18 @@ +/** Mock quick-action catalogue for the ⌘K search palette. */ + +export interface QuickAction { + group: "Jump to" | "Create" | "Theme"; + label: string; + /** Keyboard hint shown to the right. */ + hint: string; +} + +export const QUICK_ACTIONS: QuickAction[] = [ + { group: "Jump to", label: "Home", hint: "G H" }, + { group: "Jump to", label: "Pipelines", hint: "G P" }, + { group: "Jump to", label: "Sources", hint: "G S" }, + { group: "Jump to", label: "Documents", hint: "G D" }, + { group: "Create", label: "New pipeline", hint: "N P" }, + { group: "Create", label: "New API key", hint: "N K" }, + { group: "Theme", label: "Toggle dark / light", hint: "T" }, +]; diff --git a/frontend/portal/src/theme/MantineIntegration.stories.tsx b/frontend/portal/src/theme/MantineIntegration.stories.tsx new file mode 100644 index 000000000..a744ee735 --- /dev/null +++ b/frontend/portal/src/theme/MantineIntegration.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + Button as MantineButton, + Group, + Stack, + TextInput, +} from "@mantine/core"; +import { Button as SuiButton } from "@shared/components"; + +/** + * Proof that Mantine components render with the SUI-bound theme — a Mantine + * filled button uses --color-blue, so it sits next to a SUI button as one + * system. This is the "escape hatch" pattern: reach for Mantine when a + * component isn't worth rebuilding in SUI, and it still looks on-brand. + */ +const meta: Meta = { + title: "Portal/Theme/Mantine Integration", + parameters: { layout: "padded" }, +}; +export default meta; + +type Story = StoryObj; + +export const SideBySide: Story = { + render: () => ( + + + SUI Button + Mantine Button + + + Mantine green + Mantine red + Mantine amber + Mantine purple + + + Light variant (uses --color-blue-light) + + + + ), +}; diff --git a/frontend/portal/src/theme/mantineTheme.ts b/frontend/portal/src/theme/mantineTheme.ts new file mode 100644 index 000000000..fa8e6d529 --- /dev/null +++ b/frontend/portal/src/theme/mantineTheme.ts @@ -0,0 +1,87 @@ +import { createTheme, type MantineColorsTuple } from "@mantine/core"; + +/** + * Mantine theme for the portal, bound to the SUI design tokens in + * shared/tokens/tokens.css. This is what lets a Mantine component (e.g. a + * Combobox we don't want to rebuild in SUI) sit next to a SUI and look + * like one system. + * + * SUI exposes ~4 named shades per colour (`-light`, `-border`, base, `-dark`), + * but Mantine requires a 10-slot tuple. `tuple()` spreads the SUI shades across + * the 10 slots, with index 6 — Mantine's default `filled` shade — landing on + * the base brand colour. Because every slot is a `var(--color-*)` reference and + * those variables flip under `[data-theme="dark"]`, the Mantine theme follows + * SUI's light/dark switch automatically with no extra wiring. + * + * Caveat: every shade is a `var(--color-*)` reference, not a literal colour, so + * Mantine's JS colour maths can't read it. Don't enable `autoContrast` or rely + * on `theme.fn.lighten/darken/alpha` for these palettes — they need real hex + * values. CSS `color-mix()` variants work fine. + */ +function tuple( + light: string, + border: string, + base: string, + dark: string, +): MantineColorsTuple { + return [ + `var(${light})`, // 0 subtle background + `var(${light})`, // 1 + `var(${border})`, // 2 + `var(${border})`, // 3 + `var(${base})`, // 4 + `var(${base})`, // 5 + `var(${base})`, // 6 default filled shade + `var(${dark})`, // 7 hover + `var(${dark})`, // 8 + `var(${dark})`, // 9 + ]; +} + +const blue = tuple( + "--color-blue-light", + "--color-blue-border", + "--color-blue", + "--color-blue-dark", +); +const green = tuple( + "--color-green-light", + "--color-green-border", + "--color-green", + "--color-green-dark", +); +const red = tuple( + "--color-red-light", + "--color-red-border", + "--color-red", + "--color-red-dark", +); +const amber = tuple( + "--color-amber-light", + "--color-amber-border", + "--color-amber", + "--color-amber-dark", +); +const purple = tuple( + "--color-purple-light", + "--color-purple-border", + "--color-purple", + "--color-purple-dark", +); + +export const mantineTheme = createTheme({ + primaryColor: "blue", + // Mantine uses index 6 of the tuple for filled components by default, which + // is where tuple() places the base brand shade. + primaryShade: 6, + colors: { + blue, + green, + red, + amber, + purple, + }, + fontFamily: "var(--font-sans)", + fontFamilyMonospace: "var(--font-mono)", + defaultRadius: "var(--radius-md)", +}); diff --git a/frontend/portal/src/views/Documents.css b/frontend/portal/src/views/Documents.css new file mode 100644 index 000000000..202a734ac --- /dev/null +++ b/frontend/portal/src/views/Documents.css @@ -0,0 +1,5 @@ +.portal-documents { + padding: 1.5rem; + max-width: 78rem; + margin: 0 auto; +} diff --git a/frontend/portal/src/views/Documents.tsx b/frontend/portal/src/views/Documents.tsx new file mode 100644 index 000000000..c4bd89f31 --- /dev/null +++ b/frontend/portal/src/views/Documents.tsx @@ -0,0 +1,15 @@ +import { DocumentTypeGrid } from "@portal/components/DocumentTypeGrid"; +import "@portal/views/Documents.css"; + +/** + * Full document-type catalogue — the exhaustive per-vertical endpoint list. + * Home only teases four use cases (see PopularUseCases); the complete, + * tab-filterable grid lives here on its own surface. + */ +export function Documents() { + return ( +
+ +
+ ); +} diff --git a/frontend/portal/src/views/Home.css b/frontend/portal/src/views/Home.css new file mode 100644 index 000000000..0f7b435aa --- /dev/null +++ b/frontend/portal/src/views/Home.css @@ -0,0 +1,276 @@ +.portal-home { + display: flex; + flex-direction: column; + gap: 1.25rem; + padding: 1.5rem; + max-width: 78rem; + margin: 0 auto; +} + +/* Product card grid */ +.portal-home__product-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.875rem; +} + +@media (max-width: 60rem) { + .portal-home__product-grid { + grid-template-columns: 1fr; + } +} + +.portal-home__product-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.portal-home__product-title { + margin: 0; + font-size: 1.0625rem; + font-weight: 600; + color: var(--color-text-1); +} + +.portal-home__product-blurb { + margin: 0 0 1rem; + font-size: 0.8125rem; + line-height: 1.5; + color: var(--color-text-3); +} + +/* Metric strip */ +.portal-home__metrics { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.75rem; +} + +@media (max-width: 50rem) { + .portal-home__metrics { + grid-template-columns: repeat(2, 1fr); + } +} + +/* Two-column dashboard row (activity + quick actions) */ +.portal-home__row { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); + gap: 1rem; + align-items: stretch; +} + +@media (max-width: 60rem) { + .portal-home__row { + grid-template-columns: 1fr; + } +} + +/* Quick actions card */ +.portal-home__quick-head { + margin-bottom: 0.625rem; +} + +.portal-home__quick-title { + margin: 0; + font-size: 0.9375rem; + font-weight: 600; + color: var(--color-text-1); +} + +.portal-home__quick-sub { + font-size: 0.75rem; + color: var(--color-text-4); +} + +.portal-home__quick-list { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.portal-home__quick-row { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 0.625rem; + padding: 0.5rem 0.625rem; + background: var(--color-bg-subtle); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-md); + text-align: left; + transition: + background var(--motion-fast), + border-color var(--motion-fast); +} + +.portal-home__quick-row:hover { + background: var(--color-bg-hover); + border-color: var(--color-border); +} + +.portal-home__quick-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + border-radius: var(--radius-md); + font-size: 0.875rem; + font-weight: 600; +} + +.portal-home__quick-text { + display: flex; + flex-direction: column; + min-width: 0; +} + +.portal-home__quick-text strong { + font-size: 0.8125rem; + color: var(--color-text-1); + font-weight: 600; +} + +.portal-home__quick-text span { + font-size: 0.75rem; + color: var(--color-text-4); +} + +.portal-home__quick-arrow { + font-size: 0.875rem; + color: var(--color-text-5); +} + +.portal-home__quick-row:hover .portal-home__quick-arrow { + color: var(--color-blue); +} + +/* Free-tier onboarding card */ +.portal-home__onboard-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.875rem; +} + +.portal-home__onboard-title { + margin: 0; + font-size: 0.9375rem; + font-weight: 600; + color: var(--color-text-1); +} + +.portal-home__onboard-sub { + margin: 0; + font-size: 0.75rem; + color: var(--color-text-4); +} + +.portal-home__onboard-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.portal-home__onboard-step { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 0.625rem; + background: var(--color-bg-subtle); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-md); +} + +.portal-home__onboard-step.is-done { + background: var(--color-green-light); + border-color: color-mix(in srgb, var(--color-green) 30%, transparent); +} + +.portal-home__onboard-mark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + border-radius: 50%; + font-size: 0.75rem; + font-weight: 600; + background: var(--color-surface); + border: 1px solid var(--color-border); + color: var(--color-text-4); +} + +.portal-home__onboard-step.is-done .portal-home__onboard-mark { + background: var(--color-green); + color: var(--color-text-on-accent); + border-color: var(--color-green); +} + +.portal-home__onboard-text { + display: flex; + flex-direction: column; + min-width: 0; +} + +.portal-home__onboard-text strong { + font-size: 0.8125rem; + color: var(--color-text-1); + font-weight: 600; +} + +.portal-home__onboard-text span { + font-size: 0.75rem; + color: var(--color-text-4); +} + +/* Enterprise region strip */ +.portal-home__regions { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.5rem; +} + +@media (max-width: 50rem) { + .portal-home__regions { + grid-template-columns: 1fr; + } +} + +.portal-home__region { + padding: 0.625rem 0.75rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); +} + +.portal-home__region-name { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.8125rem; + font-weight: 500; + color: var(--color-text-1); +} + +.portal-home__region-dot { + width: 0.4375rem; + height: 0.4375rem; + border-radius: 50%; +} + +.portal-home__region-meta { + margin-top: 0.25rem; + font-size: 0.6875rem; + color: var(--color-text-4); + font-family: var(--font-mono); +} diff --git a/frontend/portal/src/views/Home.stories.tsx b/frontend/portal/src/views/Home.stories.tsx new file mode 100644 index 000000000..6e7aec49a --- /dev/null +++ b/frontend/portal/src/views/Home.stories.tsx @@ -0,0 +1,29 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse, delay } from "msw"; +import { Home } from "@portal/views/Home"; + +const meta: Meta = { + title: "Portal/Views/Home", + component: Home, + parameters: { layout: "fullscreen" }, +}; +export default meta; +type Story = StoryObj; + +export const ProTier: Story = { globals: { tier: "pro" } }; +export const FreeTier: Story = { globals: { tier: "free" } }; +export const EnterpriseTier: Story = { globals: { tier: "enterprise" } }; + +export const SlowUsage: Story = { + globals: { tier: "pro" }, + parameters: { + msw: { + handlers: [ + http.get("/v1/analytics/usage", async () => { + await delay(3000); + return HttpResponse.json({ points: [], priorTotal: 0 }); + }), + ], + }, + }, +}; diff --git a/frontend/portal/src/views/Home.tsx b/frontend/portal/src/views/Home.tsx new file mode 100644 index 000000000..9ccf7ea08 --- /dev/null +++ b/frontend/portal/src/views/Home.tsx @@ -0,0 +1,553 @@ +import { useMemo, useState } from "react"; +import { + Button, + Card, + EmptyState, + MetricCard, + Skeleton, + StatusBadge, +} from "@shared/components"; +import { useTier, type Tier } from "@portal/contexts/TierContext"; +import { useView, type ViewId } from "@portal/contexts/ViewContext"; +import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; +import { + fetchHomeKpis, + fetchOnboarding, + fetchRegionHealth, + fetchUsageSeries, + type KpiEntry, + type OnboardingStep, + type RegionHealth, + type UsageSeriesResponse, +} from "@portal/api/home"; +import { WelcomeCarousel } from "@portal/components/WelcomeCarousel"; +import { PopularUseCases } from "@portal/components/PopularUseCases"; +import { UsageAreaChart } from "@portal/components/UsageAreaChart"; +import { RecentActivity } from "@portal/components/RecentActivity"; +import { SingleOpRunner } from "@portal/components/SingleOpRunner"; +import "@portal/views/Home.css"; + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Product cards (Sources / Pipelines / Agents) */ +/* ──────────────────────────────────────────────────────────────────────── */ + +interface ProductCardProps { + accent: "blue" | "purple"; + badge?: string; + title: string; + blurb: string; + cta: string; + target: ViewId; +} + +function ProductCard({ + accent, + badge, + title, + blurb, + cta, + target, +}: ProductCardProps) { + const { setActiveView } = useView(); + return ( + +
+

{title}

+ {badge && ( + + {badge} + + )} +
+

{blurb}

+ +
+ ); +} + +function ProductGrid() { + return ( +
+ + + +
+ ); +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Quick actions card */ +/* ──────────────────────────────────────────────────────────────────────── */ + +function QuickActions({ onTryOp }: { onTryOp: () => void }) { + const { setActiveView } = useView(); + return ( + +
+

Quick actions

+ Top tasks for today +
+
+ + + + +
+
+ ); +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Free-tier onboarding checklist */ +/* ──────────────────────────────────────────────────────────────────────── */ + +function FreeOnboarding({ onTryOp }: { onTryOp: () => void }) { + const { setActiveView } = useView(); + const state = useAsync(() => fetchOnboarding(), []); + const { data: steps } = state; + const { isLoading, isEmpty } = useSectionFlags(state); + + const doneCount = steps?.filter((s) => s.done).length ?? 0; + + function renderCta(step: OnboardingStep) { + if (step.done) { + return ( + + ); + } + if (!step.cta) return null; + if (step.cta.kind === "try-op") { + return ( + + ); + } + const target = step.cta.target; + return ( + + ); + } + + return ( + +
+
+

Get to value

+

+ Four steps to a production-shaped Stirling project. +

+
+ {steps && steps.length > 0 && ( + + {doneCount} / {steps.length} done + + )} +
+ + {isLoading && ( +
    + {Array.from({ length: 4 }).map((_, i) => ( +
  1. + +
    +
    +
    +
  2. + ))} +
+ )} + + {isEmpty && ( + + )} + + {steps && steps.length > 0 && ( +
    + {steps.map((s, i) => ( +
  1. + + {s.done ? "✓" : i + 1} + +
    + {s.title} + {s.blurb} +
    + {renderCta(s)} +
  2. + ))} +
+ )} +
+ ); +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Tier KPI strip */ +/* ──────────────────────────────────────────────────────────────────────── */ + +interface KpiLabelSpec { + label: string; + description?: string; +} + +/** + * KPI labels are product copy — they describe what each metric IS, not what + * its current value is. Labels stay client-side so the strip's structure is + * stable across loading / error / ready states. Only the values + deltas + * flow through the API. + */ +const KPI_LABELS_BY_TIER: Record = { + free: [ + { label: "Docs processed", description: "Free plan cap" }, + { label: "Operations" }, + { label: "Pipelines" }, + { label: "Agents" }, + ], + pro: [ + { label: "Docs / 30d" }, + { label: "Pipelines" }, + { label: "Agents active" }, + { label: "Eval pass rate" }, + ], + enterprise: [ + { label: "Docs / 30d" }, + { label: "P95 latency" }, + { label: "Eval pass rate" }, + { label: "SLA uptime (30d)" }, + ], +}; + +function TierKpiStrip() { + const { tier } = useTier(); + const labels = KPI_LABELS_BY_TIER[tier]; + const { data: kpis, loading } = useAsync( + () => fetchHomeKpis(tier), + [tier], + ); + + return ( +
+ {labels.map((spec, i) => { + // useAsync keeps the previous tier's data during a refetch; ignore it + // while loading so the new labels never pair with stale values. + const fetched = loading ? undefined : kpis?.[i]; + return ( + + ); + })} +
+ ); +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Enterprise region health */ +/* ──────────────────────────────────────────────────────────────────────── */ + +const REGION_DOT: Record = { + healthy: "var(--color-green)", + degraded: "var(--color-amber)", + down: "var(--color-red)", +}; + +function EnterpriseRegions() { + const state = useAsync(() => fetchRegionHealth(), []); + const { data: regions } = state; + const { isLoading, isEmpty } = useSectionFlags(state); + + return ( +
+
+

Region health

+

+ Real-time status for every deployed Stirling region. +

+
+ + {isLoading && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ + +
+ ))} +
+ )} + + {isEmpty && ( + + )} + + {regions && regions.length > 0 && ( +
+ {regions.map((r) => ( +
+
+ + {r.name} + {` — ${r.status}`} +
+
{r.meta}
+
+ ))} +
+ )} +
+ ); +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Chart section */ +/* ──────────────────────────────────────────────────────────────────────── */ + +function ChartSection() { + const state = useAsync(() => fetchUsageSeries(), []); + const { data: usage } = state; + const { isLoading } = useSectionFlags(state); + + const docs30d = useMemo( + () => usage?.points.reduce((sum, p) => sum + p.value, 0) ?? 0, + [usage], + ); + const deltaPct = useMemo(() => { + if (!usage || usage.priorTotal <= 0) return undefined; + return (docs30d - usage.priorTotal) / usage.priorTotal; + }, [usage, docs30d]); + + if (isLoading) { + return ( +
+ + + +
+ ); + } + + if (!usage || usage.points.length === 0) { + return ( + + ); + } + + return ( + + ); +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Home view */ +/* ──────────────────────────────────────────────────────────────────────── */ + +export function Home() { + const { tier } = useTier(); + const [runnerOpen, setRunnerOpen] = useState(false); + + return ( +
+ setRunnerOpen(true)} /> + + {tier === "free" && ( + <> + +
+ setRunnerOpen(true)} /> + setRunnerOpen(true)} /> +
+ + + + )} + + {tier === "pro" && ( + <> + + +
+ + setRunnerOpen(true)} /> +
+ + + + )} + + {tier === "enterprise" && ( + <> + + + +
+ + setRunnerOpen(true)} /> +
+ + + + )} + + setRunnerOpen(false)} /> +
+ ); +} diff --git a/frontend/portal/src/views/Placeholder.css b/frontend/portal/src/views/Placeholder.css new file mode 100644 index 000000000..0bc1f0c8e --- /dev/null +++ b/frontend/portal/src/views/Placeholder.css @@ -0,0 +1,33 @@ +.portal-placeholder { + display: flex; + justify-content: center; + padding: 3rem 1.5rem; +} + +.portal-placeholder__card { + max-width: 36rem; + text-align: center; +} + +.portal-placeholder__eyebrow { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--color-blue); + margin-bottom: 0.5rem; +} + +.portal-placeholder__title { + margin: 0 0 0.75rem; + font-size: 1.5rem; + font-weight: 700; + color: var(--color-text-1); +} + +.portal-placeholder__copy { + margin: 0; + font-size: 0.9375rem; + line-height: 1.55; + color: var(--color-text-3); +} diff --git a/frontend/portal/src/views/Placeholder.stories.tsx b/frontend/portal/src/views/Placeholder.stories.tsx new file mode 100644 index 000000000..dcd37a982 --- /dev/null +++ b/frontend/portal/src/views/Placeholder.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Placeholder } from "@portal/views/Placeholder"; + +const meta: Meta = { + title: "Portal/Views/Placeholder", + component: Placeholder, + parameters: { layout: "fullscreen" }, +}; +export default meta; +type Story = StoryObj; + +export const Sources: Story = { + args: { view: "sources", phase: "Phase 5 — Sources & Agents" }, +}; +export const Documents: Story = { + args: { view: "documents", phase: "Phase 6 — Documents" }, +}; +export const Infrastructure: Story = { + args: { view: "infrastructure", phase: "Phase 7 — Infrastructure" }, +}; +export const Editor: Story = { + args: { view: "editor", phase: "Phase 8 — Editor" }, +}; +export const Settings: Story = { + args: { view: "settings", phase: "Settings — modal overlay" }, +}; diff --git a/frontend/portal/src/views/Placeholder.tsx b/frontend/portal/src/views/Placeholder.tsx new file mode 100644 index 000000000..6110c2b1c --- /dev/null +++ b/frontend/portal/src/views/Placeholder.tsx @@ -0,0 +1,27 @@ +import { Card } from "@shared/components"; +import { VIEW_LABELS, type ViewId } from "@portal/contexts/ViewContext"; +import "@portal/views/Placeholder.css"; + +interface PlaceholderProps { + view: ViewId; + phase?: string; +} + +export function Placeholder({ view, phase }: PlaceholderProps) { + return ( +
+ +
+ {phase ?? "Coming soon"} +
+

{VIEW_LABELS[view]}

+

+ This surface is part of the build plan but hasn’t been wired up + yet. The shell, theme, and tier behaviour around it are already live — + you can switch views from the sidebar, flip the theme, and change tier + to see how the chrome reacts. +

+
+
+ ); +} diff --git a/frontend/portal/tsconfig.json b/frontend/portal/tsconfig.json new file mode 100644 index 000000000..e4f1a3258 --- /dev/null +++ b/frontend/portal/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "es2022", + "jsx": "react-jsx", + "module": "esnext", + "moduleResolution": "bundler", + "baseUrl": "./", + "paths": { + "@portal/*": ["src/*"], + "@shared/*": ["../shared/*"] + }, + "resolveJsonModule": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["src", "main.tsx", "vite.config.ts"] +} diff --git a/frontend/portal/vite.config.ts b/frontend/portal/vite.config.ts new file mode 100644 index 000000000..2db925a7b --- /dev/null +++ b/frontend/portal/vite.config.ts @@ -0,0 +1,65 @@ +import { resolve } from "node:path"; +import { defineConfig, loadEnv } from "vite"; +import react from "@vitejs/plugin-react-swc"; +import tsconfigPaths from "vite-tsconfig-paths"; + +// Portal app — sibling to frontend/editor/. Standalone vite config so the +// editor's config stays editor-only. +// +// Layout: +// frontend/portal/ <-- this config's root +// index.html +// main.tsx +// src/ +// public/ +// tsconfig.json +// frontend/shared/ <-- imported via @shared/* +// frontend/node_modules/ <-- hoisted workspace deps +// +// The build emits to frontend/dist-portal/ (parallel to the editor's dist/). + +export default defineConfig(async ({ mode }) => { + // Load .env files relative to this config, regardless of where invoked from. + const env = loadEnv(mode, import.meta.dirname, ""); + + return { + plugins: [ + react(), + tsconfigPaths({ + projects: [resolve(import.meta.dirname, "tsconfig.json")], + }), + ], + // Explicit aliases — tsconfigPaths only resolves imports inside files + // covered by portal/tsconfig.json's `include`. shared/components/index.ts + // re-exports via `@shared/*` from inside shared/ itself, so we need + // resolve.alias for vite to handle those too. + resolve: { + alias: { + "@portal": resolve(import.meta.dirname, "src"), + "@shared": resolve(import.meta.dirname, "..", "shared"), + }, + }, + // The portal's @shared/* alias resolves to frontend/shared/, one level up + // from this config's root. Vite refuses to serve files outside its root + // by default; whitelisting the workspace root opens up shared/ and the + // hoisted node_modules. + server: { + host: true, + port: 5173, + strictPort: true, + fs: { + allow: [resolve(import.meta.dirname, "..")], + }, + }, + preview: { + host: true, + port: 5173, + strictPort: true, + }, + build: { + outDir: "../dist-portal", + emptyOutDir: true, + }, + base: env.RUN_SUBPATH ? `/${env.RUN_SUBPATH}` : "./", + }; +}); diff --git a/frontend/shared/components/Avatar.css b/frontend/shared/components/Avatar.css new file mode 100644 index 000000000..07187c439 --- /dev/null +++ b/frontend/shared/components/Avatar.css @@ -0,0 +1,68 @@ +.sui-avatar { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 50%; + font-weight: 600; + color: var(--color-text-on-accent); + font-family: var(--font-sans); + letter-spacing: 0.01em; + overflow: hidden; + flex-shrink: 0; +} + +.sui-avatar--interactive { + cursor: pointer; + transition: transform var(--motion-fast); +} +.sui-avatar--interactive:hover { + transform: scale(1.05); +} + +.sui-avatar--xs { + width: 1.25rem; + height: 1.25rem; + font-size: 0.625rem; +} +.sui-avatar--sm { + width: 1.625rem; + height: 1.625rem; + font-size: 0.6875rem; +} +.sui-avatar--md { + width: 2rem; + height: 2rem; + font-size: 0.8125rem; +} +.sui-avatar--lg { + width: 2.5rem; + height: 2.5rem; + font-size: 1rem; +} + +.sui-avatar__img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.sui-avatar--blue { + background: var(--grad-blue-btn); +} +.sui-avatar--purple { + background: var(--grad-purple-btn); +} +.sui-avatar--green { + background: var(--grad-green-btn); +} +.sui-avatar--amber { + background: var(--grad-amber-btn); +} +.sui-avatar--red { + background: var(--grad-red-btn); +} +.sui-avatar--neutral { + background: var(--color-bg-muted); + color: var(--color-text-2); +} diff --git a/frontend/shared/components/Avatar.stories.tsx b/frontend/shared/components/Avatar.stories.tsx new file mode 100644 index 000000000..ac959f47a --- /dev/null +++ b/frontend/shared/components/Avatar.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Avatar } from "@shared/components/Avatar"; + +const meta: Meta = { + title: "Primitives/Avatar", + component: Avatar, + tags: ["autodocs"], + parameters: { layout: "centered" }, + args: { name: "Harper Lee", size: "md", tone: "blue" }, + argTypes: { + size: { control: "inline-radio", options: ["xs", "sm", "md", "lg"] }, + tone: { + control: "inline-radio", + options: ["blue", "purple", "green", "amber", "red", "neutral"], + }, + onClick: { action: "clicked" }, + }, +}; +export default meta; +type Story = StoryObj; + +/** Flip name / size / tone / interactive in controls. */ +export const Playground: Story = {}; + +export const SizeRow: Story = { + render: () => ( +
+ + + + +
+ ), +}; + +export const ToneRow: Story = { + render: () => ( +
+ {(["blue", "purple", "green", "amber", "red", "neutral"] as const).map( + (t) => ( + + ), + )} +
+ ), +}; diff --git a/frontend/shared/components/Avatar.tsx b/frontend/shared/components/Avatar.tsx new file mode 100644 index 000000000..5246e2400 --- /dev/null +++ b/frontend/shared/components/Avatar.tsx @@ -0,0 +1,79 @@ +import "@shared/components/Avatar.css"; + +export type AvatarSize = "xs" | "sm" | "md" | "lg"; +export type AvatarTone = + | "blue" + | "purple" + | "green" + | "amber" + | "red" + | "neutral"; + +export interface AvatarProps { + /** Image source. Falls back to initials when missing or load fails. */ + src?: string; + /** Full name. Initials are derived from the first letter of each word, max 2. */ + name: string; + size?: AvatarSize; + /** Background tone when rendering initials. Defaults to blue. */ + tone?: AvatarTone; + /** Optional click handler — renders as a button when supplied. */ + onClick?: () => void; + ariaLabel?: string; + className?: string; +} + +function initialsOf(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return "?"; + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); +} + +/** + * Round avatar showing either an image or coloured initials. Used by the + * Header account button, reviewer rows, and assistant message gutters. + */ +export function Avatar({ + src, + name, + size = "md", + tone = "blue", + onClick, + ariaLabel, + className, +}: AvatarProps) { + const classes = [ + "sui-avatar", + `sui-avatar--${size}`, + `sui-avatar--${tone}`, + onClick ? "sui-avatar--interactive" : "", + className ?? "", + ] + .filter(Boolean) + .join(" "); + + const content = src ? ( + {ariaLabel + ) : ( + {initialsOf(name)} + ); + + if (onClick) { + return ( + + ); + } + return ( + + {content} + + ); +} diff --git a/frontend/shared/components/Banner.css b/frontend/shared/components/Banner.css new file mode 100644 index 000000000..7d249295b --- /dev/null +++ b/frontend/shared/components/Banner.css @@ -0,0 +1,94 @@ +.sui-banner { + display: flex; + align-items: flex-start; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-md); + border: 1px solid transparent; + font-size: 0.8125rem; + line-height: 1.5; +} + +.sui-banner--info { + background: var(--color-blue-light); + color: var(--color-text-2); + border-color: var(--color-blue-border); +} +.sui-banner--success { + background: var(--color-green-light); + color: var(--color-text-2); + border-color: var(--color-green-border); +} +.sui-banner--warning { + background: var(--color-amber-light); + color: var(--color-text-2); + border-color: var(--color-amber-border); +} +.sui-banner--danger { + background: var(--color-red-light); + color: var(--color-text-2); + border-color: var(--color-red-border); +} +.sui-banner--neutral { + background: var(--color-bg-subtle); + color: var(--color-text-2); + border-color: var(--color-border); +} + +.sui-banner__icon { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; +} + +.sui-banner--info .sui-banner__icon { + color: var(--color-blue); +} +.sui-banner--success .sui-banner__icon { + color: var(--color-green); +} +.sui-banner--warning .sui-banner__icon { + color: var(--color-amber-dark); +} +.sui-banner--danger .sui-banner__icon { + color: var(--color-red); +} + +.sui-banner__body { + flex: 1 1 auto; + min-width: 0; +} +.sui-banner__title { + font-weight: 600; + color: var(--color-text-1); +} +.sui-banner__desc { + color: var(--color-text-3); + margin-top: 0.125rem; +} + +.sui-banner__action { + flex: 0 0 auto; + display: flex; + align-items: center; +} + +.sui-banner__close { + flex: 0 0 auto; + width: 1.5rem; + height: 1.5rem; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); + color: var(--color-text-4); + font-size: 1rem; + line-height: 1; +} +.sui-banner__close:hover { + background: rgba(0, 0, 0, 0.06); + color: var(--color-text-1); +} diff --git a/frontend/shared/components/Banner.stories.tsx b/frontend/shared/components/Banner.stories.tsx new file mode 100644 index 000000000..55ea4ac16 --- /dev/null +++ b/frontend/shared/components/Banner.stories.tsx @@ -0,0 +1,70 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Banner } from "@shared/components/Banner"; +import { Button } from "@shared/components/Button"; + +const meta: Meta = { + title: "Primitives/Banner", + component: Banner, + tags: ["autodocs"], + parameters: { layout: "padded" }, + args: { + tone: "info", + title: "Heads up", + description: "Storage quota approaching 80%.", + }, + argTypes: { + tone: { + control: "inline-radio", + options: ["info", "success", "warning", "danger", "neutral"], + }, + onDismiss: { action: "dismissed" }, + }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +/** Flip tone / title / description / action / onDismiss in controls. */ +export const Playground: Story = {}; + +export const WithAction: Story = { + args: { + tone: "warning", + title: "Approaching cap", + description: "389k of 500k docs processed.", + action: ( + + ), + }, +}; + +export const ToneMatrix: Story = { + render: () => ( +
+ + + + +
+ ), +}; diff --git a/frontend/shared/components/Banner.tsx b/frontend/shared/components/Banner.tsx new file mode 100644 index 000000000..f419ebcc2 --- /dev/null +++ b/frontend/shared/components/Banner.tsx @@ -0,0 +1,65 @@ +import type { ReactNode } from "react"; +import "@shared/components/Banner.css"; + +export type BannerTone = "info" | "success" | "warning" | "danger" | "neutral"; + +export interface BannerProps { + tone?: BannerTone; + title?: ReactNode; + description?: ReactNode; + /** Right-aligned action — typically a button. */ + action?: ReactNode; + /** When set, shows an × button that calls this handler. */ + onDismiss?: () => void; + /** Optional leading icon (caller supplies — keeps the primitive icon-set-agnostic). */ + icon?: ReactNode; + className?: string; + children?: ReactNode; +} + +/** + * Inline alert. Use `tone` to convey severity; pair with `action` for + * "Approaching cap → Upgrade" style flows. Use `Toast` (separate primitive) + * for transient/dismissible notifications layered over the UI. + */ +export function Banner({ + tone = "info", + title, + description, + action, + onDismiss, + icon, + className, + children, +}: BannerProps) { + return ( +
+ {icon && ( +
+ {icon} +
+ )} +
+ {title &&
{title}
} + {description &&
{description}
} + {children} +
+ {action &&
{action}
} + {onDismiss && ( + + )} +
+ ); +} diff --git a/frontend/shared/components/Button.css b/frontend/shared/components/Button.css new file mode 100644 index 000000000..8b474e7f4 --- /dev/null +++ b/frontend/shared/components/Button.css @@ -0,0 +1,118 @@ +.sui-btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + border: 1px solid transparent; + border-radius: var(--radius-md); + font-family: var(--font-sans); + font-weight: 500; + cursor: pointer; + transition: + transform var(--motion-fast), + box-shadow var(--motion-fast), + background var(--motion-fast), + color var(--motion-fast), + border-color var(--motion-fast); + white-space: nowrap; + user-select: none; +} +.sui-btn:disabled { + cursor: not-allowed; + opacity: 0.55; +} +.sui-btn:focus-visible { + outline: 2px solid var(--color-blue); + outline-offset: 2px; +} + +/* sizes */ +.sui-btn--md { + font-size: 0.8125rem; + padding: 0.4375rem 0.875rem; + min-height: 2rem; +} +.sui-btn--sm { + font-size: 0.75rem; + padding: 0.3125rem 0.625rem; + min-height: 1.625rem; + border-radius: var(--radius-sm); +} + +.sui-btn--full { + width: 100%; + justify-content: center; +} + +/* gradient (primary) */ +.sui-btn--gradient { + color: var(--color-text-on-accent); + background: var(--grad-blue-btn); + box-shadow: var(--shadow-blue); +} +.sui-btn--gradient.sui-btn--purple { + background: var(--grad-purple-btn); +} +.sui-btn--gradient.sui-btn--green { + background: var(--grad-green-btn); +} +.sui-btn--gradient:hover:not(:disabled) { + box-shadow: var(--shadow-blue-hover); + transform: translateY(-0.0625rem); +} +.sui-btn--gradient:active:not(:disabled) { + transform: translateY(0); +} + +/* outline (secondary) */ +.sui-btn--outline { + color: var(--color-text-2); + background: var(--color-surface); + border-color: var(--color-border); +} +.sui-btn--outline.sui-btn--blue { + color: var(--color-blue); + border-color: var(--color-blue-border); +} +.sui-btn--outline.sui-btn--purple { + color: var(--color-purple); + border-color: var(--color-purple-border); +} +.sui-btn--outline.sui-btn--green { + color: var(--color-green); + border-color: var(--color-green-border); +} +.sui-btn--outline.sui-btn--amber { + color: var(--color-amber); + border-color: var(--color-amber-border); +} +.sui-btn--outline.sui-btn--red { + color: var(--color-red); + border-color: var(--color-red-border); +} +.sui-btn--outline:hover:not(:disabled) { + background: var(--color-bg-hover); + border-color: var(--color-border-hover); +} + +/* ghost (tertiary) */ +.sui-btn--ghost { + color: var(--color-text-3); + background: transparent; +} +.sui-btn--ghost:hover:not(:disabled) { + color: var(--color-text-1); + background: var(--color-bg-hover); +} + +/* spinner */ +.sui-btn__spinner { + width: 0.875rem; + height: 0.875rem; + border-radius: 50%; + border: 2px solid currentColor; + border-right-color: transparent; + animation: spin 0.7s linear infinite; +} +.sui-btn__label { + display: inline-block; +} diff --git a/frontend/shared/components/Button.stories.tsx b/frontend/shared/components/Button.stories.tsx new file mode 100644 index 000000000..e2c3189d5 --- /dev/null +++ b/frontend/shared/components/Button.stories.tsx @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Button } from "@shared/components/Button"; + +const meta: Meta = { + title: "Primitives/Button", + component: Button, + parameters: { layout: "centered" }, + args: { children: "Connect agent" }, + argTypes: { + variant: { + control: "inline-radio", + options: ["gradient", "outline", "ghost"], + }, + accent: { + control: "inline-radio", + options: ["blue", "purple", "green", "amber", "red"], + }, + size: { control: "inline-radio", options: ["sm", "md"] }, + }, +}; +export default meta; +type Story = StoryObj; + +export const Gradient: Story = { args: { variant: "gradient" } }; +export const Outline: Story = { args: { variant: "outline" } }; +export const Ghost: Story = { args: { variant: "ghost" } }; + +export const WithTrailingArrow: Story = { + args: { + variant: "gradient", + children: "Build a pipeline", + trailingIcon: , + }, +}; + +export const Loading: Story = { args: { variant: "gradient", loading: true } }; +export const Disabled: Story = { + args: { variant: "gradient", disabled: true }, +}; + +export const AccentMatrix: Story = { + render: () => ( +
+ {(["gradient", "outline"] as const).flatMap((variant) => + (["blue", "purple", "green", "amber", "red"] as const).map((accent) => ( + + )), + )} +
+ ), +}; diff --git a/frontend/shared/components/Button.tsx b/frontend/shared/components/Button.tsx new file mode 100644 index 000000000..0d978ea19 --- /dev/null +++ b/frontend/shared/components/Button.tsx @@ -0,0 +1,68 @@ +import type { ButtonHTMLAttributes, ReactNode } from "react"; +import "@shared/components/Button.css"; + +export type ButtonVariant = "gradient" | "outline" | "ghost"; +export type ButtonSize = "sm" | "md"; +export type ButtonAccent = "blue" | "purple" | "green" | "amber" | "red"; + +export interface ButtonProps extends ButtonHTMLAttributes { + /** Visual variant. `gradient` is the primary CTA; `outline` is secondary; `ghost` is tertiary/text. */ + variant?: ButtonVariant; + /** Optional accent colour. Currently affects `gradient` and `outline` variants. */ + accent?: ButtonAccent; + size?: ButtonSize; + /** Icon node rendered before the label. */ + leadingIcon?: ReactNode; + /** Icon node rendered after the label (arrow → for "next" CTAs). */ + trailingIcon?: ReactNode; + /** Show a spinner and disable interactivity. */ + loading?: boolean; + /** Stretches to fill its parent container. */ + fullWidth?: boolean; + children?: ReactNode; +} + +/** + * Stirling's two-button system: a gradient primary CTA and an outlined + * secondary action. Ghost is reserved for tertiary links inside a row. + * + * Buttons compose with the existing CSS variables — they re-skin cleanly in + * dark mode without per-variant overrides. + */ +export function Button({ + variant = "gradient", + accent = "blue", + size = "md", + leadingIcon, + trailingIcon, + loading = false, + fullWidth = false, + disabled, + className, + children, + ...rest +}: ButtonProps) { + const classes = [ + "sui-btn", + `sui-btn--${variant}`, + `sui-btn--${accent}`, + `sui-btn--${size}`, + fullWidth ? "sui-btn--full" : "", + loading ? "sui-btn--loading" : "", + className ?? "", + ] + .filter(Boolean) + .join(" "); + + return ( + + ); +} diff --git a/frontend/shared/components/Card.css b/frontend/shared/components/Card.css new file mode 100644 index 000000000..f38e2adac --- /dev/null +++ b/frontend/shared/components/Card.css @@ -0,0 +1,62 @@ +.sui-card { + position: relative; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); + transition: + box-shadow var(--motion-fast), + border-color var(--motion-fast), + transform var(--motion-fast); +} +.sui-card--pad-none { + padding: 0; + overflow: hidden; +} +.sui-card--pad-tight { + padding: 0.75rem; +} +.sui-card--pad-default { + padding: 1.125rem 1.25rem; +} +.sui-card--pad-loose { + padding: 1.5rem 1.75rem; +} + +.sui-card--interactive { + cursor: pointer; +} +.sui-card--interactive:hover { + border-color: var(--color-border-hover); + box-shadow: var(--shadow-lg); + transform: translateY(-0.0625rem); +} + +.sui-card--accent-blue::before, +.sui-card--accent-purple::before, +.sui-card--accent-green::before, +.sui-card--accent-amber::before, +.sui-card--accent-red::before { + content: ""; + position: absolute; + top: 0; + left: 0; + bottom: 0; + width: 0.25rem; + border-radius: var(--radius-lg) 0 0 var(--radius-lg); +} +.sui-card--accent-blue::before { + background: var(--color-blue); +} +.sui-card--accent-purple::before { + background: var(--color-purple); +} +.sui-card--accent-green::before { + background: var(--color-green); +} +.sui-card--accent-amber::before { + background: var(--color-amber); +} +.sui-card--accent-red::before { + background: var(--color-red); +} diff --git a/frontend/shared/components/Card.stories.tsx b/frontend/shared/components/Card.stories.tsx new file mode 100644 index 000000000..10b31b978 --- /dev/null +++ b/frontend/shared/components/Card.stories.tsx @@ -0,0 +1,189 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Card } from "@shared/components/Card"; +import { Button } from "@shared/components/Button"; +import { MetricCard } from "@shared/components/MetricCard"; +import { StatusBadge } from "@shared/components/StatusBadge"; + +function CardBody({ color }: { color: string }) { + return ( +
+

+ {color} card +

+

+ Surface treatment with the {color} accent strip. +

+
+ ); +} + +const meta: Meta = { + title: "Primitives/Card", + component: Card, + tags: ["autodocs"], + parameters: { layout: "padded" }, + args: { + accent: undefined, + padding: "default", + interactive: false, + }, + argTypes: { + accent: { + control: "inline-radio", + options: [undefined, "blue", "purple", "green", "amber", "red"], + }, + padding: { + control: "inline-radio", + options: ["tight", "default", "loose"], + }, + interactive: { control: "boolean" }, + }, + decorators: [ + (S) => ( +
+ +
+ ), + ], + render: (args) => ( + + + + ), +}; +export default meta; +type Story = StoryObj; + +/** Flip accent / padding / interactive in controls. */ +export const Playground: Story = {}; + +export const AccentMatrix: Story = { + decorators: [ + (S) => ( +
+ +
+ ), + ], + render: () => ( +
+ {(["blue", "purple", "green", "amber", "red", undefined] as const).map( + (accent) => ( + + + + ), + )} +
+ ), +}; + +export const InContext_ProductGrid: Story = { + decorators: [ + (S) => ( +
+ +
+ ), + ], + render: () => ( +
+ +

Sources

+

+ Attach pipelines where PDFs already live. +

+ +
+ +
+

Pipelines

+ + Hero + +
+

+ Compose document workflows from typed operations. +

+ +
+ +

Agents

+

+ Wire your agent via MCP, REST, or tool definitions. +

+ +
+
+ ), +}; + +export const InContext_MetricsInsideCard: Story = { + args: { padding: "loose" }, + render: (args) => ( + +

Last 24 hours

+
+ + + + +
+
+ ), +}; diff --git a/frontend/shared/components/Card.tsx b/frontend/shared/components/Card.tsx new file mode 100644 index 000000000..2afd2ddc7 --- /dev/null +++ b/frontend/shared/components/Card.tsx @@ -0,0 +1,47 @@ +import type { HTMLAttributes, ReactNode } from "react"; +import "@shared/components/Card.css"; + +export interface CardProps extends HTMLAttributes { + /** Adds an accent strip on the left edge of the card. */ + accent?: "blue" | "purple" | "green" | "amber" | "red"; + /** + * Padding profile. `tight` = 0.75rem, `default` = 1.125rem, `loose` = 1.5rem. + * `none` removes padding so the card surface can host edge-to-edge content + * (e.g. a list with row dividers). + */ + padding?: "none" | "tight" | "default" | "loose"; + /** Use the lifted surface treatment (taller shadow, hover affordance). */ + interactive?: boolean; + children?: ReactNode; +} + +/** + * Generic surface — the prototype's cardStyle / cardStyleSm helper, lifted + * into a primitive. Any list item, panel, or detail card that needs the + * standard surface treatment composes from here. + */ +export function Card({ + accent, + padding = "default", + interactive = false, + className, + children, + ...rest +}: CardProps) { + return ( +
+ {children} +
+ ); +} diff --git a/frontend/shared/components/Checkbox.css b/frontend/shared/components/Checkbox.css new file mode 100644 index 000000000..a9f8aeb4e --- /dev/null +++ b/frontend/shared/components/Checkbox.css @@ -0,0 +1,89 @@ +.sui-check { + display: inline-flex; + align-items: flex-start; + gap: var(--space-2); + cursor: pointer; + font-size: 0.8125rem; + color: var(--color-text-2); +} + +.sui-check--disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.sui-check__input { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} + +.sui-check__box { + flex: 0 0 auto; + width: 1rem; + height: 1rem; + margin-top: 0.0625rem; + border-radius: var(--radius-xs); + border: 1.5px solid var(--color-border-hover); + background: var(--color-surface); + display: inline-flex; + align-items: center; + justify-content: center; + transition: + background var(--motion-fast), + border-color var(--motion-fast); + position: relative; +} + +.sui-check__input:focus-visible + .sui-check__box { + outline: 2px solid var(--color-blue); + outline-offset: 2px; +} + +.sui-check__input:checked + .sui-check__box { + background: var(--color-blue); + border-color: var(--color-blue); + color: var(--color-text-on-accent); +} + +.sui-check__tick { + opacity: 0; +} +.sui-check__input:checked + .sui-check__box .sui-check__tick { + opacity: 1; +} + +.sui-check--mixed .sui-check__box { + background: var(--color-blue); + border-color: var(--color-blue); +} +.sui-check--mixed .sui-check__tick { + opacity: 0; +} +.sui-check--mixed .sui-check__dash { + position: absolute; + width: 0.5rem; + height: 2px; + background: var(--color-text-on-accent); + border-radius: 1px; +} +.sui-check__dash { + display: none; +} +.sui-check--mixed .sui-check__dash { + display: block; +} + +.sui-check__text { + display: flex; + flex-direction: column; +} +.sui-check__label { + color: var(--color-text-1); + font-weight: 500; +} +.sui-check__desc { + font-size: 0.75rem; + color: var(--color-text-4); +} diff --git a/frontend/shared/components/Checkbox.tsx b/frontend/shared/components/Checkbox.tsx new file mode 100644 index 000000000..245242046 --- /dev/null +++ b/frontend/shared/components/Checkbox.tsx @@ -0,0 +1,71 @@ +import { forwardRef, type InputHTMLAttributes, type ReactNode } from "react"; +import "@shared/components/Checkbox.css"; + +export interface CheckboxProps extends Omit< + InputHTMLAttributes, + "type" | "size" +> { + label?: ReactNode; + description?: ReactNode; + /** Tri-state checkbox visual (still focusable + submittable, but renders the dash). */ + indeterminate?: boolean; +} + +export const Checkbox = forwardRef( + function Checkbox( + { label, description, indeterminate, className, id, ...rest }, + ref, + ) { + return ( + + ); + }, +); diff --git a/frontend/shared/components/Chip.css b/frontend/shared/components/Chip.css new file mode 100644 index 000000000..78c18800e --- /dev/null +++ b/frontend/shared/components/Chip.css @@ -0,0 +1,96 @@ +.sui-chip { + display: inline-flex; + align-items: center; + gap: var(--space-1_5); + border-radius: var(--radius-pill); + font-family: var(--font-sans); + font-weight: 500; + white-space: nowrap; + border: 1px solid transparent; + transition: + background var(--motion-fast), + border-color var(--motion-fast), + transform var(--motion-fast); +} + +.sui-chip--sm { + padding: var(--space-0_5) var(--space-2); + font-size: 0.6875rem; +} +.sui-chip--md { + padding: var(--space-1) var(--space-2_5, 0.5rem); + font-size: 0.75rem; +} +.sui-chip--md { + padding: var(--space-1) 0.625rem; + font-size: 0.75rem; +} + +.sui-chip--neutral { + background: var(--color-bg-muted); + color: var(--color-text-2); + border-color: var(--color-border); +} +.sui-chip--blue { + background: var(--color-blue-light); + color: var(--color-blue); + border-color: var(--color-blue-border); +} +.sui-chip--purple { + background: var(--color-purple-light); + color: var(--color-purple); + border-color: var(--color-purple-border); +} +.sui-chip--green { + background: var(--color-green-light); + color: var(--color-green); + border-color: var(--color-green-border); +} +.sui-chip--amber { + background: var(--color-amber-light); + color: var(--color-amber-dark); + border-color: var(--color-amber-border); +} +.sui-chip--red { + background: var(--color-red-light); + color: var(--color-red); + border-color: var(--color-red-border); +} + +.sui-chip--interactive { + cursor: pointer; +} +.sui-chip--interactive:hover { + transform: translateY(-0.0625rem); + filter: brightness(1.05); +} + +.sui-chip__dot { + width: 0.4375rem; + height: 0.4375rem; + border-radius: 50%; + background: currentColor; + opacity: 0.85; +} + +.sui-chip__icon { + display: inline-flex; + line-height: 1; +} + +.sui-chip__remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + border-radius: 50%; + color: inherit; + opacity: 0.6; + font-size: 0.875rem; + line-height: 1; +} +.sui-chip__remove:hover { + opacity: 1; + background: rgba(0, 0, 0, 0.08); +} diff --git a/frontend/shared/components/Chip.stories.tsx b/frontend/shared/components/Chip.stories.tsx new file mode 100644 index 000000000..c92b21344 --- /dev/null +++ b/frontend/shared/components/Chip.stories.tsx @@ -0,0 +1,79 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Chip } from "@shared/components/Chip"; + +const meta: Meta = { + title: "Primitives/Chip", + component: Chip, + tags: ["autodocs"], + parameters: { layout: "centered" }, + args: { + children: "us-east-1", + tone: "neutral", + size: "md", + showDot: false, + }, + argTypes: { + tone: { + control: "inline-radio", + options: ["neutral", "blue", "purple", "green", "amber", "red"], + }, + size: { control: "inline-radio", options: ["sm", "md"] }, + showDot: { control: "boolean" }, + onClick: { action: "clicked" }, + onRemove: { action: "removed" }, + }, +}; +export default meta; +type Story = StoryObj; + +/** Flip tone / size / dot / interactive / removable in controls. */ +export const Playground: Story = {}; + +export const ToneRow: Story = { + render: () => ( +
+ {(["neutral", "blue", "purple", "green", "amber", "red"] as const).map( + (t) => ( + + {t} + + ), + )} +
+ ), +}; + +export const InContext_OpChain: Story = { + render: () => ( +
+ + ocr + + + classify + + + extract + + + validate + + + redact + + + encrypt-rest + + + store-primary + +
+ ), +}; diff --git a/frontend/shared/components/Chip.tsx b/frontend/shared/components/Chip.tsx new file mode 100644 index 000000000..3cc670a2a --- /dev/null +++ b/frontend/shared/components/Chip.tsx @@ -0,0 +1,88 @@ +import type { ReactNode } from "react"; +import "@shared/components/Chip.css"; + +export type ChipTone = + | "neutral" + | "blue" + | "purple" + | "green" + | "amber" + | "red"; + +export type ChipSize = "sm" | "md"; + +export interface ChipProps { + tone?: ChipTone; + size?: ChipSize; + leadingIcon?: ReactNode; + trailingIcon?: ReactNode; + /** Show a `×` button. Calls `onRemove` when clicked. */ + onRemove?: () => void; + /** Renders as a button when set. */ + onClick?: () => void; + /** Show the leading dot affordance. Defaults to false (set true for status-style chips). */ + showDot?: boolean; + children?: ReactNode; + className?: string; +} + +/** + * Generic chip / tag — `StatusBadge` has a fixed taxonomy (success/warning/…), + * `MethodBadge` is HTTP-method-only; this is the open-ended one for tag rows + * (selected ops, document regions, kbd hints, sort chips, etc). + */ +export function Chip({ + tone = "neutral", + size = "md", + leadingIcon, + trailingIcon, + onRemove, + onClick, + showDot, + children, + className, +}: ChipProps) { + const Tag = onClick ? "button" : "span"; + const classes = [ + "sui-chip", + `sui-chip--${tone}`, + `sui-chip--${size}`, + onClick ? "sui-chip--interactive" : "", + className ?? "", + ] + .filter(Boolean) + .join(" "); + return ( + + {showDot && } + {leadingIcon && ( + + {leadingIcon} + + )} + {children} + {trailingIcon && !onRemove && ( + + {trailingIcon} + + )} + {onRemove && ( + + )} + + ); +} diff --git a/frontend/shared/components/CodeBlock.css b/frontend/shared/components/CodeBlock.css new file mode 100644 index 000000000..b74a03c67 --- /dev/null +++ b/frontend/shared/components/CodeBlock.css @@ -0,0 +1,69 @@ +.sui-code { + background: var(--code-bg); + color: var(--code-text); + border-radius: var(--radius-lg); + overflow: hidden; + border: 1px solid var(--code-border); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); +} +.sui-code__chrome { + display: flex; + align-items: center; + gap: 0.625rem; + background: var(--code-bg-header); + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--code-border); + font-size: 0.6875rem; + color: var(--code-muted); +} +.sui-code__dots { + display: inline-flex; + gap: 0.375rem; + align-items: center; +} +.sui-code__dots > span { + width: 0.625rem; + height: 0.625rem; + border-radius: 50%; + background: var(--code-dot); +} +.sui-code__caption { + color: var(--code-muted); + margin-left: 0.25rem; + font-family: var(--font-mono); +} +.sui-code__lang { + margin-left: auto; + font-family: var(--font-mono); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--code-dim); +} +.sui-code__copy { + background: transparent; + color: var(--code-text); + border: 1px solid var(--code-border); + border-radius: var(--radius-sm); + padding: 0.125rem 0.5rem; + font-size: 0.6875rem; + cursor: pointer; + font-family: var(--font-sans); + transition: background var(--motion-fast); +} +.sui-code__copy:hover { + background: rgba(255, 255, 255, 0.05); +} +.sui-code__pre { + margin: 0; + padding: 0.875rem 1rem; + font-family: var(--font-mono); + font-size: 0.78125rem; + line-height: 1.55; + overflow: auto; + white-space: pre; +} +.sui-code__pre code { + white-space: inherit; + color: inherit; + font-family: inherit; +} diff --git a/frontend/shared/components/CodeBlock.stories.tsx b/frontend/shared/components/CodeBlock.stories.tsx new file mode 100644 index 000000000..cad00e08b --- /dev/null +++ b/frontend/shared/components/CodeBlock.stories.tsx @@ -0,0 +1,85 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { CodeBlock } from "@shared/components/CodeBlock"; + +const CURL_EXAMPLE = `curl https://api.stirling.com/v1/coi \\ + -H "Authorization: Bearer sk_live_a3f8..." \\ + -F "file=@certificate.pdf"`; + +const JSON_RESULT = `{ + "schema": "coi.v2", + "fields": { + "carrier": "Travelers Casualty", + "policy_number": "PHB-1108-2025", + "gl_limit": 1000000, + "umbrella_limit": 5000000, + "effective": "2026-01-15", + "expiry": "2027-01-15" + }, + "confidence_avg": 0.96 +}`; + +const PYTHON_EXAMPLE = `import stirling + +client = stirling.Client(api_key="sk_live_a3f8...") +result = client.extract(file="certificate.pdf", schema="coi.v2") +print(result.fields)`; + +const meta: Meta = { + title: "Primitives/CodeBlock", + component: CodeBlock, + tags: ["autodocs"], + parameters: { layout: "padded" }, + args: { code: CURL_EXAMPLE, lang: "curl", copyable: true, maxHeight: 400 }, + argTypes: { + lang: { + control: "inline-radio", + options: [ + "json", + "javascript", + "typescript", + "python", + "bash", + "curl", + "http", + "plain", + ], + }, + copyable: { control: "boolean" }, + maxHeight: { control: "number" }, + code: { control: "text" }, + }, +}; +export default meta; +type Story = StoryObj; + +/** Flip lang / copyable / maxHeight / code in controls. */ +export const Playground: Story = {}; + +export const LongScrolling: Story = { + args: { + code: Array.from( + { length: 40 }, + (_, i) => `line ${i + 1}: const x = ${i};`, + ).join("\n"), + lang: "javascript", + maxHeight: 240, + }, +}; + +export const InContext_TwoUpComparison: Story = { + render: () => ( +
+ + +
+ ), +}; + +export const InContext_Quickstart: Story = { + render: () => ( +
+ + +
+ ), +}; diff --git a/frontend/shared/components/CodeBlock.tsx b/frontend/shared/components/CodeBlock.tsx new file mode 100644 index 000000000..c3952a3f8 --- /dev/null +++ b/frontend/shared/components/CodeBlock.tsx @@ -0,0 +1,82 @@ +import { useState, type ReactNode } from "react"; +import "@shared/components/CodeBlock.css"; + +export type CodeLang = + | "json" + | "javascript" + | "typescript" + | "python" + | "bash" + | "curl" + | "http" + | "plain"; + +export interface CodeBlockProps { + /** The code content. */ + code: string; + /** Language label shown in the chrome bar. Highlight wiring is out of scope here — bring Shiki/Prism. */ + lang?: CodeLang; + /** Optional caption text shown in the chrome bar (e.g. a file path). */ + caption?: ReactNode; + /** Show a copy-to-clipboard button. Defaults to true. */ + copyable?: boolean; + /** Max height in pixels; longer content scrolls. */ + maxHeight?: number; + className?: string; +} + +/** + * Always-dark code block, matched to the prototype's CODE palette. + * + * Highlighting is intentionally not wired here — drop in Shiki at the call + * site and feed pre-highlighted HTML through `dangerouslySetInnerHTML` on a + * fork of this component if you need it. For most surfaces the raw + * monospaced text plus copy button is sufficient (and ~80% lighter). + */ +export function CodeBlock({ + code, + lang = "plain", + caption, + copyable = true, + maxHeight = 400, + className, +}: CodeBlockProps) { + const [copied, setCopied] = useState(false); + + async function copy() { + try { + await navigator.clipboard.writeText(code); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + // Older browsers / non-secure contexts — silently fall through. + } + } + + return ( +
+
+ + + + + + {caption && {caption}} + {lang} + {copyable && ( + + )} +
+
+        {code}
+      
+
+ ); +} diff --git a/frontend/shared/components/Drawer.css b/frontend/shared/components/Drawer.css new file mode 100644 index 000000000..d2b90f26a --- /dev/null +++ b/frontend/shared/components/Drawer.css @@ -0,0 +1,110 @@ +.sui-drawer__backdrop { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.32); + z-index: var(--z-drawer); + animation: fadeIn 0.18s ease both; +} + +.sui-drawer { + position: fixed; + top: 0; + bottom: 0; + max-width: 100vw; + background: var(--color-surface); + display: flex; + flex-direction: column; + z-index: calc(var(--z-drawer) + 1); + animation: slideInRight 0.22s cubic-bezier(0.4, 0, 0.2, 1) both; +} + +.sui-drawer--right { + right: 0; + border-left: 1px solid var(--color-border); + box-shadow: -4px 0 24px rgba(15, 23, 42, 0.12); +} + +.sui-drawer--left { + left: 0; + border-right: 1px solid var(--color-border); + box-shadow: 4px 0 24px rgba(15, 23, 42, 0.12); + animation-name: slideInLeft; +} + +@keyframes slideInLeft { + from { + transform: translateX(-100%); + } + to { + transform: translateX(0); + } +} + +.sui-drawer--sm { + width: 22rem; +} +.sui-drawer--md { + width: 27.5rem; +} +.sui-drawer--lg { + width: 36rem; +} + +.sui-drawer__header { + display: flex; + align-items: flex-start; + gap: var(--space-3); + padding: var(--space-4) var(--space-4) var(--space-3); + border-bottom: 1px solid var(--color-border-light); +} + +.sui-drawer__header-text { + flex: 1 1 auto; + min-width: 0; +} + +.sui-drawer__title { + font-size: 1rem; + font-weight: 600; + color: var(--color-text-1); +} + +.sui-drawer__sub { + margin-top: 0.125rem; + font-size: 0.8125rem; + color: var(--color-text-4); +} + +.sui-drawer__close { + flex: 0 0 auto; + width: 1.75rem; + height: 1.75rem; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); + color: var(--color-text-4); + transition: + background var(--motion-fast), + color var(--motion-fast); +} +.sui-drawer__close:hover { + background: var(--color-bg-hover); + color: var(--color-text-1); +} + +.sui-drawer__body { + flex: 1 1 auto; + overflow-y: auto; + padding: var(--space-3) var(--space-4) var(--space-6); +} + +.sui-drawer__footer { + padding: var(--space-3) var(--space-4); + border-top: 1px solid var(--color-border-light); + background: var(--color-bg-subtle); + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--space-2); +} diff --git a/frontend/shared/components/Drawer.stories.tsx b/frontend/shared/components/Drawer.stories.tsx new file mode 100644 index 000000000..1f218ed19 --- /dev/null +++ b/frontend/shared/components/Drawer.stories.tsx @@ -0,0 +1,91 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Drawer } from "@shared/components/Drawer"; +import { Button } from "@shared/components/Button"; + +const meta: Meta = { + title: "Primitives/Drawer", + component: Drawer, + parameters: { layout: "fullscreen" }, + tags: ["autodocs"], + args: { + side: "right", + width: "md", + title: "Pipeline detail", + subtitle: "COI Compliance · us-east-1", + }, + argTypes: { + side: { control: "inline-radio", options: ["left", "right"] }, + width: { control: "inline-radio", options: ["sm", "md", "lg"] }, + }, + decorators: [ + (S) => ( +
+ +
+ ), + ], + render: (args) => { + function Bound() { + const [open, setOpen] = useState(true); + return ( + <> + + setOpen(false)}> +

+ The drawer body scrolls when its content overflows. The header and + footer (when present) are sticky. +

+
+ + ); + } + return ; + }, +}; +export default meta; +type Story = StoryObj; + +/** Flip side / width / title / subtitle in controls. */ +export const Playground: Story = {}; + +export const WithFooter: Story = { + render: () => { + function Bound() { + const [open, setOpen] = useState(true); + return ( + <> + + setOpen(false)} + side="right" + width="md" + title="Pipeline detail" + subtitle="COI Compliance · us-east-1" + footer={ + <> + + + + + } + > +

+ Sticky footer demo — scroll the body, footer stays anchored. +

+
+ + ); + } + return ; + }, +}; diff --git a/frontend/shared/components/Drawer.tsx b/frontend/shared/components/Drawer.tsx new file mode 100644 index 000000000..add63fc20 --- /dev/null +++ b/frontend/shared/components/Drawer.tsx @@ -0,0 +1,171 @@ +import { useEffect, useId, useRef, type ReactNode } from "react"; +import { createPortal } from "react-dom"; +import "@shared/components/Drawer.css"; + +const FOCUSABLE_SELECTOR = + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +export type DrawerSide = "right" | "left"; +export type DrawerWidth = "sm" | "md" | "lg"; + +export interface DrawerProps { + open: boolean; + onClose: () => void; + side?: DrawerSide; + /** Width preset. sm=22rem, md=27.5rem, lg=36rem. */ + width?: DrawerWidth; + title?: ReactNode; + subtitle?: ReactNode; + /** Sticky footer slot below the body. */ + footer?: ReactNode; + disableBackdropClose?: boolean; + disableEscapeClose?: boolean; + className?: string; + ariaLabel?: string; + children?: ReactNode; +} + +/** + * Side drawer — sibling to {@link Modal}. Slides in from `side`, locks body + * scroll, closes on backdrop click or Escape. PipelineDetailDrawer, doc + * detail drawers, etc. should all sit on top of this primitive. + */ +export function Drawer({ + open, + onClose, + side = "right", + width = "md", + title, + subtitle, + footer, + disableBackdropClose = false, + disableEscapeClose = false, + className, + ariaLabel, + children, +}: DrawerProps) { + const dialogRef = useRef(null); + const titleId = useId(); + + useEffect(() => { + if (!open || disableEscapeClose) return; + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [open, disableEscapeClose, onClose]); + + useEffect(() => { + if (!open) return; + const previous = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = previous; + }; + }, [open]); + + useEffect(() => { + if (!open) return; + const previouslyFocused = document.activeElement as HTMLElement | null; + const dialog = dialogRef.current; + const first = dialog?.querySelector(FOCUSABLE_SELECTOR); + first?.focus(); + + function onKey(e: KeyboardEvent) { + if (e.key !== "Tab" || !dialog) return; + const focusables = + dialog.querySelectorAll(FOCUSABLE_SELECTOR); + if (focusables.length === 0) { + e.preventDefault(); + return; + } + const firstEl = focusables[0]; + const lastEl = focusables[focusables.length - 1]; + const active = document.activeElement; + if (e.shiftKey && active === firstEl) { + e.preventDefault(); + lastEl.focus(); + } else if (!e.shiftKey && active === lastEl) { + e.preventDefault(); + firstEl.focus(); + } + } + + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("keydown", onKey); + previouslyFocused?.focus?.(); + }; + }, [open]); + + if (!open) return null; + + function onBackdropClick() { + if (!disableBackdropClose) onClose(); + } + + const hasTitle = title !== undefined && title !== null; + + return createPortal( + <> +
+ + , + document.body, + ); +} diff --git a/frontend/shared/components/Dropdown.css b/frontend/shared/components/Dropdown.css new file mode 100644 index 000000000..c96aec167 --- /dev/null +++ b/frontend/shared/components/Dropdown.css @@ -0,0 +1,77 @@ +.sui-dd { + position: relative; + display: inline-flex; +} + +.sui-dd__menu { + position: absolute; + top: calc(100% + var(--space-1)); + min-width: 12rem; + padding: var(--space-1); + background: var(--color-dropdown-bg); + border: 1px solid var(--color-dropdown-border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + z-index: var(--z-dropdown); + animation: fadeInUp var(--motion-enter) both; + display: flex; + flex-direction: column; + gap: 0.0625rem; +} + +.sui-dd__menu--start { + left: 0; +} +.sui-dd__menu--end { + right: 0; +} + +.sui-dd__item { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-1_5) var(--space-2); + width: 100%; + text-align: left; + font-size: 0.8125rem; + color: var(--color-text-2); + border-radius: var(--radius-sm); + transition: + background var(--motion-fast), + color var(--motion-fast); +} + +.sui-dd__item:hover:not(.is-disabled) { + background: var(--color-dropdown-hover); + color: var(--color-text-1); +} + +.sui-dd__item.is-active { + background: var(--color-nav-active); + color: var(--color-nav-active-text); + font-weight: 500; +} + +.sui-dd__item.is-disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.sui-dd__item-leading, +.sui-dd__item-trailing { + display: inline-flex; + align-items: center; +} + +.sui-dd__item-trailing { + margin-left: auto; + font-family: var(--font-mono); + font-size: 0.6875rem; + color: var(--color-text-5); +} + +.sui-dd__divider { + height: 1px; + background: var(--color-divider); + margin: var(--space-1) 0; +} diff --git a/frontend/shared/components/Dropdown.stories.tsx b/frontend/shared/components/Dropdown.stories.tsx new file mode 100644 index 000000000..f86aeb04c --- /dev/null +++ b/frontend/shared/components/Dropdown.stories.tsx @@ -0,0 +1,82 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Dropdown } from "@shared/components/Dropdown"; +import { Button } from "@shared/components/Button"; + +const meta: Meta = { + title: "Primitives/Dropdown", + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Basic: Story = { + render: () => ( + + + + + + console.log("a")}>Item A + console.log("b")}>Item B + console.log("c")} active> + Item C (active) + + + + ), +}; + +export const WithDivider: Story = { + render: () => ( + + + + + + Profile + Workspace settings + + Sign out + + + ), +}; + +export const WithTrailingHints: Story = { + render: () => ( + + + + + + Search + New pipeline + New API key + + Toggle theme + + + + ), +}; + +export const AlignStart: Story = { + render: () => ( + + + + + + One + Two + + + ), +}; diff --git a/frontend/shared/components/Dropdown.tsx b/frontend/shared/components/Dropdown.tsx new file mode 100644 index 000000000..8d47ba9df --- /dev/null +++ b/frontend/shared/components/Dropdown.tsx @@ -0,0 +1,233 @@ +import { + cloneElement, + createContext, + isValidElement, + useCallback, + useContext, + useEffect, + useId, + useMemo, + useRef, + useState, + type ReactElement, + type ReactNode, +} from "react"; +import "@shared/components/Dropdown.css"; + +type Alignment = "start" | "end"; + +interface DropdownContextValue { + open: boolean; + setOpen: (open: boolean) => void; + triggerRef: React.RefObject; + menuId: string; + align: Alignment; +} + +const DropdownContext = createContext(null); + +function useDropdownCtx(): DropdownContextValue { + const ctx = useContext(DropdownContext); + if (!ctx) + throw new Error( + "Dropdown subcomponents must be used inside ", + ); + return ctx; +} + +export interface DropdownRootProps { + /** Controlled open state. Omit for uncontrolled. */ + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + /** Alignment of the menu relative to the trigger. */ + align?: Alignment; + children: ReactNode; + className?: string; +} + +function Root({ + open: openProp, + defaultOpen, + onOpenChange, + align = "end", + children, + className, +}: DropdownRootProps) { + const [uncontrolled, setUncontrolled] = useState(defaultOpen ?? false); + const isControlled = openProp !== undefined; + const open = isControlled ? openProp : uncontrolled; + + const setOpen = useCallback( + (next: boolean) => { + if (!isControlled) setUncontrolled(next); + onOpenChange?.(next); + }, + [isControlled, onOpenChange], + ); + + const triggerRef = useRef(null); + const containerRef = useRef(null); + const menuId = useId(); + + // Click-outside + Escape close. + useEffect(() => { + if (!open) return; + function onDocClick(e: MouseEvent) { + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) + ) { + setOpen(false); + } + } + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") { + setOpen(false); + triggerRef.current?.focus(); + } + } + document.addEventListener("mousedown", onDocClick); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDocClick); + document.removeEventListener("keydown", onKey); + }; + }, [open, setOpen]); + + const value = useMemo( + () => ({ open, setOpen, triggerRef, menuId, align }), + [open, setOpen, menuId, align], + ); + + return ( + +
+ {children} +
+
+ ); +} + +export interface DropdownTriggerProps { + /** A single button-like element. Receives onClick + aria props. */ + children: ReactElement<{ + onClick?: (e: React.MouseEvent) => void; + "aria-haspopup"?: string; + "aria-expanded"?: boolean; + "aria-controls"?: string; + ref?: React.Ref; + }>; +} + +function Trigger({ children }: DropdownTriggerProps) { + const { open, setOpen, triggerRef, menuId } = useDropdownCtx(); + if (!isValidElement(children)) { + throw new Error( + "Dropdown.Trigger requires exactly one React element child", + ); + } + return cloneElement(children, { + ref: triggerRef as React.Ref, + onClick: (e: React.MouseEvent) => { + children.props.onClick?.(e); + setOpen(!open); + }, + "aria-haspopup": "menu", + "aria-expanded": open, + "aria-controls": menuId, + }); +} + +export interface DropdownMenuProps { + children: ReactNode; + className?: string; + /** Optional min-width override (px or CSS length). */ + width?: string | number; +} + +function Menu({ children, className, width }: DropdownMenuProps) { + const { open, menuId, align } = useDropdownCtx(); + if (!open) return null; + const style = + width !== undefined + ? { minWidth: typeof width === "number" ? `${width}px` : width } + : undefined; + return ( + + ); +} + +export interface DropdownItemProps { + onSelect?: () => void; + /** Active visual state (e.g. current value in a switcher). */ + active?: boolean; + disabled?: boolean; + /** Optional leading visual. */ + leading?: ReactNode; + /** Optional trailing visual (kbd hint, badge, etc). */ + trailing?: ReactNode; + children?: ReactNode; + className?: string; +} + +function Item({ + onSelect, + active, + disabled, + leading, + trailing, + children, + className, +}: DropdownItemProps) { + const { setOpen } = useDropdownCtx(); + return ( + + ); +} + +function Divider() { + return
; +} + +export const Dropdown = { + Root, + Trigger, + Menu, + Item, + Divider, +}; diff --git a/frontend/shared/components/EmptyState.css b/frontend/shared/components/EmptyState.css new file mode 100644 index 000000000..e78db8b66 --- /dev/null +++ b/frontend/shared/components/EmptyState.css @@ -0,0 +1,57 @@ +.sui-empty { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: var(--space-6) var(--space-4); + gap: var(--space-2); +} + +.sui-empty--compact { + padding: var(--space-4) var(--space-3); +} + +.sui-empty__icon { + display: inline-flex; + margin-bottom: var(--space-2); + color: var(--color-text-4); +} + +.sui-empty__eyebrow { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-blue); +} + +.sui-empty__title { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + color: var(--color-text-1); +} + +.sui-empty--compact .sui-empty__title { + font-size: 0.9375rem; +} + +.sui-empty__copy { + margin: 0; + max-width: 32rem; + font-size: 0.875rem; + line-height: 1.5; + color: var(--color-text-3); +} + +.sui-empty--compact .sui-empty__copy { + font-size: 0.8125rem; +} + +.sui-empty__actions { + margin-top: var(--space-2); + display: flex; + gap: var(--space-2); + flex-wrap: wrap; + justify-content: center; +} diff --git a/frontend/shared/components/EmptyState.stories.tsx b/frontend/shared/components/EmptyState.stories.tsx new file mode 100644 index 000000000..e3fc1f496 --- /dev/null +++ b/frontend/shared/components/EmptyState.stories.tsx @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { EmptyState } from "@shared/components/EmptyState"; +import { Button } from "@shared/components/Button"; +import { Card } from "@shared/components/Card"; + +const meta: Meta = { + title: "Primitives/EmptyState", + component: EmptyState, + tags: ["autodocs"], + parameters: { layout: "padded" }, + args: { + title: "Nothing here yet", + description: "When pipelines are deployed they'll appear in this list.", + size: "default", + }, + argTypes: { + size: { control: "inline-radio", options: ["default", "compact"] }, + }, +}; +export default meta; +type Story = StoryObj; + +/** Flip title / description / eyebrow / size / actions in controls. */ +export const Playground: Story = {}; + +export const WithCTAs: Story = { + args: { + eyebrow: "No pipelines yet", + title: "Start from a template — or compose from scratch.", + description: + "The fastest way in is forking a pre-bundled pipeline like PII Sweep or Compliance Pack.", + actions: ( + <> + + + + ), + }, +}; + +export const InCard: Story = { + render: () => ( + + + + ), +}; diff --git a/frontend/shared/components/EmptyState.tsx b/frontend/shared/components/EmptyState.tsx new file mode 100644 index 000000000..1981c0a91 --- /dev/null +++ b/frontend/shared/components/EmptyState.tsx @@ -0,0 +1,51 @@ +import type { ReactNode } from "react"; +import "@shared/components/EmptyState.css"; + +export interface EmptyStateProps { + /** Eyebrow text shown above the title (e.g. "No pipelines yet"). */ + eyebrow?: ReactNode; + /** Headline. */ + title: ReactNode; + /** One- or two-line body copy. */ + description?: ReactNode; + /** Optional visual at the top (icon, illustration, etc). */ + icon?: ReactNode; + /** Primary + secondary CTAs. */ + actions?: ReactNode; + /** Visual size. `compact` removes the icon row's padding for inline use. */ + size?: "compact" | "default"; + className?: string; +} + +/** + * Centered "nothing here yet" panel. Replaces the bespoke empty cards inline + * in Pipelines (free tier), NotificationsDropdown (all caught up), and the + * search modal (no matches). + */ +export function EmptyState({ + eyebrow, + title, + description, + icon, + actions, + size = "default", + className, +}: EmptyStateProps) { + return ( +
+ {icon && ( +
+ {icon} +
+ )} + {eyebrow &&
{eyebrow}
} +

{title}

+ {description &&

{description}

} + {actions &&
{actions}
} +
+ ); +} diff --git a/frontend/shared/components/FormField.css b/frontend/shared/components/FormField.css new file mode 100644 index 000000000..0ae531bc6 --- /dev/null +++ b/frontend/shared/components/FormField.css @@ -0,0 +1,33 @@ +.sui-field { + display: flex; + flex-direction: column; + gap: var(--space-1); + min-width: 0; +} + +.sui-field__label { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-section-label); +} + +.sui-field__required { + color: var(--color-red); +} + +.sui-field__control { + display: flex; + flex-direction: column; +} + +.sui-field__help { + font-size: 0.6875rem; + color: var(--color-text-4); + line-height: 1.45; +} + +.sui-field--error .sui-field__help { + color: var(--color-red); +} diff --git a/frontend/shared/components/FormField.tsx b/frontend/shared/components/FormField.tsx new file mode 100644 index 000000000..dc84d2715 --- /dev/null +++ b/frontend/shared/components/FormField.tsx @@ -0,0 +1,79 @@ +import { + cloneElement, + isValidElement, + useId, + type ReactElement, + type ReactNode, +} from "react"; +import "@shared/components/FormField.css"; + +export interface FormFieldProps { + label?: ReactNode; + /** Helper text shown under the control. Replaced by `error` when present. */ + helperText?: ReactNode; + /** Error string. Causes the control + helper region to swap to the error tone. */ + error?: ReactNode; + required?: boolean; + /** A single form control as a React element. Receives id + aria-* props. */ + children: ReactElement<{ + id?: string; + "aria-invalid"?: boolean; + "aria-describedby"?: string; + required?: boolean; + }>; + className?: string; +} + +/** + * Standardised label + control + helper/error wrapper. Works with the bare + * native controls in this design system (Input, Select, Checkbox, Radio, + * Slider) or with any third-party control that accepts `id` + `aria-*`. + */ +export function FormField({ + label, + helperText, + error, + required, + children, + className, +}: FormFieldProps) { + const autoId = useId(); + if (!isValidElement(children)) { + throw new Error("FormField requires exactly one React element child"); + } + const controlId = children.props.id ?? autoId; + const describedById = error || helperText ? `${controlId}-help` : undefined; + + const child = cloneElement(children, { + id: controlId, + "aria-invalid": error ? true : undefined, + "aria-describedby": describedById, + required: required ?? children.props.required, + }); + + return ( +
+ {label && ( + + )} +
{child}
+ {(error || helperText) && ( +
+ {error ?? helperText} +
+ )} +
+ ); +} diff --git a/frontend/shared/components/Forms.stories.tsx b/frontend/shared/components/Forms.stories.tsx new file mode 100644 index 000000000..d38bc3834 --- /dev/null +++ b/frontend/shared/components/Forms.stories.tsx @@ -0,0 +1,303 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FormField } from "@shared/components/FormField"; +import { Input } from "@shared/components/Input"; +import { Select } from "@shared/components/Select"; +import { Checkbox } from "@shared/components/Checkbox"; +import { RadioGroup } from "@shared/components/Radio"; +import { Slider } from "@shared/components/Slider"; +import { Stack } from "@shared/components/Stack"; +import { Inline } from "@shared/components/Inline"; + +// Inline icon to avoid a cross-layer import; shared/ must not depend on portal/. +function SearchIcon({ size = 14 }: { size?: number }) { + return ( + + + + + ); +} + +const meta: Meta = { + title: "Primitives/Forms", + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Input_Default: Story = { + render: () => ( + + + + ), +}; + +export const Input_WithIcon: Story = { + render: () => ( + + } + placeholder="Search Stirling…" + /> + + ), +}; + +export const Input_Error: Story = { + render: () => ( + + {}} /> + + ), +}; + +export const Select_Default: Story = { + render: () => ( + + setName(e.target.value)} + placeholder="e.g. coi-compliance" + /> + + + + {trailingIcon && ( + + {trailingIcon} + + )} + + ); +}); diff --git a/frontend/shared/components/MethodBadge.css b/frontend/shared/components/MethodBadge.css new file mode 100644 index 000000000..75703b92d --- /dev/null +++ b/frontend/shared/components/MethodBadge.css @@ -0,0 +1,36 @@ +.sui-method { + display: inline-block; + font-family: var(--font-mono); + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.04em; + padding: 0.125rem 0.4375rem; + border-radius: var(--radius-sm); + border: 1px solid transparent; + line-height: 1.4; +} +.sui-method--get { + color: var(--color-green); + background: var(--color-green-light); + border-color: var(--color-green-border); +} +.sui-method--post { + color: var(--color-blue); + background: var(--color-blue-light); + border-color: var(--color-blue-border); +} +.sui-method--put { + color: var(--color-amber-dark); + background: var(--color-amber-light); + border-color: var(--color-amber-border); +} +.sui-method--patch { + color: var(--color-purple); + background: var(--color-purple-light); + border-color: var(--color-purple-border); +} +.sui-method--delete { + color: var(--color-red); + background: var(--color-red-light); + border-color: var(--color-red-border); +} diff --git a/frontend/shared/components/MethodBadge.stories.tsx b/frontend/shared/components/MethodBadge.stories.tsx new file mode 100644 index 000000000..698689f86 --- /dev/null +++ b/frontend/shared/components/MethodBadge.stories.tsx @@ -0,0 +1,42 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { MethodBadge, type HttpMethod } from "@shared/components/MethodBadge"; + +const meta: Meta = { + title: "Primitives/MethodBadge", + component: MethodBadge, + parameters: { layout: "centered" }, + args: { method: "POST" }, + argTypes: { + method: { + control: "inline-radio", + options: ["GET", "POST", "PUT", "PATCH", "DELETE"] satisfies HttpMethod[], + }, + }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const InRow: Story = { + render: () => ( +
+ + + /v1/coi + +
+ ), +}; + +export const Matrix: Story = { + render: () => ( +
+ {(["GET", "POST", "PUT", "PATCH", "DELETE"] as const).map((m) => ( + + ))} +
+ ), +}; diff --git a/frontend/shared/components/MethodBadge.tsx b/frontend/shared/components/MethodBadge.tsx new file mode 100644 index 000000000..d64ad3a5e --- /dev/null +++ b/frontend/shared/components/MethodBadge.tsx @@ -0,0 +1,28 @@ +import "@shared/components/MethodBadge.css"; + +export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + +export interface MethodBadgeProps { + method: HttpMethod; + className?: string; +} + +/** + * Method chip used in the docs and the pipeline row to label HTTP verbs. + * Colours match the prototype: GET green, POST blue, PUT amber, DELETE red. + */ +export function MethodBadge({ method, className }: MethodBadgeProps) { + return ( + + {method} + + ); +} diff --git a/frontend/shared/components/MetricCard.css b/frontend/shared/components/MetricCard.css new file mode 100644 index 000000000..03cc4c154 --- /dev/null +++ b/frontend/shared/components/MetricCard.css @@ -0,0 +1,84 @@ +.sui-metric { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 1.125rem 1.25rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); + transition: + box-shadow var(--motion-fast), + transform var(--motion-fast), + border-color var(--motion-fast); + min-width: 10rem; + /* When rendered as + ); + } + + return
{body}
; +} diff --git a/frontend/shared/components/Modal.css b/frontend/shared/components/Modal.css new file mode 100644 index 000000000..235f2f4cc --- /dev/null +++ b/frontend/shared/components/Modal.css @@ -0,0 +1,99 @@ +.sui-modal__backdrop { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.55); + display: flex; + align-items: flex-start; + justify-content: center; + padding: 5rem 1.5rem 1.5rem; + z-index: 100; + animation: fadeIn 0.18s ease both; + overscroll-behavior: contain; +} + +.sui-modal { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-xl); + box-shadow: + 0 1.25rem 3rem rgba(15, 23, 42, 0.35), + 0 0 0 1px var(--color-border-light); + display: flex; + flex-direction: column; + width: 100%; + max-height: calc(100vh - 6.5rem); + overflow: hidden; + animation: scaleIn 0.2s cubic-bezier(0.4, 0, 0.2, 1) both; +} + +.sui-modal--sm { + max-width: 24rem; +} +.sui-modal--md { + max-width: 32rem; +} +.sui-modal--lg { + max-width: 48rem; +} +.sui-modal--xl { + max-width: 64rem; +} + +.sui-modal__header { + display: flex; + align-items: flex-start; + gap: 0.75rem; + padding: 1rem 1.125rem 0.75rem; + border-bottom: 1px solid var(--color-border-light); +} + +.sui-modal__header-text { + flex: 1 1 auto; + min-width: 0; +} + +.sui-modal__title { + font-size: 0.9375rem; + font-weight: 600; + color: var(--color-text-1); +} + +.sui-modal__sub { + margin-top: 0.125rem; + font-size: 0.8125rem; + color: var(--color-text-4); +} + +.sui-modal__close { + flex: 0 0 auto; + width: 1.75rem; + height: 1.75rem; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); + color: var(--color-text-4); + transition: + background var(--motion-fast), + color var(--motion-fast); +} + +.sui-modal__close:hover { + background: var(--color-bg-hover); + color: var(--color-text-1); +} + +.sui-modal__body { + flex: 1 1 auto; + overflow-y: auto; + padding: 1rem 1.125rem 1.125rem; + color: var(--color-text-2); +} + +.sui-modal__footer { + padding: 0.875rem 1.125rem; + border-top: 1px solid var(--color-border-light); + display: flex; + justify-content: flex-end; + gap: 0.5rem; +} diff --git a/frontend/shared/components/Modal.tsx b/frontend/shared/components/Modal.tsx new file mode 100644 index 000000000..f12985a15 --- /dev/null +++ b/frontend/shared/components/Modal.tsx @@ -0,0 +1,171 @@ +import { useEffect, useId, useRef, type ReactNode } from "react"; +import { createPortal } from "react-dom"; +import "@shared/components/Modal.css"; + +export type ModalWidth = "sm" | "md" | "lg" | "xl"; + +export interface ModalProps { + open: boolean; + onClose: () => void; + /** Optional heading slot rendered above the body. */ + title?: ReactNode; + /** Optional sub-heading rendered under the title. */ + subtitle?: ReactNode; + /** Optional footer slot rendered below the body. */ + footer?: ReactNode; + /** Width preset. sm=24rem, md=32rem, lg=48rem, xl=64rem. Defaults to md. */ + width?: ModalWidth; + /** Disable click-on-backdrop dismissal. Defaults to false. */ + disableBackdropClose?: boolean; + /** Disable Escape-key dismissal. Defaults to false. */ + disableEscapeClose?: boolean; + /** Accessible name when no visible `title` is provided. */ + ariaLabel?: string; + className?: string; + children?: ReactNode; +} + +const FOCUSABLE_SELECTOR = + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +/** + * Portal-rendered modal. Backdrop fades in, dialog scales in. ESC and + * click-on-backdrop close by default. Each instance is self-contained — the + * caller owns the open state and the close handler. + * + * Focus is trapped inside the dialog while open and returned to the + * previously-focused element on close. + */ +export function Modal({ + open, + onClose, + title, + subtitle, + footer, + width = "md", + disableBackdropClose = false, + disableEscapeClose = false, + ariaLabel, + className, + children, +}: ModalProps) { + const dialogRef = useRef(null); + const titleId = useId(); + + useEffect(() => { + if (!open || disableEscapeClose) return; + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [open, disableEscapeClose, onClose]); + + useEffect(() => { + if (!open) return; + const previous = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = previous; + }; + }, [open]); + + useEffect(() => { + if (!open) return; + const previouslyFocused = document.activeElement as HTMLElement | null; + const dialog = dialogRef.current; + const first = dialog?.querySelector(FOCUSABLE_SELECTOR); + first?.focus(); + + function onKey(e: KeyboardEvent) { + if (e.key !== "Tab" || !dialog) return; + const focusables = + dialog.querySelectorAll(FOCUSABLE_SELECTOR); + if (focusables.length === 0) { + e.preventDefault(); + return; + } + const firstEl = focusables[0]; + const lastEl = focusables[focusables.length - 1]; + const active = document.activeElement; + if (e.shiftKey && active === firstEl) { + e.preventDefault(); + lastEl.focus(); + } else if (!e.shiftKey && active === lastEl) { + e.preventDefault(); + firstEl.focus(); + } + } + + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("keydown", onKey); + previouslyFocused?.focus?.(); + }; + }, [open]); + + if (!open) return null; + + function onBackdropClick() { + if (!disableBackdropClose) onClose(); + } + + const hasTitle = title !== undefined && title !== null; + + return createPortal( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby={hasTitle ? titleId : undefined} + aria-label={!hasTitle ? ariaLabel : undefined} + > + {(title || subtitle) && ( +
+
+ {title && ( +
+ {title} +
+ )} + {subtitle &&
{subtitle}
} +
+ +
+ )} +
{children}
+ {footer &&
{footer}
} +
+
, + document.body, + ); +} diff --git a/frontend/shared/components/NavItem.css b/frontend/shared/components/NavItem.css new file mode 100644 index 000000000..d70c11378 --- /dev/null +++ b/frontend/shared/components/NavItem.css @@ -0,0 +1,47 @@ +.sui-navitem { + display: flex; + align-items: center; + gap: 0.625rem; + width: 100%; + padding: 0.4375rem 0.75rem; + margin: 0.0625rem 0.5rem; + border-radius: var(--radius-lg); + color: var(--color-nav-text); + font-size: 0.8125rem; + font-weight: 400; + transition: + background var(--motion-fast), + color var(--motion-fast); + text-align: left; +} +.sui-navitem:hover { + background: var(--color-nav-hover); + color: var(--color-nav-hover-text); +} +.sui-navitem.is-active { + background: var(--color-nav-active); + color: var(--color-nav-active-text); + font-weight: 500; +} +.sui-navitem.is-active:hover { + background: var(--color-nav-active); +} +.sui-navitem__icon { + width: 1rem; + height: 1rem; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} +.sui-navitem__label { + flex: 1; +} +.sui-navitem__trailing { + display: inline-flex; + align-items: center; +} +.sui-navitem:focus-visible { + outline: 0.125rem solid var(--color-blue); + outline-offset: 0.125rem; +} diff --git a/frontend/shared/components/NavItem.stories.tsx b/frontend/shared/components/NavItem.stories.tsx new file mode 100644 index 000000000..2cf5794a7 --- /dev/null +++ b/frontend/shared/components/NavItem.stories.tsx @@ -0,0 +1,106 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { NavItem } from "@shared/components/NavItem"; +import { SectionDivider } from "@shared/components/SectionDivider"; + +function Dot({ color = "var(--color-blue)" }: { color?: string }) { + return ( + + ); +} + +const meta: Meta = { + title: "Primitives/NavItem", + component: NavItem, + tags: ["autodocs"], + parameters: { layout: "padded" }, + args: { id: "home", label: "Home", isActive: false }, + argTypes: { isActive: { control: "boolean" } }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +/** Flip isActive / label / icon / trailing in controls. */ +export const Playground: Story = { + args: { icon: }, +}; + +export const WithTrailingBadge: Story = { + args: { + icon: , + trailing: ( + + 3 + + ), + }, +}; + +export const InContext_SidebarGroup: Story = { + render: () => { + function Bound() { + const [active, setActive] = useState("pipelines"); + const items = [ + { id: "home", label: "Home" }, + { id: "editor", label: "Editor" }, + { id: "sources", label: "Sources" }, + { id: "pipelines", label: "Pipelines" }, + { id: "documents", label: "Documents" }, + ]; + return ( +
+ } + isActive={active === "home"} + onClick={setActive} + /> + + {items.slice(1).map((item) => ( + } + isActive={active === item.id} + onClick={setActive} + /> + ))} +
+ ); + } + return ; + }, +}; diff --git a/frontend/shared/components/NavItem.tsx b/frontend/shared/components/NavItem.tsx new file mode 100644 index 000000000..fd328bc0f --- /dev/null +++ b/frontend/shared/components/NavItem.tsx @@ -0,0 +1,51 @@ +import type { ReactNode } from "react"; +import "@shared/components/NavItem.css"; + +export interface NavItemProps { + /** Stable view id passed to the click handler. */ + id: string; + label: string; + icon?: ReactNode; + /** Show the active highlight (navActive background, navActiveText colour). */ + isActive?: boolean; + /** Optional trailing badge (e.g. unread count, "new"). */ + trailing?: ReactNode; + onClick?: (id: string) => void; + className?: string; +} + +/** + * Sidebar navigation row matching the prototype's hover + active styling. + * + * Active styling: navActive background, navActiveText colour, weight 500. + * Hover styling: navHover background, navHoverText colour (only when not + * already active). + */ +export function NavItem({ + id, + label, + icon, + isActive, + trailing, + onClick, + className, +}: NavItemProps) { + return ( + + ); +} diff --git a/frontend/shared/components/PanelHeader.css b/frontend/shared/components/PanelHeader.css new file mode 100644 index 000000000..df537d9b8 --- /dev/null +++ b/frontend/shared/components/PanelHeader.css @@ -0,0 +1,48 @@ +.sui-panelhdr { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--color-border); +} +.sui-panelhdr__left { + display: flex; + align-items: center; + gap: 0.75rem; + min-width: 0; +} +.sui-panelhdr__back { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: var(--radius-md); + color: var(--color-text-3); + transition: + background var(--motion-fast), + color var(--motion-fast); +} +.sui-panelhdr__back:hover { + background: var(--color-bg-hover); + color: var(--color-text-1); +} +.sui-panelhdr__text { + min-width: 0; +} +.sui-panelhdr__title { + font-size: 1.0625rem; + font-weight: 600; + color: var(--color-text-1); +} +.sui-panelhdr__sub { + font-size: 0.8125rem; + color: var(--color-text-4); + margin-top: 0.125rem; +} +.sui-panelhdr__actions { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} diff --git a/frontend/shared/components/PanelHeader.stories.tsx b/frontend/shared/components/PanelHeader.stories.tsx new file mode 100644 index 000000000..1aa37b955 --- /dev/null +++ b/frontend/shared/components/PanelHeader.stories.tsx @@ -0,0 +1,61 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PanelHeader } from "@shared/components/PanelHeader"; +import { Button } from "@shared/components/Button"; +import { StatusBadge } from "@shared/components/StatusBadge"; + +const meta: Meta = { + title: "Primitives/PanelHeader", + component: PanelHeader, + tags: ["autodocs"], + parameters: { layout: "padded" }, + args: { + title: "Pipeline detail", + subtitle: "COI Compliance · us-east-1", + }, + argTypes: { onBack: { action: "back" } }, +}; +export default meta; +type Story = StoryObj; + +/** Toggle title / subtitle / onBack / actions in controls. */ +export const Playground: Story = {}; + +export const WithActions: Story = { + args: { + subtitle: "Last deploy 14m ago · golden set 48/48", + actions: ( + <> + + Healthy + + + + + ), + }, +}; + +export const Everything: Story = { + args: { + title: "Pipeline detail — COI Compliance", + subtitle: "Forked from Compliance Pack · 1,287 docs / 24h", + onBack: () => {}, + actions: ( + <> + + Healthy + + + + + ), + }, +}; diff --git a/frontend/shared/components/PanelHeader.tsx b/frontend/shared/components/PanelHeader.tsx new file mode 100644 index 000000000..031edbf1d --- /dev/null +++ b/frontend/shared/components/PanelHeader.tsx @@ -0,0 +1,60 @@ +import type { ReactNode } from "react"; +import "@shared/components/PanelHeader.css"; + +export interface PanelHeaderProps { + title: ReactNode; + /** Sub-heading below the title. */ + subtitle?: ReactNode; + /** Show a back chevron and trigger this callback when clicked. */ + onBack?: () => void; + /** Right-aligned action buttons / chips. */ + actions?: ReactNode; + className?: string; +} + +/** + * Header strip used by drill-down panels (admin tabs, agent detail, settings + * sub-pages). Back chevron renders only when `onBack` is supplied. + */ +export function PanelHeader({ + title, + subtitle, + onBack, + actions, + className, +}: PanelHeaderProps) { + return ( +
+
+ {onBack && ( + + )} +
+
{title}
+ {subtitle &&
{subtitle}
} +
+
+ {actions &&
{actions}
} +
+ ); +} diff --git a/frontend/shared/components/ProgressBar.css b/frontend/shared/components/ProgressBar.css new file mode 100644 index 000000000..9bee3de24 --- /dev/null +++ b/frontend/shared/components/ProgressBar.css @@ -0,0 +1,13 @@ +.sui-progress { + width: 100%; + background: var(--color-usage-track); + border-radius: 62.4375rem; + overflow: hidden; +} +.sui-progress__fill { + height: 100%; + border-radius: 62.4375rem; + transition: + width 0.5s cubic-bezier(0.4, 0, 0.2, 1), + background var(--motion-base); +} diff --git a/frontend/shared/components/ProgressBar.stories.tsx b/frontend/shared/components/ProgressBar.stories.tsx new file mode 100644 index 000000000..bc7db818e --- /dev/null +++ b/frontend/shared/components/ProgressBar.stories.tsx @@ -0,0 +1,95 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ProgressBar } from "@shared/components/ProgressBar"; + +const meta: Meta = { + title: "Primitives/ProgressBar", + component: ProgressBar, + tags: ["autodocs"], + parameters: { layout: "padded" }, + args: { value: 0.5, height: 6, thresholded: false }, + argTypes: { + value: { control: { type: "range", min: 0, max: 1, step: 0.01 } }, + height: { control: { type: "number" } }, + thresholded: { control: "boolean" }, + }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +/** Drag the value slider, toggle thresholded, change height in controls. */ +export const Playground: Story = {}; + +export const ThresholdLadder: Story = { + render: () => ( +
+ {[0, 0.25, 0.5, 0.79, 0.85, 0.95, 0.98, 1].map((v) => ( +
+ + {Math.round(v * 100)}% + + +
+ ))} +
+ ), +}; + +export const InContext_UsageMeter: Story = { + decorators: [ + (S) => ( +
+ +
+ ), + ], + render: () => ( +
+
+ Docs processed + + 412 / 500 + +
+ +
+ Approaching the free-plan cap +
+
+ ), +}; diff --git a/frontend/shared/components/ProgressBar.tsx b/frontend/shared/components/ProgressBar.tsx new file mode 100644 index 000000000..cf3a9b9c7 --- /dev/null +++ b/frontend/shared/components/ProgressBar.tsx @@ -0,0 +1,70 @@ +import "@shared/components/ProgressBar.css"; + +export interface ProgressBarProps { + /** 0–1. Values outside the range are clamped. */ + value: number; + /** Height in pixels. Defaults to 6. */ + height?: number; + /** When set, colour shifts to amber at 80% and red at 96% — the prototype's usage-meter behaviour. */ + thresholded?: boolean; + /** Optional override colour (CSS gradient or solid). Disables threshold behaviour. */ + color?: string; + className?: string; + /** Accessible label for screen readers. */ + label?: string; +} + +function clamp01(n: number) { + return Math.max(0, Math.min(1, n)); +} + +/** + * Progress bar with optional threshold-based colouring. + * + * The prototype's sidebar usage meter uses `thresholded` so the bar turns + * amber at 80% and red at 96% — a small visual hint that drives upgrade + * conversion. Pipeline progress / storage bars typically pass a fixed colour. + */ +export function ProgressBar({ + value, + height = 6, + thresholded = false, + color, + className, + label, +}: ProgressBarProps) { + const v = clamp01(value); + let fill = color; + if (!fill) { + // Second stop is a lighter mix of the same token so the gradient keeps its + // sheen in both themes (a hardcoded hex here flattened to a solid bar in + // dark mode, where the token already equalled that hex). + if (thresholded) { + fill = + v >= 0.96 + ? "linear-gradient(90deg, var(--color-red), color-mix(in srgb, var(--color-red) 70%, white))" + : v >= 0.8 + ? "linear-gradient(90deg, var(--color-amber), color-mix(in srgb, var(--color-amber) 70%, white))" + : "linear-gradient(90deg, var(--color-blue), color-mix(in srgb, var(--color-blue) 70%, white))"; + } else { + fill = + "linear-gradient(90deg, var(--color-blue), color-mix(in srgb, var(--color-blue) 70%, white))"; + } + } + return ( +
+
+
+ ); +} diff --git a/frontend/shared/components/Radio.css b/frontend/shared/components/Radio.css new file mode 100644 index 000000000..162d0a9d5 --- /dev/null +++ b/frontend/shared/components/Radio.css @@ -0,0 +1,77 @@ +.sui-radio-group { + display: flex; +} +.sui-radio-group--vertical { + flex-direction: column; + gap: var(--space-2); +} +.sui-radio-group--horizontal { + flex-direction: row; + gap: var(--space-4); + flex-wrap: wrap; +} + +.sui-radio { + display: inline-flex; + align-items: flex-start; + gap: var(--space-2); + cursor: pointer; + font-size: 0.8125rem; + color: var(--color-text-2); +} +.sui-radio--disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.sui-radio__input { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} + +.sui-radio__dot { + flex: 0 0 auto; + width: 1rem; + height: 1rem; + margin-top: 0.0625rem; + border-radius: 50%; + border: 1.5px solid var(--color-border-hover); + background: var(--color-surface); + display: inline-flex; + align-items: center; + justify-content: center; + transition: border-color var(--motion-fast); + position: relative; +} + +.sui-radio__input:focus-visible + .sui-radio__dot { + outline: 2px solid var(--color-blue); + outline-offset: 2px; +} + +.sui-radio__input:checked + .sui-radio__dot { + border-color: var(--color-blue); +} + +.sui-radio__input:checked + .sui-radio__dot::after { + content: ""; + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: var(--color-blue); +} + +.sui-radio__text { + display: flex; + flex-direction: column; +} +.sui-radio__label { + color: var(--color-text-1); + font-weight: 500; +} +.sui-radio__desc { + font-size: 0.75rem; + color: var(--color-text-4); +} diff --git a/frontend/shared/components/Radio.tsx b/frontend/shared/components/Radio.tsx new file mode 100644 index 000000000..64f2dfb3e --- /dev/null +++ b/frontend/shared/components/Radio.tsx @@ -0,0 +1,88 @@ +import { type InputHTMLAttributes, type ReactNode } from "react"; +import "@shared/components/Radio.css"; + +export interface RadioOption { + value: V; + label: ReactNode; + description?: ReactNode; + disabled?: boolean; +} + +export interface RadioGroupProps { + name: string; + value: V; + onChange: (value: V) => void; + options: RadioOption[]; + /** Layout. `vertical` stacks options; `horizontal` lays them inline. */ + direction?: "vertical" | "horizontal"; + className?: string; +} + +/** Standalone single radio — usually consumed via {@link RadioGroup}. */ +export function Radio({ + label, + description, + className, + ...rest +}: Omit, "type"> & { + label?: ReactNode; + description?: ReactNode; +}) { + return ( + + ); +} + +export function RadioGroup({ + name, + value, + onChange, + options, + direction = "vertical", + className, +}: RadioGroupProps) { + return ( +
+ {options.map((opt) => ( + onChange(opt.value)} + disabled={opt.disabled} + label={opt.label} + description={opt.description} + /> + ))} +
+ ); +} diff --git a/frontend/shared/components/SectionDivider.css b/frontend/shared/components/SectionDivider.css new file mode 100644 index 000000000..8999f6f0a --- /dev/null +++ b/frontend/shared/components/SectionDivider.css @@ -0,0 +1,5 @@ +.sui-divider { + height: 0.0625rem; + background: var(--color-sidebar-divider); + width: 100%; +} diff --git a/frontend/shared/components/SectionDivider.stories.tsx b/frontend/shared/components/SectionDivider.stories.tsx new file mode 100644 index 000000000..b2b3f17ba --- /dev/null +++ b/frontend/shared/components/SectionDivider.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SectionDivider } from "@shared/components/SectionDivider"; + +const meta: Meta = { + title: "Primitives/SectionDivider", + component: SectionDivider, + tags: ["autodocs"], + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( +
+

Section above

+ +

Section below

+
+ ), +}; + +export const InContext_SidebarGroups: Story = { + render: () => ( +
+
Home
+ +
Editor
+
Sources
+
Pipelines
+ +
Infrastructure
+
Usage
+
+ ), +}; diff --git a/frontend/shared/components/SectionDivider.tsx b/frontend/shared/components/SectionDivider.tsx new file mode 100644 index 000000000..6fe5556ed --- /dev/null +++ b/frontend/shared/components/SectionDivider.tsx @@ -0,0 +1,25 @@ +import "@shared/components/SectionDivider.css"; + +export interface SectionDividerProps { + /** Vertical margin in px. */ + spacing?: number; + className?: string; +} + +/** + * Hairline divider used between sidebar groups and inside settings panels. + * No section label — the prototype's deliberate Supabase pattern is to group + * via the divider alone, without forcing a category noun onto the surfaces. + */ +export function SectionDivider({ + spacing = 12, + className, +}: SectionDividerProps) { + return ( +
+ ); +} diff --git a/frontend/shared/components/Select.css b/frontend/shared/components/Select.css new file mode 100644 index 000000000..f97bf0f58 --- /dev/null +++ b/frontend/shared/components/Select.css @@ -0,0 +1,60 @@ +.sui-select { + position: relative; + display: inline-flex; + align-items: center; + background: var(--color-surface); + border: 1px solid var(--color-border-input); + border-radius: var(--radius-md); + color: var(--color-text-1); + transition: + border-color var(--motion-fast), + box-shadow var(--motion-fast); +} + +.sui-select:focus-within { + border-color: var(--color-blue); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-blue) 16%, transparent); +} + +.sui-select--sm { + min-height: 1.75rem; +} +.sui-select--md { + min-height: 2.25rem; +} + +.sui-select--invalid { + border-color: var(--color-red); +} +.sui-select--disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.sui-select__el { + flex: 1 1 auto; + font: inherit; + font-size: 0.875rem; + background: transparent; + border: none; + color: inherit; + outline: none; + padding: 0 var(--space-6) 0 var(--space-3); + appearance: none; + -webkit-appearance: none; +} + +.sui-select--sm .sui-select__el { + font-size: 0.8125rem; + padding-left: var(--space-2); + padding-right: var(--space-5); +} + +.sui-select__caret { + position: absolute; + right: var(--space-2); + display: inline-flex; + align-items: center; + pointer-events: none; + color: var(--color-text-4); +} diff --git a/frontend/shared/components/Select.tsx b/frontend/shared/components/Select.tsx new file mode 100644 index 000000000..432734221 --- /dev/null +++ b/frontend/shared/components/Select.tsx @@ -0,0 +1,69 @@ +import { forwardRef, type SelectHTMLAttributes } from "react"; +import "@shared/components/Select.css"; + +export interface SelectOption { + value: string; + label: string; + disabled?: boolean; +} + +export type SelectSize = "sm" | "md"; + +export interface SelectProps extends Omit< + SelectHTMLAttributes, + "size" +> { + inputSize?: SelectSize; + options: SelectOption[]; + /** Optional placeholder rendered as a disabled first option. */ + placeholder?: string; + invalid?: boolean; +} + +export const Select = forwardRef( + function Select( + { inputSize = "md", options, placeholder, invalid, className, ...rest }, + ref, + ) { + return ( + + + + + + + + + ); + }, +); diff --git a/frontend/shared/components/Skeleton.css b/frontend/shared/components/Skeleton.css new file mode 100644 index 000000000..98da3c7db --- /dev/null +++ b/frontend/shared/components/Skeleton.css @@ -0,0 +1,30 @@ +.sui-skel { + display: inline-block; + background: linear-gradient( + 90deg, + var(--color-bg-muted) 0%, + var(--color-bg-hover) 50%, + var(--color-bg-muted) 100% + ); + background-size: 200% 100%; + animation: shimmer 1.4s linear infinite; + vertical-align: middle; +} + +.sui-skel--text { + border-radius: var(--radius-pill); +} +.sui-skel--rect { + border-radius: var(--radius-md); +} +.sui-skel--circle { + border-radius: 50%; + aspect-ratio: 1 / 1; +} + +.sui-skel-lines { + display: flex; + flex-direction: column; + gap: var(--space-1); + width: 100%; +} diff --git a/frontend/shared/components/Skeleton.stories.tsx b/frontend/shared/components/Skeleton.stories.tsx new file mode 100644 index 000000000..ed926004d --- /dev/null +++ b/frontend/shared/components/Skeleton.stories.tsx @@ -0,0 +1,62 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Skeleton } from "@shared/components/Skeleton"; + +const meta: Meta = { + title: "Primitives/Skeleton", + component: Skeleton, + tags: ["autodocs"], + parameters: { layout: "padded" }, + args: { width: "20rem", shape: "text", lines: 1 }, + argTypes: { + shape: { control: "inline-radio", options: ["text", "rect", "circle"] }, + lines: { control: { type: "number", min: 1, max: 8 } }, + width: { control: "text" }, + height: { control: "text" }, + }, +}; +export default meta; +type Story = StoryObj; + +/** Flip shape / lines / width / height in controls. */ +export const Playground: Story = {}; + +export const InContext_TableRow: Story = { + render: () => ( +
+ + + + +
+ ), +}; + +export const InContext_ActivityFeed: Story = { + render: () => ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ +
+ +
+
+ ))} +
+ ), +}; diff --git a/frontend/shared/components/Skeleton.tsx b/frontend/shared/components/Skeleton.tsx new file mode 100644 index 000000000..b4fafc9b7 --- /dev/null +++ b/frontend/shared/components/Skeleton.tsx @@ -0,0 +1,67 @@ +import "@shared/components/Skeleton.css"; + +export interface SkeletonProps { + /** Width as a CSS length (`100%`, `12rem`, …). Defaults to 100%. */ + width?: string | number; + /** Height as a CSS length. Defaults to 0.75rem. */ + height?: string | number; + /** Shape preset. `text` = pill-rounded line, `rect` = card / image area. */ + shape?: "text" | "rect" | "circle"; + /** For text shape, the number of stacked lines to render. */ + lines?: number; + className?: string; +} + +/** + * Shimmering placeholder for content that hasn't loaded yet. Replaces the + * ad-hoc `linear-gradient(...) shimmer 1.4s` blocks that were duplicated + * across the deployed-pipelines table, the activity feed, and the doc-type + * grid. + */ +export function Skeleton({ + width = "100%", + height, + shape = "text", + lines = 1, + className, +}: SkeletonProps) { + const dim = (v: string | number | undefined) => + typeof v === "number" ? `${v}px` : v; + + if (shape === "text" && lines > 1) { + return ( + + {Array.from({ length: lines }).map((_, i) => ( + + ))} + + ); + } + + return ( + + ); +} diff --git a/frontend/shared/components/Slider.css b/frontend/shared/components/Slider.css new file mode 100644 index 000000000..005ca0cbb --- /dev/null +++ b/frontend/shared/components/Slider.css @@ -0,0 +1,68 @@ +.sui-slider { + display: inline-flex; + align-items: center; + gap: var(--space-3); + width: 100%; +} + +.sui-slider--disabled { + opacity: 0.5; +} + +.sui-slider__input { + flex: 1 1 auto; + appearance: none; + -webkit-appearance: none; + height: 0.5rem; + border-radius: var(--radius-pill); + background: linear-gradient( + 90deg, + var(--color-blue) 0%, + var(--color-blue) var(--slider-pct), + var(--color-bg-muted) var(--slider-pct), + var(--color-bg-muted) 100% + ); + outline: none; +} + +.sui-slider__input::-webkit-slider-thumb { + appearance: none; + -webkit-appearance: none; + width: 1rem; + height: 1rem; + border-radius: 50%; + background: #fff; + border: 2px solid var(--color-blue); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); + cursor: pointer; + transition: transform var(--motion-fast); +} + +.sui-slider__input::-moz-range-thumb { + width: 1rem; + height: 1rem; + border-radius: 50%; + background: #fff; + border: 2px solid var(--color-blue); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); + cursor: pointer; +} + +.sui-slider__input:focus-visible::-webkit-slider-thumb { + outline: 2px solid var(--color-blue); + outline-offset: 2px; +} + +.sui-slider__input:hover::-webkit-slider-thumb { + transform: scale(1.08); +} + +.sui-slider__value { + flex: 0 0 auto; + min-width: 3rem; + text-align: right; + font-family: var(--font-mono); + font-size: 0.75rem; + font-weight: 500; + color: var(--color-text-2); +} diff --git a/frontend/shared/components/Slider.tsx b/frontend/shared/components/Slider.tsx new file mode 100644 index 000000000..2829cae94 --- /dev/null +++ b/frontend/shared/components/Slider.tsx @@ -0,0 +1,63 @@ +import { forwardRef, type InputHTMLAttributes } from "react"; +import "@shared/components/Slider.css"; + +export interface SliderProps extends Omit< + InputHTMLAttributes, + "type" | "value" | "onChange" +> { + value: number; + min?: number; + max?: number; + step?: number; + onChange: (value: number) => void; + /** Optional formatter for the value pill (e.g. "0.85", "30 days"). */ + formatValue?: (value: number) => string; + /** Show the right-aligned value badge. Defaults to true. */ + showValue?: boolean; +} + +export const Slider = forwardRef(function Slider( + { + value, + min = 0, + max = 1, + step = 0.01, + onChange, + formatValue, + showValue = true, + className, + ...rest + }, + ref, +) { + const pct = ((value - min) / (max - min)) * 100; + return ( + + onChange(Number(e.target.value))} + className="sui-slider__input" + {...rest} + /> + {showValue && ( + + {formatValue ? formatValue(value) : value.toString()} + + )} + + ); +}); diff --git a/frontend/shared/components/Spinner.css b/frontend/shared/components/Spinner.css new file mode 100644 index 000000000..23a5f6fae --- /dev/null +++ b/frontend/shared/components/Spinner.css @@ -0,0 +1,27 @@ +.sui-spinner { + display: inline-block; + border-radius: 50%; + border: 2px solid currentColor; + border-right-color: transparent; + animation: spin 0.7s linear infinite; + vertical-align: middle; +} + +.sui-spinner--xs { + width: 0.625rem; + height: 0.625rem; + border-width: 1.5px; +} +.sui-spinner--sm { + width: 0.875rem; + height: 0.875rem; +} +.sui-spinner--md { + width: 1.25rem; + height: 1.25rem; +} +.sui-spinner--lg { + width: 1.75rem; + height: 1.75rem; + border-width: 2.5px; +} diff --git a/frontend/shared/components/Spinner.stories.tsx b/frontend/shared/components/Spinner.stories.tsx new file mode 100644 index 000000000..68ca648f4 --- /dev/null +++ b/frontend/shared/components/Spinner.stories.tsx @@ -0,0 +1,45 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Spinner } from "@shared/components/Spinner"; + +const meta: Meta = { + title: "Primitives/Spinner", + component: Spinner, + tags: ["autodocs"], + parameters: { layout: "centered" }, + args: { size: "md" }, + argTypes: { + size: { control: "inline-radio", options: ["xs", "sm", "md", "lg"] }, + }, +}; +export default meta; +type Story = StoryObj; + +/** Flip size in controls. */ +export const Playground: Story = {}; + +export const SizeRow: Story = { + render: () => ( +
+ + + + +
+ ), +}; + +export const InheritsColor: Story = { + render: () => ( +
+ + + + + + + + + +
+ ), +}; diff --git a/frontend/shared/components/Spinner.tsx b/frontend/shared/components/Spinner.tsx new file mode 100644 index 000000000..d383e2cd0 --- /dev/null +++ b/frontend/shared/components/Spinner.tsx @@ -0,0 +1,28 @@ +import "@shared/components/Spinner.css"; + +export type SpinnerSize = "xs" | "sm" | "md" | "lg"; + +export interface SpinnerProps { + size?: SpinnerSize; + /** Optional accessible label for screen readers. */ + label?: string; + className?: string; +} + +/** + * Circular spinner. Inherits `currentColor` so it picks up the surrounding + * text colour — drop it into a Button, a banner, or a code header without + * any extra styling. + */ +export function Spinner({ size = "md", label, className }: SpinnerProps) { + return ( + + ); +} diff --git a/frontend/shared/components/Stack.css b/frontend/shared/components/Stack.css new file mode 100644 index 000000000..4a2e7bac9 --- /dev/null +++ b/frontend/shared/components/Stack.css @@ -0,0 +1,76 @@ +.sui-stack { + display: flex; + flex-direction: column; + min-width: 0; +} + +.sui-stack--gap-0 { + gap: var(--space-0); +} +.sui-stack--gap-0_5 { + gap: var(--space-0_5); +} +.sui-stack--gap-1 { + gap: var(--space-1); +} +.sui-stack--gap-1_5 { + gap: var(--space-1_5); +} +.sui-stack--gap-2 { + gap: var(--space-2); +} +.sui-stack--gap-3 { + gap: var(--space-3); +} +.sui-stack--gap-4 { + gap: var(--space-4); +} +.sui-stack--gap-5 { + gap: var(--space-5); +} +.sui-stack--gap-6 { + gap: var(--space-6); +} +.sui-stack--gap-8 { + gap: var(--space-8); +} + +.sui-stack--align-stretch { + align-items: stretch; +} +.sui-stack--align-start { + align-items: flex-start; +} +.sui-stack--align-center { + align-items: center; +} +.sui-stack--align-end { + align-items: flex-end; +} +.sui-stack--align-baseline { + align-items: baseline; +} + +.sui-stack--justify-start { + justify-content: flex-start; +} +.sui-stack--justify-center { + justify-content: center; +} +.sui-stack--justify-end { + justify-content: flex-end; +} +.sui-stack--justify-between { + justify-content: space-between; +} +.sui-stack--justify-around { + justify-content: space-around; +} +.sui-stack--justify-evenly { + justify-content: space-evenly; +} + +.sui-stack--fill { + flex: 1 1 auto; + min-height: 0; +} diff --git a/frontend/shared/components/Stack.stories.tsx b/frontend/shared/components/Stack.stories.tsx new file mode 100644 index 000000000..ef98c57ae --- /dev/null +++ b/frontend/shared/components/Stack.stories.tsx @@ -0,0 +1,70 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Stack } from "@shared/components/Stack"; +import { Inline } from "@shared/components/Inline"; +import { Card } from "@shared/components/Card"; + +const meta: Meta = { + title: "Primitives/Layout/Stack", + component: Stack, + tags: ["autodocs"], + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +function Box({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export const Default: Story = { + render: () => ( + + One + Two + Three + + ), +}; + +export const GapSizes: Story = { + render: () => ( + + {(["1", "2", "4", "6"] as const).map((gap) => ( + +
+ gap {gap} +
+ A + B + C +
+ ))} +
+ ), +}; + +export const InCard: Story = { + render: () => ( + + +
Card title
+
+ Stack is the default vertical container — it's how you compose every + card body, list, and form section. +
+ Action row +
+
+ ), +}; diff --git a/frontend/shared/components/Stack.tsx b/frontend/shared/components/Stack.tsx new file mode 100644 index 000000000..be02f7f23 --- /dev/null +++ b/frontend/shared/components/Stack.tsx @@ -0,0 +1,67 @@ +import type { ElementType, ReactNode, HTMLAttributes } from "react"; +import "@shared/components/Stack.css"; + +export type StackGap = + | "0" + | "0_5" + | "1" + | "1_5" + | "2" + | "3" + | "4" + | "5" + | "6" + | "8"; + +export type StackAlign = "stretch" | "start" | "center" | "end" | "baseline"; +export type StackJustify = + | "start" + | "center" + | "end" + | "between" + | "around" + | "evenly"; + +export interface StackProps extends HTMLAttributes { + /** Token-aligned gap between children (maps to `--space-*`). */ + gap?: StackGap; + align?: StackAlign; + justify?: StackJustify; + /** Stretch to fill parent height. */ + fill?: boolean; + /** Render as a custom element (defaults to div). */ + as?: ElementType; + children?: ReactNode; +} + +/** + * Vertical flex column with token-aligned gap. Replaces inline + * `style={{ display: 'flex', flexDirection: 'column', gap: ... }}` everywhere. + */ +export function Stack({ + gap = "2", + align, + justify, + fill, + as, + className, + children, + ...rest +}: StackProps) { + const Tag: ElementType = as ?? "div"; + const classes = [ + "sui-stack", + `sui-stack--gap-${gap}`, + align ? `sui-stack--align-${align}` : "", + justify ? `sui-stack--justify-${justify}` : "", + fill ? "sui-stack--fill" : "", + className ?? "", + ] + .filter(Boolean) + .join(" "); + return ( + + {children} + + ); +} diff --git a/frontend/shared/components/StatusBadge.css b/frontend/shared/components/StatusBadge.css new file mode 100644 index 000000000..a250f99d9 --- /dev/null +++ b/frontend/shared/components/StatusBadge.css @@ -0,0 +1,70 @@ +.sui-status { + display: inline-flex; + align-items: center; + gap: 0.375rem; + border-radius: var(--radius-pill); + font-family: var(--font-sans); + font-weight: 500; + letter-spacing: 0.01em; + border: 1px solid transparent; + line-height: 1; +} +.sui-status--sm { + font-size: 0.6875rem; + padding: 0.125rem 0.5rem; +} +.sui-status--md { + font-size: 0.75rem; + padding: 0.1875rem 0.625rem; +} +.sui-status--lg { + font-size: 0.8125rem; + padding: 0.3125rem 0.75rem; +} + +.sui-status__dot { + width: 0.375rem; + height: 0.375rem; + border-radius: 50%; + background: currentColor; + position: relative; +} +.sui-status__dot--pulse::after { + content: ""; + position: absolute; + inset: -0.125rem; + border-radius: 50%; + border: 2px solid currentColor; + animation: pulseRing 1.4s ease-out infinite; +} + +.sui-status--neutral { + color: var(--color-text-3); + background: var(--color-bg-muted); + border-color: var(--color-border-light); +} +.sui-status--success { + color: var(--color-green); + background: var(--color-green-light); + border-color: var(--color-green-border); +} +.sui-status--warning { + color: var(--color-amber-dark); + background: var(--color-amber-light); + border-color: var(--color-amber-border); +} +.sui-status--danger { + color: var(--color-red); + background: var(--color-red-light); + border-color: var(--color-red-border); +} +.sui-status--info { + color: var(--color-blue); + background: var(--color-blue-light); + border-color: var(--color-blue-border); +} +.sui-status--purple { + color: var(--color-purple); + background: var(--color-purple-light); + border-color: var(--color-purple-border); +} diff --git a/frontend/shared/components/StatusBadge.stories.tsx b/frontend/shared/components/StatusBadge.stories.tsx new file mode 100644 index 000000000..4c8f069a0 --- /dev/null +++ b/frontend/shared/components/StatusBadge.stories.tsx @@ -0,0 +1,53 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { StatusBadge } from "@shared/components/StatusBadge"; + +const meta: Meta = { + title: "Primitives/StatusBadge", + component: StatusBadge, + parameters: { layout: "centered" }, + args: { children: "Healthy", tone: "success" }, + argTypes: { + tone: { + control: "inline-radio", + options: ["neutral", "success", "warning", "danger", "info", "purple"], + }, + size: { control: "inline-radio", options: ["sm", "md", "lg"] }, + }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const AllTones: Story = { + render: () => ( +
+ Processed + Needs review + Escalated + In review + Resolved + Paused +
+ ), +}; + +export const Live: Story = { + args: { tone: "success", pulse: true, children: "Live" }, +}; + +export const Sizes: Story = { + render: () => ( +
+ + Small + + + Medium + + + Large + +
+ ), +}; diff --git a/frontend/shared/components/StatusBadge.tsx b/frontend/shared/components/StatusBadge.tsx new file mode 100644 index 000000000..72207de98 --- /dev/null +++ b/frontend/shared/components/StatusBadge.tsx @@ -0,0 +1,56 @@ +import type { ReactNode } from "react"; +import "@shared/components/StatusBadge.css"; + +export type StatusTone = + | "neutral" + | "success" + | "warning" + | "danger" + | "info" + | "purple"; + +export type StatusSize = "sm" | "md" | "lg"; + +export interface StatusBadgeProps { + tone?: StatusTone; + size?: StatusSize; + /** Show a leading coloured dot. */ + showDot?: boolean; + /** Render the dot with a pulse animation (active / live indicator). */ + pulse?: boolean; + children?: ReactNode; + className?: string; +} + +/** + * Inline status pill used across surfaces — pipeline rows, document status, + * deployments, audit logs. Tone maps to semantic meaning, not raw colour. + */ +export function StatusBadge({ + tone = "neutral", + size = "md", + showDot = true, + pulse = false, + children, + className, +}: StatusBadgeProps) { + const cls = [ + "sui-status", + `sui-status--${tone}`, + `sui-status--${size}`, + className ?? "", + ] + .filter(Boolean) + .join(" "); + return ( + + {showDot && ( + + )} + {children} + + ); +} diff --git a/frontend/shared/components/Tabs.css b/frontend/shared/components/Tabs.css new file mode 100644 index 000000000..b9dd365fc --- /dev/null +++ b/frontend/shared/components/Tabs.css @@ -0,0 +1,77 @@ +.sui-tabs { + display: flex; + gap: var(--space-1); + flex-wrap: wrap; +} + +.sui-tabs--pill { +} +.sui-tabs--underline { + border-bottom: 1px solid var(--color-border-light); + padding-bottom: var(--space-1); + gap: var(--space-3); +} + +.sui-tabs__tab { + display: inline-flex; + align-items: center; + gap: var(--space-1_5); + padding: var(--space-1_5) var(--space-3); + font-size: 0.8125rem; + color: var(--color-text-3); + background: transparent; + border: 1px solid transparent; + transition: + color var(--motion-fast), + background var(--motion-fast), + border-color var(--motion-fast); +} + +.sui-tabs__tab:hover:not(.is-disabled) { + color: var(--color-text-1); + background: var(--color-bg-hover); +} + +.sui-tabs__tab.is-disabled { + opacity: 0.45; + cursor: not-allowed; +} + +/* Pill variant — rounded chip, filled tint when active. */ +.sui-tabs--pill .sui-tabs__tab { + border-radius: var(--radius-pill); +} +.sui-tabs--pill .sui-tabs__tab.is-active { + color: var(--sui-tab-accent, var(--color-blue)); + background: var(--color-blue-light); + border-color: var(--sui-tab-accent, var(--color-blue-border)); + font-weight: 500; +} + +/* Underline variant — line-under, no background fill. */ +.sui-tabs--underline .sui-tabs__tab { + border-radius: 0; + border-bottom: 2px solid transparent; + padding-bottom: var(--space-2); + margin-bottom: -1px; +} +.sui-tabs--underline .sui-tabs__tab.is-active { + color: var(--sui-tab-accent, var(--color-blue)); + border-bottom-color: var(--sui-tab-accent, var(--color-blue)); + font-weight: 500; +} + +.sui-tabs__dot { + width: 0.4375rem; + height: 0.4375rem; + border-radius: 50%; +} + +.sui-tabs__count { + font-size: 0.6875rem; + color: var(--color-text-5); +} + +.sui-tabs__tab.is-active .sui-tabs__count { + color: inherit; +} diff --git a/frontend/shared/components/Tabs.stories.tsx b/frontend/shared/components/Tabs.stories.tsx new file mode 100644 index 000000000..ef0c71aca --- /dev/null +++ b/frontend/shared/components/Tabs.stories.tsx @@ -0,0 +1,93 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Tabs, type TabItem } from "@shared/components/Tabs"; + +const baseItems: TabItem[] = [ + { key: "deployed", label: "Deployed", count: 6 }, + { key: "templates", label: "Templates", count: 4 }, + { key: "archive", label: "Archive", count: 0 }, +]; + +function Bound({ + items = baseItems, + variant = "pill" as const, +}: { + items?: TabItem[]; + variant?: "pill" | "underline"; +}) { + const [active, setActive] = useState(items[0].key); + return ( + + ); +} + +const meta: Meta = { + title: "Primitives/Tabs", + component: Tabs, + tags: ["autodocs"], + parameters: { layout: "padded" }, + args: { variant: "pill" }, + argTypes: { + variant: { control: "inline-radio", options: ["pill", "underline"] }, + }, + render: (args) => , +}; +export default meta; +type Story = StoryObj; + +/** Flip variant in controls. */ +export const Playground: Story = {}; + +export const WithDisabledTab: Story = { + render: () => ( + + ), +}; + +export const InContext_DocumentVerticals: Story = { + render: () => ( + + ), +}; diff --git a/frontend/shared/components/Tabs.tsx b/frontend/shared/components/Tabs.tsx new file mode 100644 index 000000000..0727c3ced --- /dev/null +++ b/frontend/shared/components/Tabs.tsx @@ -0,0 +1,88 @@ +import type { ReactNode } from "react"; +import "@shared/components/Tabs.css"; + +export interface TabItem { + /** Stable identifier for the tab. */ + key: K; + label: ReactNode; + /** Optional count badge shown to the right of the label. */ + count?: number; + /** Accent colour applied when this tab is active (override per-tab). */ + accentColor?: string; + /** Leading dot before the label (used by Document type grid). */ + dotColor?: string; + disabled?: boolean; +} + +export interface TabsProps { + items: TabItem[]; + activeKey: K; + onChange: (key: K) => void; + /** Visual treatment. `pill` is rounded-chip style; `underline` is line-under tabs. */ + variant?: "pill" | "underline"; + /** Accessible label for the tablist. */ + ariaLabel?: string; + className?: string; +} + +/** + * Single-row tab strip. Generic over the key type so callers get strongly + * typed `onChange` handlers without casting. + */ +export function Tabs({ + items, + activeKey, + onChange, + variant = "pill", + ariaLabel, + className, +}: TabsProps) { + return ( + // role="group" + aria-pressed is the right pattern for a filter strip + // that does not have paired tabpanels. Switching to ARIA tab roles would + // require ArrowLeft / ArrowRight handling and per-tab tabpanel wiring per + // the APG — out of scope for what this primitive actually does. +
+ {items.map((item) => { + const isActive = item.key === activeKey; + const styleVars = + isActive && item.accentColor + ? ({ "--sui-tab-accent": item.accentColor } as React.CSSProperties) + : undefined; + return ( + + ); + })} +
+ ); +} diff --git a/frontend/shared/components/Toast.css b/frontend/shared/components/Toast.css new file mode 100644 index 000000000..ab52d37d3 --- /dev/null +++ b/frontend/shared/components/Toast.css @@ -0,0 +1,70 @@ +.sui-toast-viewport { + position: fixed; + bottom: var(--space-6); + right: var(--space-6); + z-index: var(--z-toast); + display: flex; + flex-direction: column; + gap: var(--space-2); + pointer-events: none; + max-width: 24rem; +} + +.sui-toast { + pointer-events: auto; + display: flex; + align-items: flex-start; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-left-width: 3px; + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + animation: fadeInUp var(--motion-enter) both; +} + +.sui-toast--info { + border-left-color: var(--color-blue); +} +.sui-toast--success { + border-left-color: var(--color-green); +} +.sui-toast--warning { + border-left-color: var(--color-amber); +} +.sui-toast--danger { + border-left-color: var(--color-red); +} + +.sui-toast__body { + flex: 1 1 auto; + min-width: 0; + font-size: 0.8125rem; +} +.sui-toast__title { + font-weight: 600; + color: var(--color-text-1); +} +.sui-toast__desc { + color: var(--color-text-3); + margin-top: 0.125rem; + line-height: 1.45; +} + +.sui-toast__close { + flex: 0 0 auto; + width: 1.25rem; + height: 1.25rem; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); + color: var(--color-text-4); + font-size: 1rem; + line-height: 1; +} +.sui-toast__close:hover { + background: var(--color-bg-hover); + color: var(--color-text-1); +} diff --git a/frontend/shared/components/Toast.stories.tsx b/frontend/shared/components/Toast.stories.tsx new file mode 100644 index 000000000..4c599bc9f --- /dev/null +++ b/frontend/shared/components/Toast.stories.tsx @@ -0,0 +1,71 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ToastProvider, useToast } from "@shared/components/Toast"; +import { Button } from "@shared/components/Button"; + +function Trigger() { + const { toast } = useToast(); + return ( +
+ + + + +
+ ); +} + +const meta: Meta = { + title: "Primitives/Toast", + parameters: { layout: "padded" }, + decorators: [ + (S) => ( + + + + ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Triggers: Story = { render: () => }; diff --git a/frontend/shared/components/Toast.tsx b/frontend/shared/components/Toast.tsx new file mode 100644 index 000000000..9dd9a00ff --- /dev/null +++ b/frontend/shared/components/Toast.tsx @@ -0,0 +1,141 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { createPortal } from "react-dom"; +import "@shared/components/Toast.css"; + +export type ToastTone = "info" | "success" | "warning" | "danger"; + +export interface ToastOptions { + tone?: ToastTone; + title?: ReactNode; + description?: ReactNode; + /** Auto-dismiss timeout in ms. Defaults to 4000. Pass 0 to keep open. */ + durationMs?: number; +} + +interface ToastEntry extends ToastOptions { + id: number; +} + +interface ToastContextValue { + toast: (options: ToastOptions) => number; + dismiss: (id: number) => void; +} + +const ToastContext = createContext(null); + +export function useToast(): ToastContextValue { + const ctx = useContext(ToastContext); + if (!ctx) throw new Error("useToast must be used inside "); + return ctx; +} + +export function ToastProvider({ children }: { children: ReactNode }) { + const [entries, setEntries] = useState([]); + const nextIdRef = useRef(1); + + const dismiss = useCallback((id: number) => { + setEntries((prev) => prev.filter((e) => e.id !== id)); + }, []); + + const toast = useCallback( + (options: ToastOptions) => { + const id = nextIdRef.current++; + const entry: ToastEntry = { + tone: "info", + durationMs: 4000, + ...options, + id, + }; + setEntries((prev) => [...prev, entry]); + if ((entry.durationMs ?? 0) > 0) { + window.setTimeout(() => dismiss(id), entry.durationMs); + } + return id; + }, + [dismiss], + ); + + const value = useMemo( + () => ({ toast, dismiss }), + [toast, dismiss], + ); + + return ( + + {children} + + + ); +} + +function ToastViewport({ + entries, + onDismiss, +}: { + entries: ToastEntry[]; + onDismiss: (id: number) => void; +}) { + // Render through a portal so toasts always sit above any in-flow stacking + // context. SSR-safe by checking for document existence. + if (typeof document === "undefined") return null; + return createPortal( +
+ {entries.map((entry) => ( + + ))} +
, + document.body, + ); +} + +function ToastItem({ + entry, + onDismiss, +}: { + entry: ToastEntry; + onDismiss: (id: number) => void; +}) { + useEffect(() => { + // Allow Escape to dismiss the most recently focused toast. + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") onDismiss(entry.id); + } + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [entry.id, onDismiss]); + + return ( +
+
+ {entry.title &&
{entry.title}
} + {entry.description && ( +
{entry.description}
+ )} +
+ +
+ ); +} diff --git a/frontend/shared/components/ToggleSwitch.css b/frontend/shared/components/ToggleSwitch.css new file mode 100644 index 000000000..f5025ebfb --- /dev/null +++ b/frontend/shared/components/ToggleSwitch.css @@ -0,0 +1,78 @@ +.sui-toggle { + display: inline-flex; + align-items: center; + gap: 0.625rem; + cursor: pointer; + user-select: none; +} +.sui-toggle input { + position: absolute; + opacity: 0; + width: 0.0625rem; + height: 0.0625rem; +} + +.sui-toggle__track { + display: inline-block; + width: 2.25rem; + height: 1.25rem; + background: var(--color-toggle-off); + border-radius: 0.75rem; + position: relative; + transition: background var(--motion-base); + flex-shrink: 0; +} +.sui-toggle__thumb { + position: absolute; + top: 0.125rem; + left: 0.125rem; + width: 1rem; + height: 1rem; + background: #fff; + border-radius: 50%; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25); + transition: transform var(--motion-base); +} +.sui-toggle input:checked + .sui-toggle__track { + background: var(--color-blue); +} +.sui-toggle input:checked + .sui-toggle__track .sui-toggle__thumb { + transform: translateX(1rem); +} + +.sui-toggle--sm .sui-toggle__track { + width: 1.875rem; + height: 1rem; +} +.sui-toggle--sm .sui-toggle__thumb { + width: 0.75rem; + height: 0.75rem; +} +.sui-toggle--sm input:checked + .sui-toggle__track .sui-toggle__thumb { + transform: translateX(0.875rem); +} + +.sui-toggle.is-disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.sui-toggle__text { + display: inline-flex; + flex-direction: column; + gap: 0.125rem; +} +.sui-toggle__label { + font-size: 0.8125rem; + font-weight: 500; + color: var(--color-text-2); +} +.sui-toggle__desc { + font-size: 0.75rem; + color: var(--color-text-4); +} + +.sui-toggle input:focus-visible + .sui-toggle__track { + outline: 2px solid var(--color-blue); + outline-offset: 2px; +} diff --git a/frontend/shared/components/ToggleSwitch.stories.tsx b/frontend/shared/components/ToggleSwitch.stories.tsx new file mode 100644 index 000000000..9dc2f0abd --- /dev/null +++ b/frontend/shared/components/ToggleSwitch.stories.tsx @@ -0,0 +1,89 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ToggleSwitch } from "@shared/components/ToggleSwitch"; + +const meta: Meta = { + title: "Primitives/ToggleSwitch", + component: ToggleSwitch, + tags: ["autodocs"], + parameters: { layout: "centered" }, + args: { + checked: true, + label: "Encryption at rest", + size: "md", + disabled: false, + }, + argTypes: { + size: { control: "inline-radio", options: ["sm", "md"] }, + disabled: { control: "boolean" }, + checked: { control: "boolean" }, + }, + render: (args) => { + function Bound() { + const [on, setOn] = useState(args.checked); + return ; + } + return ; + }, +}; +export default meta; +type Story = StoryObj; + +/** Flip size / checked / disabled / label / description in controls. */ +export const Playground: Story = {}; + +export const WithDescription: Story = { + args: { + label: "Auto-promote on golden set pass", + description: + "When the eval set passes, the new version is promoted automatically.", + }, +}; + +export const InContext_SettingsRows: Story = { + parameters: { layout: "padded" }, + render: () => { + function Bound() { + const [a, setA] = useState(true); + const [b, setB] = useState(true); + const [c, setC] = useState(false); + const [d, setD] = useState(false); + return ( +
+ + + + +
+ ); + } + return ; + }, +}; diff --git a/frontend/shared/components/ToggleSwitch.tsx b/frontend/shared/components/ToggleSwitch.tsx new file mode 100644 index 000000000..a921e763c --- /dev/null +++ b/frontend/shared/components/ToggleSwitch.tsx @@ -0,0 +1,59 @@ +import { useId } from "react"; +import "@shared/components/ToggleSwitch.css"; + +export interface ToggleSwitchProps { + checked: boolean; + onChange: (checked: boolean) => void; + /** Accessible label associated to the control. */ + label?: string; + /** Optional helper text rendered next to the label. */ + description?: string; + disabled?: boolean; + size?: "sm" | "md"; + id?: string; +} + +/** + * 36×20 pill toggle, matched to the prototype's SettingsRow control. + * + * Pair with the {@link SettingsRow} component (or a custom row) when you need + * a label + description layout. Standalone, this is just the switch itself. + */ +export function ToggleSwitch({ + checked, + onChange, + label, + description, + disabled, + size = "md", + id, +}: ToggleSwitchProps) { + const autoId = useId(); + const controlId = id ?? autoId; + return ( + + ); +} diff --git a/frontend/shared/components/index.ts b/frontend/shared/components/index.ts new file mode 100644 index 000000000..1e1a26b30 --- /dev/null +++ b/frontend/shared/components/index.ts @@ -0,0 +1,38 @@ +export * from "@shared/components/Button"; +export * from "@shared/components/StatusBadge"; +export * from "@shared/components/MethodBadge"; +export * from "@shared/components/ToggleSwitch"; +export * from "@shared/components/ProgressBar"; +export * from "@shared/components/MetricCard"; +export * from "@shared/components/NavItem"; +export * from "@shared/components/PanelHeader"; +export * from "@shared/components/CodeBlock"; +export * from "@shared/components/SectionDivider"; +export * from "@shared/components/Card"; +export * from "@shared/components/Modal"; + +// Layout +export * from "@shared/components/Stack"; +export * from "@shared/components/Inline"; + +// Feedback +export * from "@shared/components/Spinner"; +export * from "@shared/components/Skeleton"; +export * from "@shared/components/Avatar"; +export * from "@shared/components/Chip"; +export * from "@shared/components/EmptyState"; +export * from "@shared/components/Banner"; +export * from "@shared/components/Toast"; + +// Compound +export * from "@shared/components/Tabs"; +export * from "@shared/components/Dropdown"; +export * from "@shared/components/Drawer"; + +// Forms +export * from "@shared/components/FormField"; +export * from "@shared/components/Input"; +export * from "@shared/components/Select"; +export * from "@shared/components/Checkbox"; +export * from "@shared/components/Radio"; +export * from "@shared/components/Slider"; diff --git a/frontend/shared/data/Endpoints.stories.tsx b/frontend/shared/data/Endpoints.stories.tsx new file mode 100644 index 000000000..53d64816a --- /dev/null +++ b/frontend/shared/data/Endpoints.stories.tsx @@ -0,0 +1,116 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { VERTICALS, ALL_ENDPOINTS } from "@shared/data/endpoints"; +import { MethodBadge } from "@shared/components/MethodBadge"; +import { StatusBadge } from "@shared/components/StatusBadge"; + +const meta: Meta = { + title: "Data/Endpoint catalogue", + parameters: { + layout: "padded", + docs: { + description: { + component: + "Live preview of the typed endpoint catalogue in src/data/endpoints.ts. Verticals come from VERTICALS, the flat list from ALL_ENDPOINTS.", + }, + }, + }, +}; +export default meta; +type Story = StoryObj; + +export const ByVertical: Story = { + render: () => ( +
+
+ {ALL_ENDPOINTS.length} endpoints across {VERTICALS.length} verticals +
+ {VERTICALS.map((v) => ( +
+
+ +

+ {v.label} +

+ + {v.endpoints.length} endpoints + +
+
+ {v.endpoints.map((e) => ( +
+ + + {e.endpoint} + +
+
+ {e.name} +
+
+ {e.desc} +
+
+ + {e.tier === 0 ? "free" : e.tier === 1 ? "paid" : "enterprise"} + +
+ ))} +
+
+ ))} +
+ ), +}; diff --git a/frontend/shared/data/Ops.stories.tsx b/frontend/shared/data/Ops.stories.tsx new file mode 100644 index 000000000..5be51159d --- /dev/null +++ b/frontend/shared/data/Ops.stories.tsx @@ -0,0 +1,306 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { + PIPELINE_OPS, + LIBRARY_OPS, + OP_CATEGORIES, + PIPELINE_AGENTS, + SOURCE_OPTIONS, + DESTINATION_OPTIONS, + type OpKind, +} from "@shared/data/ops"; + +const meta: Meta = { + title: "Data/Ops library", + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +const STAGE_ORDER: OpKind[] = [ + "ingest", + "validate", + "modify", + "secure", + "store", + "alert", +]; +const STAGE_COLOUR: Record = { + ingest: "var(--color-green)", + validate: "var(--color-blue)", + modify: "#F97316", + secure: "var(--color-red)", + store: "var(--color-purple)", + alert: "var(--color-amber)", +}; + +export const PipelineOps: Story = { + render: () => ( +
+ {STAGE_ORDER.map((stage) => ( +
+
+ +

+ {stage} +

+ + {PIPELINE_OPS[stage].length} ops + +
+
+ {PIPELINE_OPS[stage].map((op) => ( + + {op.label} + {op.defaultOn && ( + + · default + + )} + + ))} +
+
+ ))} +
+ ), +}; + +export const LibraryByCategory: Story = { + render: () => ( +
+
+ {LIBRARY_OPS.length} library ops across {OP_CATEGORIES.length}{" "} + categories +
+ {OP_CATEGORIES.map((cat) => { + const ops = LIBRARY_OPS.filter((op) => op.category === cat.name); + return ( +
+
+ +

+ {cat.name} +

+ + {cat.blurb} + + + {ops.length} + +
+
+ {ops.map((op) => ( + + {op.label} + {op.provider && ( + + · {op.provider} + + )} + + ))} +
+
+ ); + })} +
+ ), +}; + +export const Agents: Story = { + render: () => ( +
+ {PIPELINE_AGENTS.map((a) => ( +
+
+ + {a.label} + +
+
+ {a.desc} +
+
+ {a.ops.map((op) => ( + + {op} + + ))} +
+
+ ))} +
+ ), +}; + +export const SourcesAndDestinations: Story = { + render: () => ( +
+
+

Sources

+
+ {SOURCE_OPTIONS.map((s) => ( +
+
{s.label}
+
+ {s.desc} +
+
+ ))} +
+
+
+

Destinations

+
+ {DESTINATION_OPTIONS.map((d) => ( +
+
{d.label}
+
+ {d.desc} +
+
+ ))} +
+
+
+ ), +}; diff --git a/frontend/shared/data/endpoints.ts b/frontend/shared/data/endpoints.ts new file mode 100644 index 000000000..80380225d --- /dev/null +++ b/frontend/shared/data/endpoints.ts @@ -0,0 +1,1270 @@ +/** + * The Stirling endpoint catalogue. + * + * Document types are grouped by vertical (Insurance, Finance, Legal, …). + * Each entry carries the route path, tier gate, JSON-shape schema, supported + * regions, and a category accent. The catalogue is the spine the Docs view, + * the Pipelines composer, and the Document type grid on Home all read from. + * + * Ported faithfully from the prototype's DOCUMENT_TYPES. Schema field shapes + * are kept as the prototype-style "type-or-list-of-type" string for now; a + * future revision should convert them to Zod schemas so they double as + * runtime validators. + */ + +import type { Tier } from "@shared/tokens/tokens"; + +/** Canonical vertical keys — these map 1:1 to category-accent CSS variables. */ +export type VerticalKey = + | "insurance" + | "compliance" + | "finance" + | "legal" + | "healthcare" + | "government" + | "operations" + | "hr" + | "realestate" + | "energy"; + +/** + * Numeric tier requirement on an endpoint. + * 0 = free + * 1 = paid (pro / pay-as-you-go and above) + * 2 = enterprise only + * + * Use `isEndpointAvailable(endpoint, currentTier)` for runtime checks rather + * than comparing numbers directly. + */ +export type EndpointTierGate = 0 | 1 | 2; + +export interface EndpointSchema { + /** + * Prototype-style field shape. Strings like "string", "number", "date", + * "boolean", "string?" (optional), "[string]" (array), or an inline shape + * "{start, end}". Replace with a Zod schema when wiring up real requests. + */ + [field: string]: string; +} + +export interface Endpoint { + /** Display name shown in cards and docs. */ + name: string; + /** Route path mounted under the Stirling API. */ + endpoint: string; + /** Numeric tier gate, see {@link EndpointTierGate}. */ + tier: EndpointTierGate; + /** One-line description used in cards and docs index. */ + desc: string; + /** Vertical this endpoint belongs to. */ + vertical: VerticalKey; + /** Region availability list. "+22" tail means "and N more regions". */ + regions: readonly string[]; + /** Endpoint payload shape. */ + schema: EndpointSchema; +} + +export interface Vertical { + key: VerticalKey; + label: string; + /** CSS-variable reference for the category accent. */ + color: string; + endpoints: readonly Endpoint[]; +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Catalogue */ +/* ──────────────────────────────────────────────────────────────────────── */ + +const insurance: readonly Endpoint[] = [ + { + name: "Certificates of Insurance", + endpoint: "/v1/coi", + tier: 1, + vertical: "insurance", + desc: "Live compliance dashboard with coverage gaps, renewal alerts, and AI Q&A for policy verification", + regions: ["US", "UK", "CA", "AU", "+22"], + schema: { + holder: "string", + insurer: "string", + policy_number: "string", + coverage_types: "[string]", + general_aggregate: "number", + expiry_date: "date", + additional_insured: "boolean", + }, + }, + { + name: "Loss Run Reports", + endpoint: "/v1/loss-run", + tier: 1, + vertical: "insurance", + desc: "Claims timeline with loss ratio trends, carrier comparisons, and underwriting AI chat", + regions: ["US", "UK", "CA"], + schema: { + carrier: "string", + policy_period: "string", + total_claims: "number", + total_incurred: "number", + loss_ratio: "number", + claims: "[{date, type, incurred, status}]", + }, + }, + { + name: "ACORD Forms", + endpoint: "/v1/acord", + tier: 1, + vertical: "insurance", + desc: "Multi-carrier coverage view with ACORD compliance badges and reconciliation actions", + regions: ["US"], + schema: { + form_number: "string", + producer: "string", + insured: "string", + carriers: "[{name, naic, policy}]", + coverages: "[{type, limit, deductible}]", + effective_date: "date", + }, + }, + { + name: "Declarations Pages", + endpoint: "/v1/dec-page", + tier: 1, + vertical: "insurance", + desc: "Coverage summary with gap highlights, endorsement changes, and premium calculator", + regions: ["US", "UK", "CA", "AU"], + schema: { + named_insured: "string", + policy_number: "string", + effective: "date", + expiry: "date", + coverages: "[{type, limit, deductible, premium}]", + total_premium: "number", + endorsements: "[string]", + }, + }, + { + name: "Explanation of Benefits", + endpoint: "/v1/eob", + tier: 1, + vertical: "insurance", + desc: "Benefit breakdown with denial codes, patient cost visibility, and billing action buttons", + regions: ["US"], + schema: { + patient: "string", + provider: "string", + service_date: "date", + services: "[{code, description, billed, allowed, paid, patient_resp}]", + denial_codes: "[string]?", + total_patient_owed: "number", + }, + }, + { + name: "Subrogation Notices", + endpoint: "/v1/subrogation", + tier: 1, + vertical: "insurance", + desc: "Recovery opportunity tracker with status updates, lien tracking, and settlement AI", + regions: ["US", "UK"], + schema: { + claim_number: "string", + claimant: "string", + liable_party: "string", + loss_date: "date", + recovery_sought: "number", + lien_amount: "number?", + status: "string", + }, + }, + { + name: "Surety Bonds", + endpoint: "/v1/surety-bond", + tier: 1, + vertical: "insurance", + desc: "Bond status dashboard showing obligee requirements, renewal dates, and compliance checklist", + regions: ["US", "UK", "CA", "AU", "+14"], + schema: { + bond_number: "string", + principal: "string", + obligee: "string", + surety: "string", + bond_amount: "number", + effective_date: "date", + expiry_date: "date", + bond_type: "string", + }, + }, +]; + +const compliance: readonly Endpoint[] = [ + { + name: "SOC 2 Reports", + endpoint: "/v1/soc2", + tier: 1, + vertical: "compliance", + desc: "Control assessment view with exception severity ratings, trust criteria status, and remediation notes", + regions: ["US", "Global"], + schema: { + report_type: "string", + service_org: "string", + auditor: "string", + period: "{start, end}", + opinion: "string", + exceptions: "[{control, description}]", + trust_criteria: "[string]", + }, + }, + { + name: "Regulatory Filings", + endpoint: "/v1/regulatory-filing", + tier: 1, + vertical: "compliance", + desc: "Multi-jurisdiction filing calendar with deadline alerts, status tracking, and submission buttons", + regions: ["US", "UK", "EU", "SG", "HK", "+31"], + schema: { + filing_type: "string", + jurisdiction: "string", + entity: "string", + filing_date: "date", + deadline: "date", + status: "string", + reference_number: "string?", + }, + }, + { + name: "Consent Orders", + endpoint: "/v1/consent-order", + tier: 1, + vertical: "compliance", + desc: "Violation timeline with remediation progress bars, deadline countdowns, and compliance history", + regions: ["US", "UK", "EU"], + schema: { + regulatory_body: "string", + respondent: "string", + order_date: "date", + violations: "[{statute, description}]", + penalties: "number", + remediation: "[string]", + compliance_deadline: "date", + }, + }, + { + name: "GDPR Processing Agreements", + endpoint: "/v1/dpa", + tier: 1, + vertical: "compliance", + desc: "Data processing overview with transfer mechanism validation, sub-processor registry, and approval flow", + regions: ["EU", "UK", "CH", "Global"], + schema: { + controller: "string", + processor: "string", + data_categories: "[string]", + purposes: "[string]", + sub_processors: "[{name, location}]", + transfer_mechanism: "string", + retention_period: "string", + }, + }, + { + name: "Sanctions Screening Results", + endpoint: "/v1/sanctions-screen", + tier: 1, + vertical: "compliance", + desc: "Real-time screening results with match confidence scores and one-click disposition", + regions: ["US", "UK", "EU", "Global"], + schema: { + screened_entity: "string", + lists_checked: "[string]", + matches: "[{list, name, score, type}]", + overall_risk: "string", + disposition: "string", + screened_date: "date", + }, + }, + { + name: "ISO Audit Reports", + endpoint: "/v1/iso-audit", + tier: 1, + vertical: "compliance", + desc: "Audit findings organized by severity with remediation timelines and compliance tracking", + regions: ["Global"], + schema: { + standard: "string", + audit_type: "string", + auditor: "string", + organization: "string", + nonconformities: "[{clause, severity, finding}]", + observations: "[string]", + recommendation: "string", + }, + }, + { + name: "Incident Reports", + endpoint: "/v1/incident-report", + tier: 1, + vertical: "compliance", + desc: "Incident severity dashboard with root cause analysis, remediation tracking, and escalation status", + regions: ["Global"], + schema: { + incident_id: "string", + date: "date", + severity: "string", + category: "string", + description: "string", + root_cause: "string?", + remediation: "string?", + reported_by: "string", + }, + }, +]; + +const finance: readonly Endpoint[] = [ + { + name: "Letters of Credit", + endpoint: "/v1/loc", + tier: 1, + vertical: "finance", + desc: "LC tracker with condition verification, expiry alerts, and cash call risk assessment", + regions: ["US", "UK", "HK", "SG", "+34"], + schema: { + lc_number: "string", + issuing_bank: "string", + applicant: "string", + beneficiary: "string", + amount: "number", + currency: "string", + conditions: "[string]", + expiry_date: "date", + type: "string", + }, + }, + { + name: "Trade Confirmations", + endpoint: "/v1/trade-confirm", + tier: 1, + vertical: "finance", + desc: "Trade confirmation with counterparty validation, settlement date verification, and matching status", + regions: ["US", "UK", "EU", "HK", "JP", "+18"], + schema: { + trade_date: "date", + settlement_date: "date", + security: "string", + identifier: "string", + quantity: "number", + price: "number", + counterparty: "string", + account: "string", + }, + }, + { + name: "Fund Prospectuses", + endpoint: "/v1/prospectus", + tier: 1, + vertical: "finance", + desc: "Fund profile with fee breakdown, risk level compliance checks, and mandate alignment flags", + regions: ["US", "UK", "EU", "LU", "+12"], + schema: { + fund_name: "string", + fund_type: "string", + objective: "string", + expense_ratio: "number", + risk_level: "string", + min_investment: "number", + performance: "[{period, return}]", + manager: "string", + }, + }, + { + name: "K-1 Schedules", + endpoint: "/v1/k1", + tier: 1, + vertical: "finance", + desc: "Partnership income view with equity reconciliation, distribution tracking, and tax line mapping", + regions: ["US"], + schema: { + partnership: "string", + partner: "string", + tax_year: "number", + ordinary_income: "number", + rental_income: "number?", + interest_income: "number?", + capital_gains: "number?", + distributions: "number", + capital_account: "number", + }, + }, + { + name: "10-K / Annual Reports", + endpoint: "/v1/10k", + tier: 1, + vertical: "finance", + desc: "Financial summary with risk factor highlights, year-over-year comparisons, and segment performance", + regions: ["US", "UK", "EU", "JP", "+8"], + schema: { + entity: "string", + fiscal_year: "number", + revenue: "number", + net_income: "number", + total_assets: "number", + risk_factors: "[string]", + segments: "[{name, revenue}]", + auditor: "string", + }, + }, + { + name: "Bank Statements", + endpoint: "/v1/bank-statement", + tier: 1, + vertical: "finance", + desc: "Transaction timeline with anomaly detection, balance reconciliation, and cash flow insights", + regions: ["US", "UK", "EU", "CA", "AU", "+41"], + schema: { + institution: "string", + account_last_4: "string", + period: "{start, end}", + opening_balance: "number", + closing_balance: "number", + transactions: "[{date, desc, amount, balance}]", + }, + }, + { + name: "Invoices", + endpoint: "/v1/invoice", + tier: 1, + vertical: "finance", + desc: "Three-way match status, approval actions, and AI chat for line-item disputes", + regions: ["US", "UK", "EU", "DE", "JP", "BR", "+52"], + schema: { + vendor_name: "string", + invoice_number: "string", + date: "date", + line_items: "[{desc, qty, unit_price, total}]", + subtotal: "number", + tax: "number", + total: "number", + payment_terms: "string", + }, + }, +]; + +const legal: readonly Endpoint[] = [ + { + name: "Contracts", + endpoint: "/v1/contract", + tier: 1, + vertical: "legal", + desc: "Clause-by-clause risk highlights, renewal countdown, and negotiation AI", + regions: ["Global"], + schema: { + parties: "[{name, role}]", + effective_date: "date", + term_months: "number", + renewal: "string", + governing_law: "string", + signature_status: "string", + }, + }, + { + name: "Court Filings", + endpoint: "/v1/court-filing", + tier: 1, + vertical: "legal", + desc: "Docket event timeline with deadline alerts, motion status, and counsel response tracking", + regions: ["US", "UK", "CA", "AU", "+16"], + schema: { + case_number: "string", + court: "string", + parties: "[{name, role}]", + filing_date: "date", + document_class: "string", + docket_entries: "[{date, description}]", + }, + }, + { + name: "Corporate Formation Docs", + endpoint: "/v1/corp-formation", + tier: 1, + vertical: "legal", + desc: "Entity status dashboard with good standing checks, officer compliance, and filing tracker", + regions: ["US", "UK", "DE", "SG", "HK", "+28"], + schema: { + entity_name: "string", + entity_type: "string", + jurisdiction: "string", + formation_date: "date", + officers: "[{name, title}]", + registered_agent: "string", + authorized_shares: "number?", + }, + }, + { + name: "UCC Filings", + endpoint: "/v1/ucc", + tier: 1, + vertical: "legal", + desc: "UCC status view with lapse countdown, priority verification, and collateral tracking", + regions: ["US"], + schema: { + filing_number: "string", + filing_date: "date", + secured_party: "string", + debtor: "string", + collateral: "string", + filing_office: "string", + status: "string", + lapse_date: "date", + }, + }, + { + name: "Powers of Attorney", + endpoint: "/v1/poa", + tier: 1, + vertical: "legal", + desc: "Agent authority scope with durable status verification, witness records, and action permissions", + regions: ["US", "UK", "EU", "Global"], + schema: { + principal: "string", + agent: "string", + scope: "[string]", + effective_date: "date", + durable: "boolean", + expiry_date: "date?", + witnesses: "[string]", + }, + }, + { + name: "Patent Applications", + endpoint: "/v1/patent", + tier: 1, + vertical: "legal", + desc: "Patent prosecution timeline with office action tracking, fee deadlines, and claim status", + regions: ["US", "EU", "JP", "CN", "KR", "+8"], + schema: { + application_number: "string", + title: "string", + inventors: "[string]", + assignee: "string", + priority_date: "date", + classifications: "[string]", + claims_count: "number", + status: "string", + }, + }, +]; + +const healthcare: readonly Endpoint[] = [ + { + name: "Prior Authorizations", + endpoint: "/v1/prior-auth", + tier: 1, + vertical: "healthcare", + desc: "Decision tracking, medical necessity AI, and automatic payer follow-up notifications", + regions: ["US"], + schema: { + patient_id: "string", + provider_npi: "string", + payer: "string", + procedure_codes: "[string]", + diagnosis_codes: "[string]", + medical_necessity: "string", + decision: "string", + auth_number: "string?", + }, + }, + { + name: "Discharge Summaries", + endpoint: "/v1/discharge", + tier: 1, + vertical: "healthcare", + desc: "Discharge plan with follow-up order checklist, medication list, and continuity of care actions", + regions: ["US", "UK", "CA", "AU"], + schema: { + patient_id: "string", + admission_date: "date", + discharge_date: "date", + diagnoses: "[{code, description}]", + procedures: "[string]", + medications: "[{name, dose, frequency}]", + follow_up: "[string]", + }, + }, + { + name: "Clinical Trial Documents", + endpoint: "/v1/clinical-trial", + tier: 1, + vertical: "healthcare", + desc: "Trial progress view with enrollment metrics, site performance, and endpoint tracking", + regions: ["US", "EU", "UK", "JP", "+12"], + schema: { + trial_id: "string", + phase: "string", + sponsor: "string", + indication: "string", + primary_endpoint: "string", + enrollment: "number", + sites: "number", + status: "string", + }, + }, + { + name: "FDA Submissions", + endpoint: "/v1/fda-submission", + tier: 1, + vertical: "healthcare", + desc: "Submission status timeline with decision tracking, reviewer feedback, and compliance alerts", + regions: ["US"], + schema: { + submission_type: "string", + submission_number: "string", + applicant: "string", + product_name: "string", + classification: "string", + predicate_device: "string?", + decision: "string", + decision_date: "date?", + }, + }, + { + name: "Pathology Reports", + endpoint: "/v1/pathology", + tier: 1, + vertical: "healthcare", + desc: "Pathology summary with diagnostic alerts, biomarker results, and tumor board routing", + regions: ["US", "UK", "EU", "Global"], + schema: { + patient_id: "string", + specimen_type: "string", + diagnosis: "string", + staging: "string?", + margins: "string", + biomarkers: "[{marker, result}]", + pathologist: "string", + }, + }, + { + name: "Patient Intake Forms", + endpoint: "/v1/patient-intake", + tier: 1, + vertical: "healthcare", + desc: "Patient profile with allergy alerts, medication conflict detection, and insurance verification", + regions: ["US", "UK", "CA", "AU", "+18"], + schema: { + patient_name: "string", + dob: "date", + insurance_provider: "string", + policy_number: "string", + allergies: "[string]", + medications: "[string]", + chief_complaint: "string", + }, + }, +]; + +const government: readonly Endpoint[] = [ + { + name: "Tax Forms", + endpoint: "/v1/tax-form", + tier: 1, + vertical: "government", + desc: "Tax liability view with filing deadline alerts, jurisdiction tracker, and e-file actions", + regions: ["US", "UK", "DE", "FR", "BR", "JP", "IN", "+43"], + schema: { + form_type: "string", + jurisdiction: "string", + tax_year: "number", + entity: "string", + gross_income: "number", + tax_withheld: "number", + filing_status: "string", + }, + }, + { + name: "Permits & Licenses", + endpoint: "/v1/permit", + tier: 1, + vertical: "government", + desc: "Permit dashboard showing status, conditions, renewal dates, and compliance checklist", + regions: ["US", "UK", "EU", "CA", "AU", "+32"], + schema: { + permit_type: "string", + permit_number: "string", + issuing_authority: "string", + holder: "string", + issue_date: "date", + expiry_date: "date", + conditions: "[string]", + status: "string", + }, + }, + { + name: "Grant Applications", + endpoint: "/v1/grant", + tier: 1, + vertical: "government", + desc: "Grant status with approval workflow, funding milestones, and compliance audit tracking", + regions: ["US", "UK", "EU"], + schema: { + applicant: "string", + program: "string", + amount_requested: "number", + project_title: "string", + period: "{start, end}", + budget_categories: "[{category, amount}]", + status: "string", + }, + }, + { + name: "FOIA Responses", + endpoint: "/v1/foia", + tier: 1, + vertical: "government", + desc: "Request status with exemption review, redaction tracker, and disclosure timeline", + regions: ["US", "UK", "CA", "AU"], + schema: { + request_number: "string", + agency: "string", + requester: "string", + responsive_pages: "number", + redacted_pages: "number", + exemptions_cited: "[string]", + disposition: "string", + }, + }, + { + name: "Municipal Filings", + endpoint: "/v1/municipal-filing", + tier: 1, + vertical: "government", + desc: "Filing timeline with hearing date alerts, public comment deadline, and status updates", + regions: ["US", "UK", "CA", "+18"], + schema: { + filing_type: "string", + municipality: "string", + applicant: "string", + property_address: "string?", + date: "date", + status: "string", + hearing_date: "date?", + }, + }, + { + name: "Customs Declarations", + endpoint: "/v1/customs", + tier: 1, + vertical: "government", + desc: "Customs entry view with HS code mapping, duty calculation, and tariff classification AI", + regions: ["US", "UK", "EU", "CN", "JP", "SG", "+48"], + schema: { + entry_number: "string", + importer: "string", + country_origin: "string", + items: "[{hs_code, description, value, duty}]", + total_value: "number", + total_duty: "number", + port: "string", + }, + }, +]; + +const operations: readonly Endpoint[] = [ + { + name: "Bills of Lading", + endpoint: "/v1/bol", + tier: 1, + vertical: "operations", + desc: "Shipping document view with incoterm validation, port tracking, and cargo checklist", + regions: ["Global"], + schema: { + bol_number: "string", + shipper: "string", + consignee: "string", + carrier: "string", + vessel: "string?", + port_loading: "string", + port_discharge: "string", + goods: "[{description, weight, packages}]", + }, + }, + { + name: "Certificates of Origin", + endpoint: "/v1/cert-origin", + tier: 1, + vertical: "operations", + desc: "Certificate details with tariff eligibility verification, rules of origin check, and compliance badges", + regions: ["US", "EU", "UK", "MX", "CA", "+38"], + schema: { + certificate_number: "string", + exporter: "string", + importer: "string", + country_origin: "string", + goods: "[{description, hs_code, value}]", + trade_agreement: "string?", + issued_by: "string", + }, + }, + { + name: "Warehouse Receipts", + endpoint: "/v1/warehouse-receipt", + tier: 1, + vertical: "operations", + desc: "Warehouse inventory view with lien holder tracking, withdrawal requests, and storage timeline", + regions: ["US", "UK", "EU", "+22"], + schema: { + receipt_number: "string", + warehouse: "string", + depositor: "string", + goods: "[{description, quantity, unit}]", + storage_date: "date", + location: "string", + lien_status: "string", + }, + }, + { + name: "Inspection Reports", + endpoint: "/v1/inspection", + tier: 1, + vertical: "operations", + desc: "Inspection findings with remediation actions, re-audit scheduling, and compliance status", + regions: ["Global"], + schema: { + inspector: "string", + date: "date", + location: "string", + findings: "[{item, status, note}]", + overall_status: "string", + next_inspection: "date?", + }, + }, + { + name: "Purchase Orders", + endpoint: "/v1/purchase-order", + tier: 1, + vertical: "operations", + desc: "PO status with approval workflow, invoice matching, and delivery tracking buttons", + regions: ["Global"], + schema: { + po_number: "string", + vendor: "string", + date: "date", + line_items: "[{desc, qty, unit_price, total}]", + total: "number", + delivery_terms: "string", + approved_by: "string?", + }, + }, + { + name: "Packing Lists", + endpoint: "/v1/packing-list", + tier: 1, + vertical: "operations", + desc: "Shipment manifest with PO reconciliation, receiving report actions, and discrepancy alerts", + regions: ["Global"], + schema: { + shipment_id: "string", + shipper: "string", + packages: "[{package_id, contents, weight, dimensions}]", + total_packages: "number", + total_weight: "string", + po_reference: "string?", + }, + }, +]; + +const hr: readonly Endpoint[] = [ + { + name: "I-9 / Right to Work", + endpoint: "/v1/i9", + tier: 1, + vertical: "hr", + desc: "Verification status with document validity tracking, expiry alerts, and re-verification scheduler", + regions: ["US", "UK", "EU", "AU", "CA"], + schema: { + employee_name: "string", + document_type: "string", + document_number: "string", + expiration_date: "date?", + issuing_authority: "string", + verified: "boolean", + }, + }, + { + name: "Employment Verification Letters", + endpoint: "/v1/employment-verification", + tier: 1, + vertical: "hr", + desc: "Employment history view with title verification, salary details, and underwriting status", + regions: ["US", "UK", "CA", "AU", "+14"], + schema: { + employer: "string", + employee: "string", + start_date: "date", + end_date: "date?", + title: "string", + salary: "number?", + employment_status: "string", + }, + }, + { + name: "Workers Comp Claims", + endpoint: "/v1/workers-comp", + tier: 1, + vertical: "hr", + desc: "Claim status with injury timeline, return-to-work plan, and case management actions", + regions: ["US", "UK", "CA", "AU"], + schema: { + claim_number: "string", + employee: "string", + injury_date: "date", + injury_type: "string", + body_part: "string", + provider: "string", + lost_days: "number", + claim_status: "string", + }, + }, + { + name: "Benefits Enrollment", + endpoint: "/v1/benefits-enrollment", + tier: 1, + vertical: "hr", + desc: "Enrollment status with dependent tracking, plan comparison, and open enrollment countdown", + regions: ["US", "UK", "CA"], + schema: { + employee: "string", + plan_type: "string", + coverage_tier: "string", + dependents: "[{name, relationship}]", + effective_date: "date", + employee_contribution: "number", + }, + }, + { + name: "Separation Agreements", + endpoint: "/v1/separation", + tier: 1, + vertical: "hr", + desc: "Separation terms dashboard with non-compete tracking, severance payment status, and sign-off", + regions: ["US", "UK", "EU", "Global"], + schema: { + employee: "string", + employer: "string", + separation_date: "date", + severance_amount: "number", + non_compete_months: "number?", + release_scope: "string", + consideration_period_days: "number", + }, + }, + { + name: "Resumes / CVs", + endpoint: "/v1/resume", + tier: 1, + vertical: "hr", + desc: "Candidate profile with skill matching, experience summary, and hiring endpoint status", + regions: ["Global"], + schema: { + name: "string", + email: "string", + phone: "string?", + experience: "[{company, title, start, end}]", + education: "[{school, degree, year}]", + skills: "[string]", + }, + }, +]; + +const realestate: readonly Endpoint[] = [ + { + name: "Title Reports", + endpoint: "/v1/title-report", + tier: 1, + vertical: "realestate", + desc: "Title status with defect highlights, lien tracker, and insurance coverage verification", + regions: ["US", "UK", "CA", "AU"], + schema: { + property_address: "string", + owner: "string", + legal_description: "string", + liens: "[{type, holder, amount}]", + easements: "[string]", + exceptions: "[string]", + effective_date: "date", + }, + }, + { + name: "Appraisals", + endpoint: "/v1/appraisal", + tier: 1, + vertical: "realestate", + desc: "Property valuation with comparable analysis, condition assessment, and underwriting routing", + regions: ["US", "UK", "CA", "AU", "+8"], + schema: { + property_address: "string", + appraised_value: "number", + approach: "string", + comparables: "[{address, sale_price, adjustments}]", + condition: "string", + appraiser: "string", + date: "date", + }, + }, + { + name: "Environmental Assessments", + endpoint: "/v1/environmental", + tier: 1, + vertical: "realestate", + desc: "Phase assessment with liability findings, remediation recommendations, and insurance buttons", + regions: ["US", "UK", "EU", "CA"], + schema: { + property_address: "string", + phase: "string", + findings: "[{condition, severity, description}]", + recs: "[{condition, severity}]", + consultant: "string", + date: "date", + clean: "boolean", + }, + }, + { + name: "Lease Abstracts", + endpoint: "/v1/lease-abstract", + tier: 1, + vertical: "realestate", + desc: "Lease timeline with expiration alerts, renewal option tracking, and rent escalation calculator", + regions: ["US", "UK", "EU", "AU", "+12"], + schema: { + tenant: "string", + landlord: "string", + premises: "string", + commencement: "date", + expiry: "date", + base_rent: "number", + escalation: "string", + renewal_options: "[{term, notice_period}]", + }, + }, + { + name: "Closing Disclosures", + endpoint: "/v1/closing-disclosure", + tier: 1, + vertical: "realestate", + desc: "Closing costs view with TRID compliance check, fee comparison, and settlement team routing", + regions: ["US"], + schema: { + borrower: "string", + property: "string", + loan_amount: "number", + interest_rate: "number", + monthly_payment: "number", + closing_costs: "number", + cash_to_close: "number", + closing_date: "date", + }, + }, + { + name: "Zoning Documents", + endpoint: "/v1/zoning", + tier: 1, + vertical: "realestate", + desc: "Zoning summary with use restrictions, setback requirements, and variance application buttons", + regions: ["US", "UK", "CA"], + schema: { + property_address: "string", + zone_class: "string", + permitted_uses: "[string]", + setbacks: "{front, side, rear}", + far: "number", + max_height: "string", + variances: "[string]?", + }, + }, +]; + +const energy: readonly Endpoint[] = [ + { + name: "Environmental Impact Statements", + endpoint: "/v1/eis", + tier: 1, + vertical: "energy", + desc: "Impact assessment with alternatives comparison, mitigation tracking, and public comment dashboard", + regions: ["US", "UK", "EU", "CA", "AU", "+14"], + schema: { + project: "string", + lead_agency: "string", + alternatives: "[{name, description}]", + impacts: "[{resource, severity, mitigation}]", + public_comments: "number", + record_of_decision: "string?", + }, + }, + { + name: "Pipeline Permits", + endpoint: "/v1/pipeline-permit", + tier: 1, + vertical: "energy", + desc: "Permit status with compliance conditions, route visualization, and segment monitoring tools", + regions: ["US", "UK", "CA", "AU"], + schema: { + permit_number: "string", + operator: "string", + route: "string", + capacity: "string", + material: "string", + length_miles: "number", + conditions: "[string]", + expiry_date: "date", + }, + }, + { + name: "NERC Compliance", + endpoint: "/v1/nerc", + tier: 1, + vertical: "energy", + desc: "Violation dashboard organized by severity, remediation plan tracking, and completion status", + regions: ["US", "CA"], + schema: { + entity: "string", + standard: "string", + finding: "string", + severity: "string", + violation_date: "date", + mitigation_plan: "string", + completion_date: "date?", + }, + }, + { + name: "Interconnection Agreements", + endpoint: "/v1/interconnection", + tier: 1, + vertical: "energy", + desc: "Generator connection agreement with network upgrade checklist and commercial operation date tracker", + regions: ["US", "UK", "EU", "AU"], + schema: { + generator: "string", + utility: "string", + capacity_mw: "number", + poi: "string", + voltage: "string", + commercial_operation_date: "date", + term_years: "number", + network_upgrades: "[string]", + }, + }, + { + name: "Well Logs", + endpoint: "/v1/well-log", + tier: 1, + vertical: "energy", + desc: "Well section with formation breakdown, depth tracking, and development planning actions", + regions: ["US", "CA", "UK", "AU", "+12"], + schema: { + well_name: "string", + api_number: "string", + operator: "string", + spud_date: "date", + total_depth: "number", + formations: "[{name, top, bottom, lithology}]", + status: "string", + }, + }, + { + name: "Decommissioning Reports", + endpoint: "/v1/decommissioning", + tier: 1, + vertical: "energy", + desc: "Decommission budget dashboard with remediation items, timeline, and regulatory approval flow", + regions: ["US", "UK", "EU", "CA"], + schema: { + facility: "string", + operator: "string", + asset_count: "number", + estimated_cost: "number", + timeline: "{start, end}", + remediation_items: "[{item, status}]", + regulatory_approval: "string", + }, + }, +]; + +export const VERTICALS: readonly Vertical[] = [ + { + key: "insurance", + label: "Insurance", + color: "var(--color-cat-insurance)", + endpoints: insurance, + }, + { + key: "compliance", + label: "Compliance", + color: "var(--color-cat-compliance)", + endpoints: compliance, + }, + { + key: "finance", + label: "Finance", + color: "var(--color-cat-finance)", + endpoints: finance, + }, + { + key: "legal", + label: "Legal", + color: "var(--color-cat-legal)", + endpoints: legal, + }, + { + key: "healthcare", + label: "Healthcare", + color: "var(--color-cat-healthcare)", + endpoints: healthcare, + }, + { + key: "government", + label: "Government", + color: "var(--color-cat-government)", + endpoints: government, + }, + { + key: "operations", + label: "Supply Chain", + color: "var(--color-cat-operations)", + endpoints: operations, + }, + { key: "hr", label: "HR", color: "var(--color-cat-hr)", endpoints: hr }, + { + key: "realestate", + label: "Real Estate", + color: "var(--color-cat-realestate)", + endpoints: realestate, + }, + { + key: "energy", + label: "Energy & Infrastructure", + color: "var(--color-cat-energy)", + endpoints: energy, + }, +]; + +/** Flat list of every endpoint across all verticals. */ +export const ALL_ENDPOINTS: readonly Endpoint[] = VERTICALS.flatMap( + (v) => v.endpoints, +); + +/** Index by path, e.g. ENDPOINTS_BY_PATH['/v1/coi']. */ +export const ENDPOINTS_BY_PATH: Record = Object.fromEntries( + ALL_ENDPOINTS.map((e) => [e.endpoint, e]), +); + +/** Lookup a vertical by its key. */ +export const lookupVertical = (key: VerticalKey): Vertical | null => + VERTICALS.find((v) => v.key === key) ?? null; + +/** Lookup an endpoint by its route path. */ +export const lookupEndpoint = (path: string): Endpoint | null => + ENDPOINTS_BY_PATH[path] ?? null; + +/** Tier-availability gate used by the docs / cards / picker. */ +export function isEndpointAvailable(endpoint: Endpoint, tier: Tier): boolean { + if (endpoint.tier === 0) return true; + if (endpoint.tier === 1) return tier === "pro" || tier === "enterprise"; + return tier === "enterprise"; +} diff --git a/frontend/shared/data/ops.ts b/frontend/shared/data/ops.ts new file mode 100644 index 000000000..0cb4fa6b0 --- /dev/null +++ b/frontend/shared/data/ops.ts @@ -0,0 +1,1308 @@ +/** + * The Stirling operation library. + * + * Three layers, ported faithfully from the prototype: + * + * 1. PIPELINE_OPS — the canonical pipeline-stage taxonomy. Ops are grouped + * by `OpKind` (ingest / validate / modify / secure / store / alert) which + * drives accent colours in chip rendering. + * + * 2. LIBRARY_OPS — the broader catalogue surfaced in the composer's + * Operations picker. Includes everything in PIPELINE_OPS plus the wider + * PDF tool set (formatting, extraction, AI-powered ops, schema-aware + * ops). Each library op also carries an `OpCategory` for finer-grained + * grouping in the picker UI. + * + * 3. PIPELINE_AGENTS — one-click bundles of related ops (PII Sweep, Trust & + * Verify, Compliance Pack, Format Prep). Selecting an agent expands its + * `ops` list into the chip row. + * + * SOURCE_OPTIONS and DESTINATION_OPTIONS sit alongside as the structural + * rails — every pipeline has exactly one source and one destination. + */ + +/** The pipeline-stage taxonomy a chip-row uses for accent colours. */ +export type OpKind = + | "ingest" + | "validate" + | "modify" + | "secure" + | "store" + | "alert"; + +/** Finer-grained groupings shown in the Operations picker. */ +export type OpCategory = + | "Document Security" + | "Validation" + | "Classification" + | "Document Review" + | "Signing" + | "Page Formatting" + | "Extraction" + | "Removal" + | "Automation" + | "Advanced Formatting" + | "Developer Tools"; + +export interface PipelineOp { + id: string; + label: string; + icon: string; + desc: string; + kind: OpKind; + /** Ship in the default chip row when this op's stage is on. */ + defaultOn?: boolean; + /** Only configurable in the pipeline composer (not as an ad-hoc op). */ + pipelineOnly?: boolean; +} + +export interface LibraryOp extends Omit< + PipelineOp, + "defaultOn" | "pipelineOnly" +> { + category: OpCategory; + provider?: "claude" | "stirling"; +} + +export interface PipelineAgent { + id: string; + label: string; + icon: string; + desc: string; + /** Op IDs from PIPELINE_OPS that this agent expands into. */ + ops: string[]; +} + +export interface SourceOption { + id: "upload" | "webhook" | "s3" | "email" | "scheduled"; + label: string; + icon: string; + desc: string; +} + +export interface DestinationOption { + id: "vault" | "s3" | "webhook" | "pipeline" | "database" | "sftp"; + label: string; + icon: string; + desc: string; +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* PIPELINE_OPS — canonical stages */ +/* ──────────────────────────────────────────────────────────────────────── */ + +export const PIPELINE_OPS: Record = { + ingest: [ + { + id: "ocr", + label: "OCR", + icon: "eye", + kind: "ingest", + desc: "Text-recognize scanned or image-based pages", + }, + { + id: "parse", + label: "Parse", + icon: "layout", + kind: "ingest", + desc: "Reconstruct reading order and layout structure", + }, + { + id: "classify", + label: "Classify", + icon: "layers", + kind: "ingest", + desc: "Identify the document type and return a confidence score", + }, + { + id: "bundle-split", + label: "Bundle split", + icon: "grid", + kind: "ingest", + desc: "Split a multi-document upload into component documents", + }, + { + id: "extract", + label: "Extract", + icon: "sparkles", + kind: "ingest", + desc: "Pull structured fields from the document into a typed schema", + }, + ], + validate: [ + { + id: "validate", + label: "Schema validate", + icon: "shield", + kind: "validate", + desc: "Check required fields, business rules, and coverage gaps against the typed schema", + }, + { + id: "authenticity", + label: "Authenticity", + icon: "check", + kind: "validate", + desc: "Verify the document is genuine (signature, issuer, watermark checks)", + }, + { + id: "tamper-check", + label: "Tamper check", + icon: "alertTriangle", + kind: "validate", + desc: "Detect modifications since signing or last-known-good state", + }, + { + id: "source-auth", + label: "Source auth", + icon: "lock", + kind: "validate", + desc: "Confirm the document came from an authenticated source", + }, + { + id: "counterparty-match", + label: "Counterparty match", + icon: "userPlus", + kind: "validate", + desc: "Match the document counterparty against expected identity", + }, + { + id: "confidence-check", + label: "Confidence bounds", + icon: "activity", + kind: "validate", + desc: "Gate downstream ops on extraction-confidence thresholds", + }, + ], + modify: [ + { + id: "merge", + label: "Merge", + icon: "layers", + kind: "modify", + desc: "Combine multiple PDFs into a single document", + }, + { + id: "split", + label: "Split", + icon: "grid", + kind: "modify", + desc: "Split a PDF into pages, sections, or by document boundary", + }, + { + id: "convert", + label: "Convert", + icon: "fileText", + kind: "modify", + desc: "Convert between PDF, Word, Excel, image, HTML, Markdown", + }, + { + id: "compress", + label: "Compress", + icon: "package", + kind: "modify", + desc: "Reduce file size while preserving fidelity", + }, + { + id: "rotate", + label: "Rotate", + icon: "arrowRight", + kind: "modify", + desc: "Rotate pages or correct page orientation", + }, + { + id: "crop", + label: "Crop", + icon: "layout", + kind: "modify", + desc: "Crop pages to a region or trim margins", + }, + ], + secure: [ + { + id: "redact", + label: "Redact PII", + icon: "eye", + kind: "secure", + defaultOn: true, + desc: "Remove or mask PII before the document is stored or released downstream", + }, + { + id: "pii-enforce", + label: "PII/PHI enforcement", + icon: "shield", + kind: "secure", + desc: "Policy check that outputs do not leak protected data beyond declared scope", + }, + { + id: "watermark", + label: "Confidentiality mark", + icon: "penTool", + kind: "secure", + desc: "Stamp a visible confidentiality / classification watermark onto pages", + }, + { + id: "attribution-watermark", + label: "Attribution watermark", + icon: "penTool", + kind: "secure", + desc: "Invisible per-recipient watermarking for leak tracing", + }, + { + id: "flatten", + label: "Flatten + lock", + icon: "layout", + kind: "secure", + desc: "Flatten forms and annotations to harden the document against tampering", + }, + { + id: "sign-output", + label: "Signed outputs", + icon: "penTool", + kind: "secure", + desc: "Tamper-evident signatures covering the artifact and run metadata", + }, + { + id: "encrypt-rest", + label: "Encryption at rest", + icon: "lock", + kind: "secure", + defaultOn: true, + desc: "AES-256 on stored artifacts. Stirling-managed, customer KMS, or BYOK", + }, + { + id: "retention", + label: "Retention policy", + icon: "fileText", + kind: "secure", + defaultOn: true, + pipelineOnly: true, + desc: "How long Stirling retains the artifact, run record, and audit trail", + }, + { + id: "residency", + label: "Regional residency", + icon: "globe", + kind: "secure", + pipelineOnly: true, + desc: "Where the artifact is stored and processed. Region-pin or air-gap", + }, + { + id: "access-policy", + label: "Access policy", + icon: "key", + kind: "secure", + defaultOn: true, + pipelineOnly: true, + desc: "Who can fetch the sealed artifact (signed URL, IdP-gated, public)", + }, + ], + store: [ + { + id: "store-primary", + label: "Primary store", + icon: "fileText", + kind: "store", + defaultOn: true, + pipelineOnly: true, + desc: "Write the sealed artifact and its run record into the Documents centralized view", + }, + { + id: "mirror-bucket", + label: "Mirror to bucket", + icon: "globe", + kind: "store", + pipelineOnly: true, + desc: "Copy the secured artifact to the customer’s S3 / GCS / Azure Blob for compliance archival", + }, + { + id: "mirror-archive", + label: "Compliance archive", + icon: "lock", + kind: "store", + pipelineOnly: true, + desc: "Mirror to SharePoint / M365 Records / WORM-locked archive for regulated retention", + }, + { + id: "emit-manifest", + label: "Processing manifest", + icon: "penTool", + kind: "store", + defaultOn: true, + pipelineOnly: true, + desc: "Emit a signed manifest describing every stage the document passed through", + }, + ], + alert: [ + { + id: "review", + label: "Human review", + icon: "userPlus", + kind: "alert", + desc: "Route the document to the review queue when rules fail", + }, + { + id: "flag", + label: "Flag", + icon: "alertTriangle", + kind: "alert", + desc: "Raise an in-app flag on low-confidence or out-of-policy docs", + }, + { + id: "notify", + label: "Notify", + icon: "bell", + kind: "alert", + desc: "Fire a webhook or email when the pipeline finishes or trips a rule", + }, + ], +}; + +/** Flat lookup across every stage bucket. */ +export const PIPELINE_OPS_INDEX: Record = (() => { + const idx: Record = {}; + for (const kind of Object.keys(PIPELINE_OPS) as OpKind[]) { + for (const op of PIPELINE_OPS[kind]) idx[op.id] = op; + } + return idx; +})(); + +export const lookupOp = (id: string): PipelineOp | null => + PIPELINE_OPS_INDEX[id] ?? null; + +/* ──────────────────────────────────────────────────────────────────────── */ +/* PIPELINE_AGENTS — one-click op bundles */ +/* ──────────────────────────────────────────────────────────────────────── */ + +export const PIPELINE_AGENTS: readonly PipelineAgent[] = [ + { + id: "agent-pii-sweep", + label: "PII Sweep", + icon: "shield", + desc: "Redact PII categories + encrypt the sealed artifact", + ops: ["redact", "encrypt-rest"], + }, + { + id: "agent-trust-verify", + label: "Trust & Verify", + icon: "check", + desc: "Authenticity, tamper, and confidence checks before downstream", + ops: ["authenticity", "tamper-check", "confidence-check"], + }, + { + id: "agent-compliance-pack", + label: "Compliance Pack", + icon: "lock", + desc: "Redact + watermark + sign + retention policy", + ops: ["redact", "watermark", "sign-output", "retention"], + }, + { + id: "agent-format-prep", + label: "Format Prep", + icon: "package", + desc: "Compress and flatten before downstream consumption", + ops: ["compress", "flatten"], + }, +]; + +export const lookupAgent = (id: string): PipelineAgent | null => + PIPELINE_AGENTS.find((a) => a.id === id) ?? null; + +/* ──────────────────────────────────────────────────────────────────────── */ +/* OP_CATEGORIES — picker section metadata */ +/* ──────────────────────────────────────────────────────────────────────── */ + +export interface OpCategoryMeta { + name: OpCategory; + color: string; + blurb: string; +} + +export const OP_CATEGORIES: readonly OpCategoryMeta[] = [ + { + name: "Document Security", + color: "var(--color-red)", + blurb: "PII, encryption, watermarks, policy seal", + }, + { + name: "Validation", + color: "var(--color-blue)", + blurb: "Schema checks, trust gates, filters", + }, + { + name: "Classification", + color: "var(--color-purple)", + blurb: "Type inference, routing, boundary detection", + }, + { + name: "Document Review", + color: "var(--color-cat-compliance)", + blurb: "Inspection, AI review, certification", + }, + { + name: "Signing", + color: "var(--color-green)", + blurb: "Digital signatures, cert sign / remove", + }, + { + name: "Page Formatting", + color: "var(--color-cat-energy)", + blurb: "Rotate, crop, layout, page-level edits", + }, + { + name: "Extraction", + color: "var(--color-cat-extraction)", + blurb: "OCR, format conversion, content pull", + }, + { + name: "Removal", + color: "var(--color-amber)", + blurb: "Drop pages, blanks, images, partition splits", + }, + { + name: "Automation", + color: "var(--color-cat-operations)", + blurb: "AI-driven generation, translation, forms", + }, + { + name: "Advanced Formatting", + color: "var(--color-cat-healthcare)", + blurb: "TOC, overlays, format-to-PDF, PDF/A", + }, + { + name: "Developer Tools", + color: "var(--color-text-5)", + blurb: "Repair, compress, metadata", + }, +]; + +/* ──────────────────────────────────────────────────────────────────────── */ +/* LIBRARY_OPS — full atomic operation catalogue */ +/* ──────────────────────────────────────────────────────────────────────── */ + +export const LIBRARY_OPS: readonly LibraryOp[] = [ + // Validation / Document Review + { + id: "validate", + label: "Schema validate", + icon: "shield", + kind: "validate", + category: "Validation", + desc: "Check required fields, business rules, and coverage gaps against the typed schema", + }, + { + id: "authenticity", + label: "Authenticity", + icon: "check", + kind: "validate", + category: "Validation", + desc: "Verify the document is genuine (signature, issuer, watermark checks)", + }, + { + id: "tamper-check", + label: "Tamper check", + icon: "alertTriangle", + kind: "validate", + category: "Validation", + desc: "Detect modifications since signing or last-known-good state", + }, + { + id: "source-auth", + label: "Source auth", + icon: "lock", + kind: "validate", + category: "Validation", + desc: "Confirm the document came from an authenticated source", + }, + { + id: "counterparty-match", + label: "Counterparty match", + icon: "userPlus", + kind: "validate", + category: "Validation", + desc: "Match the document counterparty against expected identity", + }, + { + id: "confidence-check", + label: "Confidence bounds", + icon: "activity", + kind: "validate", + category: "Validation", + desc: "Gate downstream ops on extraction-confidence thresholds", + }, + { + id: "inspect-basic", + label: "Basic info", + icon: "fileText", + kind: "validate", + category: "Document Review", + desc: "Get document metadata (title, author, page count, dates)", + }, + { + id: "inspect-pages", + label: "Page count", + icon: "fileText", + kind: "validate", + category: "Document Review", + desc: "Return the page count of the document", + }, + { + id: "inspect-fonts", + label: "Font info", + icon: "fileText", + kind: "validate", + category: "Document Review", + desc: "List fonts embedded in the document", + }, + { + id: "inspect-security", + label: "Security info", + icon: "lock", + kind: "validate", + category: "Document Review", + desc: "Read security configuration (encryption, signatures, permissions)", + }, + { + id: "inspect-form", + label: "Form fields", + icon: "fileText", + kind: "validate", + category: "Document Review", + desc: "Enumerate form fields and their values", + }, + { + id: "filter-text", + label: "Filter by text", + icon: "shield", + kind: "validate", + category: "Validation", + desc: "Pass only docs containing specific text", + }, + { + id: "filter-image", + label: "Filter by image", + icon: "shield", + kind: "validate", + category: "Validation", + desc: "Pass only docs containing images", + }, + { + id: "filter-page-count", + label: "Filter by page count", + icon: "shield", + kind: "validate", + category: "Validation", + desc: "Pass only docs within a page-count range", + }, + { + id: "filter-page-size", + label: "Filter by page size", + icon: "shield", + kind: "validate", + category: "Validation", + desc: "Pass only docs matching a page-dimension constraint", + }, + { + id: "filter-file-size", + label: "Filter by file size", + icon: "shield", + kind: "validate", + category: "Validation", + desc: "Pass only docs within a file-size range", + }, + + // Modify / Page Formatting / Removal / Extraction / Advanced Formatting / Developer Tools + { + id: "merge", + label: "Merge", + icon: "layers", + kind: "modify", + category: "Page Formatting", + desc: "Combine multiple PDFs into a single document", + }, + { + id: "split", + label: "Split", + icon: "grid", + kind: "modify", + category: "Removal", + desc: "Split a PDF into pages, sections, or by document boundary", + }, + { + id: "split-chapters", + label: "Split by chapters", + icon: "grid", + kind: "modify", + category: "Removal", + desc: "Split a PDF along bookmark or chapter boundaries", + }, + { + id: "split-size", + label: "Split by size", + icon: "grid", + kind: "modify", + category: "Removal", + desc: "Split a PDF when it exceeds a size or page-count threshold", + }, + { + id: "auto-split", + label: "Auto split", + icon: "grid", + kind: "modify", + category: "Classification", + desc: "Detect document boundaries and split automatically", + }, + { + id: "remove-pages", + label: "Remove pages", + icon: "grid", + kind: "modify", + category: "Removal", + desc: "Drop specified pages from the document", + }, + { + id: "remove-blanks", + label: "Remove blank pages", + icon: "grid", + kind: "modify", + category: "Removal", + desc: "Detect and drop blank pages", + }, + { + id: "rearrange-pages", + label: "Rearrange pages", + icon: "grid", + kind: "modify", + category: "Page Formatting", + desc: "Reorder pages by a specified sequence", + }, + { + id: "rotate", + label: "Rotate", + icon: "arrowRight", + kind: "modify", + category: "Page Formatting", + desc: "Rotate pages or correct page orientation", + }, + { + id: "crop", + label: "Crop", + icon: "layout", + kind: "modify", + category: "Page Formatting", + desc: "Crop pages to a region or trim margins", + }, + { + id: "compress", + label: "Compress", + icon: "package", + kind: "modify", + category: "Developer Tools", + desc: "Reduce file size while preserving fidelity", + }, + { + id: "flatten", + label: "Flatten", + icon: "layout", + kind: "modify", + category: "Page Formatting", + desc: "Flatten forms and annotations into the page content", + }, + { + id: "repair", + label: "Repair", + icon: "package", + kind: "modify", + category: "Developer Tools", + desc: "Repair a corrupted or malformed PDF", + }, + { + id: "remove-images", + label: "Remove images", + icon: "eye", + kind: "modify", + category: "Removal", + desc: "Strip images to reduce file size", + }, + { + id: "update-metadata", + label: "Update metadata", + icon: "fileText", + kind: "modify", + category: "Developer Tools", + desc: "Edit PDF metadata fields (title, author, keywords)", + }, + { + id: "auto-rename", + label: "Auto rename", + icon: "fileText", + kind: "modify", + category: "Classification", + desc: "Rename based on extracted document content", + }, + { + id: "add-image", + label: "Add image", + icon: "penTool", + kind: "modify", + category: "Page Formatting", + desc: "Insert an image onto pages", + }, + { + id: "add-stamp", + label: "Add stamp", + icon: "penTool", + kind: "modify", + category: "Page Formatting", + desc: "Apply a stamp to pages", + }, + { + id: "add-page-numbers", + label: "Page numbers", + icon: "penTool", + kind: "modify", + category: "Page Formatting", + desc: "Add page numbers to the document", + }, + { + id: "add-attachments", + label: "Add attachments", + icon: "penTool", + kind: "modify", + category: "Advanced Formatting", + desc: "Embed file attachments in the PDF", + }, + { + id: "overlay", + label: "Overlay PDFs", + icon: "layers", + kind: "modify", + category: "Advanced Formatting", + desc: "Overlay one PDF on top of another", + }, + { + id: "multi-page-layout", + label: "Multi-page layout", + icon: "grid", + kind: "modify", + category: "Advanced Formatting", + desc: "Combine multiple pages into a single page (n-up)", + }, + { + id: "scale-pages", + label: "Scale pages", + icon: "layout", + kind: "modify", + category: "Page Formatting", + desc: "Resize pages to a target dimension", + }, + { + id: "edit-toc", + label: "Edit TOC", + icon: "fileText", + kind: "modify", + category: "Advanced Formatting", + desc: "Edit the table of contents / bookmarks", + }, + { + id: "scanner-effect", + label: "Scanner effect", + icon: "penTool", + kind: "modify", + category: "Page Formatting", + desc: "Apply a scanned-document visual effect", + }, + { + id: "invert-colors", + label: "Invert colors", + icon: "penTool", + kind: "modify", + category: "Page Formatting", + desc: "Invert page colors or replace specific colors", + }, + { + id: "convert-image", + label: "PDF → image", + icon: "fileText", + kind: "modify", + category: "Extraction", + desc: "Convert PDF pages to image files", + }, + { + id: "image-to-pdf", + label: "Image → PDF", + icon: "fileText", + kind: "modify", + category: "Advanced Formatting", + desc: "Convert images into a PDF", + }, + { + id: "convert-word", + label: "PDF → Word", + icon: "fileText", + kind: "modify", + category: "Extraction", + desc: "Convert PDF to a DOCX document", + }, + { + id: "convert-pptx", + label: "PDF → presentation", + icon: "fileText", + kind: "modify", + category: "Extraction", + desc: "Convert PDF to a PPTX deck", + }, + { + id: "convert-text", + label: "PDF → text", + icon: "fileText", + kind: "modify", + category: "Extraction", + desc: "Extract plain text from the PDF", + }, + { + id: "convert-html", + label: "PDF → HTML", + icon: "fileText", + kind: "modify", + category: "Extraction", + desc: "Convert PDF to HTML", + }, + { + id: "convert-xml", + label: "PDF → XML", + icon: "fileText", + kind: "modify", + category: "Extraction", + desc: "Convert PDF to structured XML", + }, + { + id: "convert-csv", + label: "PDF → CSV", + icon: "fileText", + kind: "modify", + category: "Extraction", + desc: "Extract tables from the PDF as CSV", + }, + { + id: "convert-markdown", + label: "PDF → Markdown", + icon: "fileText", + kind: "modify", + category: "Extraction", + desc: "Convert PDF to Markdown", + }, + { + id: "convert-pdfa", + label: "PDF → PDF/A", + icon: "fileText", + kind: "modify", + category: "Advanced Formatting", + desc: "Convert to the PDF/A archival format", + }, + { + id: "html-to-pdf", + label: "HTML → PDF", + icon: "fileText", + kind: "modify", + category: "Advanced Formatting", + desc: "Render HTML as a PDF", + }, + { + id: "markdown-to-pdf", + label: "Markdown → PDF", + icon: "fileText", + kind: "modify", + category: "Advanced Formatting", + desc: "Render Markdown as a PDF", + }, + { + id: "url-to-pdf", + label: "URL → PDF", + icon: "fileText", + kind: "modify", + category: "Advanced Formatting", + desc: "Render a web page as a PDF", + }, + { + id: "eml-to-pdf", + label: "Email → PDF", + icon: "fileText", + kind: "modify", + category: "Advanced Formatting", + desc: "Convert an email file (.eml) to a PDF", + }, + { + id: "ocr", + label: "OCR", + icon: "eye", + kind: "modify", + category: "Extraction", + desc: "Optical character recognition on scanned pages", + }, + { + id: "extract-images", + label: "Extract images", + icon: "sparkles", + kind: "modify", + category: "Extraction", + desc: "Extract embedded images from the PDF", + }, + { + id: "extract-bookmarks", + label: "Extract bookmarks", + icon: "sparkles", + kind: "modify", + category: "Extraction", + desc: "Extract the bookmark / outline tree", + }, + { + id: "extract-scans", + label: "Extract scans", + icon: "sparkles", + kind: "modify", + category: "Extraction", + desc: "Detect and extract scanned image regions", + }, + + // Secure + { + id: "redact", + label: "Redact PII", + icon: "eye", + kind: "secure", + category: "Document Security", + desc: "Remove or mask PII before the document is stored or released", + }, + { + id: "pii-enforce", + label: "PII / PHI enforcement", + icon: "shield", + kind: "secure", + category: "Document Security", + desc: "Policy check that outputs do not leak protected data beyond declared scope", + }, + { + id: "auto-redact", + label: "Auto-redact", + icon: "eye", + kind: "secure", + category: "Document Security", + desc: "Automatically redact sensitive content based on policy", + }, + { + id: "watermark", + label: "Confidentiality mark", + icon: "penTool", + kind: "secure", + category: "Document Security", + desc: "Apply a visible confidentiality watermark to pages", + }, + { + id: "attribution-watermark", + label: "Attribution watermark", + icon: "penTool", + kind: "secure", + category: "Document Security", + desc: "Embed a per-recipient invisible watermark for leak tracing", + }, + { + id: "sanitize", + label: "Sanitize", + icon: "shield", + kind: "secure", + category: "Document Security", + desc: "Strip hidden data, JavaScript, and metadata from the PDF", + }, + { + id: "sign-output", + label: "Sign outputs", + icon: "penTool", + kind: "secure", + category: "Signing", + desc: "Apply a tamper-evident signature covering the artifact and run metadata", + }, + { + id: "cert-sign", + label: "Certificate sign", + icon: "penTool", + kind: "secure", + category: "Signing", + desc: "Sign with a digital certificate", + }, + { + id: "add-password", + label: "Add password", + icon: "lock", + kind: "secure", + category: "Document Security", + desc: "Password-protect the output PDF", + }, + { + id: "encrypt-rest", + label: "Encryption at rest", + icon: "lock", + kind: "secure", + category: "Document Security", + desc: "AES-256 encryption on stored artifacts (Stirling-managed, BYOK, or HYOK)", + }, + { + id: "flatten-secure", + label: "Flatten + lock", + icon: "layout", + kind: "secure", + category: "Document Security", + desc: "Flatten forms and lock the document against tampering", + }, + { + id: "retention", + label: "Retention policy", + icon: "fileText", + kind: "secure", + category: "Document Security", + desc: "How long Stirling retains the artifact, run record, and audit trail", + }, + { + id: "residency", + label: "Regional residency", + icon: "globe", + kind: "secure", + category: "Document Security", + desc: "Where the artifact is stored and processed (region-pin or air-gap)", + }, + { + id: "access-policy", + label: "Access policy", + icon: "key", + kind: "secure", + category: "Document Security", + desc: "Who can fetch the sealed artifact (signed URL, IdP-gated, public)", + }, + { + id: "remove-cert-sign", + label: "Remove signature", + icon: "penTool", + kind: "secure", + category: "Signing", + desc: "Strip a digital signature from the document", + }, + + // AI-powered ops + { + id: "summarize", + label: "Document summarizer", + icon: "fileText", + kind: "modify", + category: "Document Review", + provider: "claude", + desc: "Generate executive summaries and key insights", + }, + { + id: "translate", + label: "Document translator", + icon: "globe", + kind: "modify", + category: "Automation", + provider: "claude", + desc: "Translate documents while preserving formatting", + }, + { + id: "contract-analyze", + label: "Contract analyzer", + icon: "shield", + kind: "validate", + category: "Document Review", + provider: "claude", + desc: "Review contracts for risks, obligations, and key terms", + }, + { + id: "compliance-audit", + label: "Compliance auditor", + icon: "check", + kind: "validate", + category: "Document Review", + provider: "stirling", + desc: "Check documents against regulatory requirements", + }, + { + id: "generate", + label: "Document generation", + icon: "penTool", + kind: "modify", + category: "Automation", + provider: "claude", + desc: "Create contracts, reports, and business documents from prompts", + }, + { + id: "certify", + label: "Document certification", + icon: "check", + kind: "validate", + category: "Document Review", + provider: "claude", + desc: "Review documents against criteria and certify approval", + }, + { + id: "intelligent-forms", + label: "Intelligent forms", + icon: "fileText", + kind: "modify", + category: "Automation", + provider: "stirling", + desc: "Auto-fill forms and extract submissions", + }, + + // Schema-aware ops (D35) + { + id: "schema-extract", + label: "Schema-aware extract", + icon: "sparkles", + kind: "modify", + category: "Classification", + desc: "Extract the typed fields the inferred schema declares — not heuristic regexes. Confidence scored per field", + }, + { + id: "schema-validate", + label: "Schema validate", + icon: "shield", + kind: "validate", + category: "Validation", + desc: "Validate the document against its inferred schema. Required-field coverage, type-correctness, business rules", + }, + { + id: "schema-route", + label: "Conditional routing", + icon: "arrowRight", + kind: "validate", + category: "Classification", + desc: "Fork to a downstream pipeline based on the inferred schema (Invoice → AP, Loan Closing → Compliance, etc.)", + }, + { + id: "drift-check", + label: "Schema drift check", + icon: "alertTriangle", + kind: "validate", + category: "Validation", + desc: "Flag when a doc claims to be a known type but its field shape diverges from prior examples", + }, + { + id: "smart-redact", + label: "Field-aware redact", + icon: "eye", + kind: "secure", + category: "Document Security", + desc: "Schema-aware PII redaction — targets fields the schema declares as PII, not regex fishing", + }, + { + id: "field-confidence", + label: "Field confidence", + icon: "activity", + kind: "validate", + category: "Validation", + desc: "Per-field confidence scoring backed by the typed schema", + }, + { + id: "schema-split", + label: "Schema-aware split", + icon: "grid", + kind: "modify", + category: "Classification", + desc: "Split a multi-doc bundle along schema boundaries", + }, + { + id: "smart-watermark", + label: "Smart watermark", + icon: "penTool", + kind: "secure", + category: "Document Security", + desc: "Watermark text incorporates extracted schema fields", + }, +]; + +export const LIBRARY_OPS_INDEX: Record = Object.fromEntries( + LIBRARY_OPS.map((op) => [op.id, op]), +); + +export function lookupLibraryOp(id: string): LibraryOp | PipelineOp | null { + return LIBRARY_OPS_INDEX[id] ?? lookupOp(id); +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* SOURCES + DESTINATIONS — the rails */ +/* ──────────────────────────────────────────────────────────────────────── */ + +export const SOURCE_OPTIONS: readonly SourceOption[] = [ + { + id: "upload", + label: "Upload API", + icon: "upload", + desc: "POST documents to a Stirling endpoint", + }, + { + id: "webhook", + label: "Inbound webhook", + icon: "plug", + desc: "Receive documents from another system via webhook", + }, + { + id: "s3", + label: "S3 bucket watch", + icon: "server", + desc: "Poll an S3 bucket for new files", + }, + { + id: "email", + label: "Email intake", + icon: "fileText", + desc: "Route an inbox to this pipeline", + }, + { + id: "scheduled", + label: "Scheduled import", + icon: "activity", + desc: "Fetch from SFTP/URL on a schedule", + }, +]; + +export const DESTINATION_OPTIONS: readonly DestinationOption[] = [ + { + id: "vault", + label: "Stirling vault", + icon: "shield", + desc: "Store the processed document and extracted data in Stirling", + }, + { + id: "s3", + label: "S3 bucket", + icon: "server", + desc: "Write results to an S3 bucket", + }, + { + id: "webhook", + label: "Outbound webhook", + icon: "externalLink", + desc: "POST results to a URL you control", + }, + { + id: "pipeline", + label: "Another pipeline", + icon: "arrowRight", + desc: "Chain to a second pipeline", + }, + { + id: "database", + label: "Database", + icon: "database", + desc: "Insert extracted fields into Postgres/Snowflake/BigQuery", + }, + { + id: "sftp", + label: "SFTP", + icon: "fileText", + desc: "Drop output files on an SFTP server", + }, +]; + +export const lookupSource = (id: string) => + SOURCE_OPTIONS.find((s) => s.id === id) ?? null; +export const lookupDestination = (id: string) => + DESTINATION_OPTIONS.find((d) => d.id === id) ?? null; diff --git a/frontend/shared/tokens/Tokens.stories.tsx b/frontend/shared/tokens/Tokens.stories.tsx new file mode 100644 index 000000000..f83c93517 --- /dev/null +++ b/frontend/shared/tokens/Tokens.stories.tsx @@ -0,0 +1,262 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +const meta: Meta = { + title: "Foundations/Design Tokens", + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +interface Swatch { + label: string; + varName: string; +} + +function Group({ heading, swatches }: { heading: string; swatches: Swatch[] }) { + return ( +
+

+ {heading} +

+
+ {swatches.map((s) => ( +
+
+
+
+ {s.label} +
+
+ {s.varName} +
+
+
+ ))} +
+
+ ); +} + +export const Colours: Story = { + render: () => ( +
+ + + + +
+ ), +}; + +export const Typography: Story = { + render: () => ( +
+
+
+ Brand wordmark (Alumni Sans) +
+ + Stirling + +
+
+
+ Page title (24/700) +
+ Pipelines +
+
+
+ Section title (13/600) +
+ + Configured primitives + +
+
+
+ Body (13/400) +
+ + Drop a sample to see what just this op produces. + +
+
+
+ Code (12 mono) +
+ + POST /v1/coi + +
+
+ ), +}; + +export const Motion: Story = { + render: () => ( +
+
+ fadeInUp — standard view transition +
+
+
+ ), +}; diff --git a/frontend/shared/tokens/base.css b/frontend/shared/tokens/base.css new file mode 100644 index 000000000..b9b40e0c2 --- /dev/null +++ b/frontend/shared/tokens/base.css @@ -0,0 +1,39 @@ +/* Minimal global reset + base typography. */ + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body, +#root { + margin: 0; + padding: 0; + background: var(--color-bg); + color: var(--color-text-2); + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + transition: + background var(--motion-slow), + color var(--motion-slow); +} + +button { + font: inherit; + color: inherit; + background: none; + border: none; + padding: 0; + cursor: pointer; +} + +a { + color: var(--color-blue); + text-decoration: none; +} +a:hover { + text-decoration: underline; +} diff --git a/frontend/shared/tokens/tokens.css b/frontend/shared/tokens/tokens.css new file mode 100644 index 000000000..c8ba2171a --- /dev/null +++ b/frontend/shared/tokens/tokens.css @@ -0,0 +1,419 @@ +/* + * Stirling design tokens — CSS custom properties. + * + * This is the single source of truth for the design tokens. Components + * reference them via var(--color-...); there is no JS palette to keep in sync. + * Theme switching flips `data-theme="dark"` on and these variables + * cascade. (tokens.ts holds only the Tier type, not the palette.) + * + * Category accents (insurance, finance, legal, …) are not theme-switched — + * the document types they label are stable, theme-agnostic concepts. + */ + +:root, +[data-theme="light"] { + /* Text hierarchy */ + --color-text-1: #0f172a; + --color-text-2: #1a202c; + --color-text-3: #475569; + --color-text-4: #64748b; + /* text-5 was #94a3b8 (≈2.85:1 on white — fails WCAG AA). Bumped to #64748b + so meta/timestamp copy at small sizes still passes contrast. The visual + hierarchy now collapses text-4 and text-5 into the same shade — use weight + or size to differentiate in components instead. */ + --color-text-5: #64748b; + --color-text-6: #6b7280; + --color-text-muted: #8b92a1; + --color-text-placeholder: #9ca3af; + + /* Body text colour for use on coloured / gradient backgrounds (blue buttons, + assistant header, onboarding "done" badges). Theme-stable: even in dark + mode the underlying surface is the brand gradient, so the text is white. */ + --color-text-on-accent: #ffffff; + + /* Brand / status */ + --color-blue: #3b82f6; + --color-blue-dark: #2563eb; + --color-blue-light: #eff6ff; + --color-blue-border: #bfdbfe; + --color-purple: #8b5cf6; + --color-purple-light: #f5f3ff; + --color-purple-border: #ddd6fe; + --color-purple-dark: #7c3aed; + --color-green: #10b981; + --color-green-light: #ecfdf5; + --color-green-border: #a7f3d0; + --color-green-dark: #16a34a; + --color-red: #ef4444; + --color-red-light: #fee2e2; + --color-red-border: #fecaca; + --color-red-dark: #dc2626; + --color-amber: #f59e0b; + --color-amber-light: #fef3c7; + --color-amber-border: #fde68a; + --color-amber-dark: #92400e; + + /* Category accents (theme-stable) */ + --color-cat-insurance: #0ea5e9; + --color-cat-compliance: #6366f1; + --color-cat-finance: #10b981; + --color-cat-legal: #3b82f6; + --color-cat-healthcare: #8b5cf6; + --color-cat-government: #dc2626; + --color-cat-operations: #ec4899; + --color-cat-hr: #f59e0b; + --color-cat-realestate: #84cc16; + --color-cat-energy: #f97316; + --color-cat-extraction: #14b8a6; + + /* Surfaces */ + --color-bg: #f8f9fb; + --color-bg-alt: #f6f8fa; + --color-surface: #ffffff; + --color-surface-alt: #ffffff; + --color-border: #e3e8ee; + --color-border-light: #eef0f2; + --color-border-input: #e2e8f0; + --color-border-hover: #cbd5e1; + --color-divider: #f0f0f0; + --color-bg-subtle: #f9fafb; + --color-bg-hover: #f8fafc; + --color-bg-muted: #f3f4f6; + --color-bg-code: #f1f5f9; + + /* Dropdowns / tooltips */ + --color-dropdown-bg: #ffffff; + --color-dropdown-border: #e5e7eb; + --color-dropdown-hover: #f9fafb; + --color-tooltip-bg: #1e293b; + --color-tooltip-text: #f8fafc; + + /* Navigation */ + --color-nav-active: #eff6ff; + --color-nav-active-text: #3b82f6; + --color-nav-text: #4b5563; + --color-nav-hover: #f6f8fa; + --color-nav-hover-text: #1a202c; + --color-section-label: #9ca3af; + --color-logo-text: #111827; + + /* Header */ + --color-header-bg: #ffffff; + --color-header-border: #f0f0f0; + --color-header-text: #111827; + --color-search-bg: #f9fafb; + --color-search-border: #e5e7eb; + --color-search-border-hover: #d1d5db; + --color-search-text: #9ca3af; + + /* Sidebar */ + --color-sidebar-bg: #ffffff; + --color-sidebar-border: #e5e7eb; + --color-sidebar-divider: #f0f0f0; + --color-usage-text: #6b7280; + --color-usage-value: #374151; + --color-usage-track: #f3f4f6; + + --color-toggle-off: #cbd5e1; + --color-badge-red: #ef4444; + + /* Shadows */ + --shadow-sm: inset 0 0 0 1px #e3e8ee; + --shadow-md: inset 0 0 0 1px #e3e8ee, 0 1px 2px rgba(15, 23, 42, 0.04); + --shadow-lg: inset 0 0 0 1px #e3e8ee, 0 4px 12px rgba(15, 23, 42, 0.06); + --shadow-blue: + 0 1px 2px rgba(59, 130, 246, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.2); + --shadow-blue-hover: + 0 2px 6px rgba(59, 130, 246, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.25); + + /* Gradients */ + --grad-blue-btn: linear-gradient( + 180deg, + #5b9bf7 0%, + #4c8bf5 50%, + #3a7be8 100% + ); + --grad-purple-btn: linear-gradient( + 180deg, + #a78bfa 0%, + #8b5cf6 50%, + #7c3aed 100% + ); + --grad-green-btn: linear-gradient( + 180deg, + #34d399 0%, + #10b981 50%, + #059669 100% + ); + --grad-amber-btn: linear-gradient( + 180deg, + #fbbf24 0%, + #f59e0b 50%, + #d97706 100% + ); + --grad-red-btn: linear-gradient( + 180deg, + #f87171 0%, + #ef4444 50%, + #dc2626 100% + ); + --grad-banner: linear-gradient(135deg, #eef2ff 0%, #ddd6fe 50%, #fce7f3 100%); +} + +[data-theme="dark"] { + --color-text-1: #f1f5f9; + --color-text-2: #e2e8f0; + --color-text-3: #94a3b8; + --color-text-4: #64748b; + /* Bumped from #475569 (fails AA) — see light-theme note. */ + --color-text-5: #7c869a; + --color-text-6: #94a3b8; + --color-text-muted: #64748b; + --color-text-placeholder: #475569; + + --color-blue: #60a5fa; + --color-blue-dark: #3b82f6; + --color-blue-light: #1e293b; + --color-blue-border: #1e3a5f; + --color-purple: #a78bfa; + --color-purple-light: #1e1b2e; + --color-purple-border: #2e2650; + --color-purple-dark: #8b5cf6; + --color-green: #34d399; + --color-green-light: #0d2818; + --color-green-border: #065f46; + --color-green-dark: #22c55e; + --color-red: #f87171; + --color-red-light: #2d1215; + --color-red-border: #7f1d1d; + --color-red-dark: #ef4444; + --color-amber: #fbbf24; + --color-amber-light: #2d2006; + --color-amber-border: #78350f; + /* Deeper than the base so it stays distinct (hover shade + amber text on + dark surfaces). ~7.4:1 on --color-amber-light, passes WCAG AA. Was + #fbbf24 — identical to the base — which left Mantine's amber hover a + no-op in dark mode. */ + --color-amber-dark: #f59e0b; + + --color-bg: #090c14; + --color-bg-alt: #0d1120; + --color-surface: #151c2e; + --color-surface-alt: #1c2640; + --color-border: #283248; + --color-border-light: #1e2840; + --color-border-input: #2e3c55; + --color-border-hover: #3d4f6a; + --color-divider: #1e2840; + --color-bg-subtle: #111827; + --color-bg-hover: #1c2640; + --color-bg-muted: #243044; + --color-bg-code: #0b0f1a; + + --color-dropdown-bg: #151c2e; + --color-dropdown-border: #2e3c55; + --color-dropdown-hover: #1c2640; + --color-tooltip-bg: #e2e8f0; + --color-tooltip-text: #0f172a; + + --color-nav-active: #172044; + --color-nav-active-text: #60a5fa; + --color-nav-text: #94a3b8; + --color-nav-hover: #1c2640; + --color-nav-hover-text: #e2e8f0; + --color-section-label: #475569; + --color-logo-text: #f1f5f9; + + --color-header-bg: #0d1120; + --color-header-border: #1e2840; + --color-header-text: #f1f5f9; + --color-search-bg: #151c2e; + --color-search-border: #2e3c55; + --color-search-border-hover: #3d4f6a; + --color-search-text: #64748b; + + --color-sidebar-bg: #0d1120; + --color-sidebar-border: #1e2840; + --color-sidebar-divider: #1e2840; + --color-usage-text: #64748b; + --color-usage-value: #94a3b8; + --color-usage-track: #1e2840; + + --color-toggle-off: #3d4f6a; + + --shadow-sm: inset 0 0 0 1px #283248; + --shadow-md: inset 0 0 0 1px #283248, 0 1px 3px rgba(0, 0, 0, 0.3); + --shadow-lg: inset 0 0 0 1px #283248, 0 8px 24px rgba(0, 0, 0, 0.4); + --shadow-blue: + 0 1px 3px rgba(59, 130, 246, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.1); + --shadow-blue-hover: + 0 2px 8px rgba(59, 130, 246, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.15); + + --grad-blue-btn: linear-gradient( + 180deg, + #3b82f6 0%, + #2563eb 50%, + #1d4ed8 100% + ); + --grad-banner: linear-gradient(135deg, #0f172a 0%, #111827 50%, #1a1535 100%); +} + +/* Always-dark code palette. Not theme-switched. */ +:root { + --code-bg: #0f172a; + --code-bg-alt: #1e293b; + --code-bg-header: #1a2332; + --code-text: #e2e8f0; + --code-dim: #64748b; + --code-muted: #94a3b8; + --code-keyword: #60a5fa; + --code-string: #fbbf24; + --code-number: #c084fc; + --code-fn: #34d399; + --code-type: #f472b6; + --code-property: #93c5fd; + --code-comment: #475569; + --code-border: #1e293b; + /* Window-chrome traffic-light dots in the code-block header. */ + --code-dot: #475569; +} + +/* Radii / typography / motion / spacing / z-index — theme-stable */ +:root { + --radius-xs: 0.1875rem; + --radius-sm: 0.25rem; + --radius-md: 0.375rem; + --radius-lg: 0.5rem; + --radius-xl: 0.75rem; + --radius-pill: 0.625rem; + + --font-sans: + "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, + sans-serif; + --font-mono: + "SF Mono", "Fira Code", Menlo, Consolas, "DejaVu Sans Mono", monospace; + --font-brand: "Alumni Sans", "Inter", sans-serif; + + --motion-fast: 0.15s ease; + --motion-base: 0.2s cubic-bezier(0.4, 0, 0.2, 1); + --motion-slow: 0.3s ease; + --motion-enter: 0.22s cubic-bezier(0.4, 0, 0.2, 1); + + /* 4-px grid spacing scale (rem so it tracks font-size changes). Use these + in layout primitives + component CSS instead of bare 0.5rem / 0.75rem. */ + --space-0: 0; + --space-0_5: 0.125rem; + --space-1: 0.25rem; + --space-1_5: 0.375rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-5: 1.25rem; + --space-6: 1.5rem; + --space-8: 2rem; + --space-10: 2.5rem; + --space-12: 3rem; + + /* Z-index hierarchy. Pick the highest tier that still puts you below the + thing that's supposed to win. Modals beat drawers beat dropdowns. */ + --z-base: 0; + --z-sticky: 10; + --z-dropdown: 25; + --z-fixed-overlay: 35; + --z-drawer: 50; + --z-modal: 100; + --z-toast: 200; +} + +/* Keyframes used across the app. */ +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(0.375rem); + } + to { + opacity: 1; + transform: translateY(0); + } +} +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +@keyframes scaleIn { + from { + opacity: 0; + transform: scale(0.97); + } + to { + opacity: 1; + transform: scale(1); + } +} +@keyframes slideInRight { + from { + transform: translateX(100%); + } + to { + transform: translateX(0); + } +} +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} +@keyframes pulseRing { + 0% { + transform: scale(0.8); + opacity: 1; + } + 100% { + transform: scale(2.2); + opacity: 0; + } +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} +@keyframes shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + } +} + +/* Visually-hidden utility for content meant only for screen readers. */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} diff --git a/frontend/shared/tokens/tokens.ts b/frontend/shared/tokens/tokens.ts new file mode 100644 index 000000000..c20128268 --- /dev/null +++ b/frontend/shared/tokens/tokens.ts @@ -0,0 +1,14 @@ +/** + * Subscription tier identifier. + * + * Defined here rather than in the portal because shared/data/endpoints.ts needs + * it for tier-availability gating, and the shared layer can't import from the + * portal. The portal UI keeps its own matching `Tier` in + * contexts/TierContext.tsx. + * + * The design tokens themselves live in tokens.css as CSS custom properties — + * that is the single runtime source of truth. A parallel TS mirror of the + * palette used to live here but was removed: nothing consumed it and it had + * silently drifted from the CSS. + */ +export type Tier = "free" | "pro" | "enterprise"; diff --git a/frontend/shared/tsconfig.json b/frontend/shared/tsconfig.json new file mode 100644 index 000000000..bb042a488 --- /dev/null +++ b/frontend/shared/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "es2022", + "jsx": "react-jsx", + "module": "esnext", + "moduleResolution": "bundler", + "baseUrl": "./", + "paths": { + "@shared/*": ["./*"] + }, + "resolveJsonModule": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["**/*.ts", "**/*.tsx"] +}