diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index 9d5d17a394..e4e63c0b59 100644 --- a/.taskfiles/frontend.yml +++ b/.taskfiles/frontend.yml @@ -107,6 +107,13 @@ tasks: - task: dev:_run vars: { MODE: prototypes, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' } + dev:portal: + desc: "Start developer portal dev server" + deps: [prepare] + cmds: + - task: dev:_run + vars: { MODE: portal, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' } + # ============================================================ # Build # ============================================================ @@ -151,6 +158,24 @@ tasks: cmds: - npx vite build --mode prototypes + build:portal: + desc: "Build developer portal" + deps: [prepare] + cmds: + - npx vite build --mode portal + + storybook: + desc: "Start Storybook dev server" + deps: [install] + cmds: + - npx storybook dev -p 6006 {{.CLI_ARGS}} + + storybook:build: + desc: "Build static Storybook" + deps: [install] + cmds: + - npx storybook build {{.CLI_ARGS}} + # ============================================================ # Code quality # ============================================================ @@ -231,6 +256,12 @@ tasks: cmds: - npx tsc --noEmit --project src/prototypes/tsconfig.json + typecheck:portal: + desc: "Typecheck developer portal build variant" + deps: [prepare] + cmds: + - npx tsc --noEmit --project src/portal/tsconfig.json + typecheck:all: desc: "Typecheck all build variants" cmds: @@ -240,6 +271,7 @@ tasks: - task: typecheck:desktop - task: typecheck:scripts - task: typecheck:prototypes + - task: typecheck:portal # ============================================================ # Quality Gate @@ -261,6 +293,7 @@ tasks: - task: format:check - task: build - task: test + - task: storybook:build # ============================================================ # Test diff --git a/frontend/.gitignore b/frontend/.gitignore index cf6ed7061e..70cbce6696 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -11,6 +11,8 @@ # production /build /dist +/dist-portal +/storybook-static # misc .DS_Store diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts new file mode 100644 index 0000000000..26d3374e37 --- /dev/null +++ b/frontend/.storybook/main.ts @@ -0,0 +1,56 @@ +import { resolve } from "node:path"; +import type { StorybookConfig } from "@storybook/react-vite"; + +// Tell vite.config.ts to skip the editor's viteStaticCopy plugin (PDF assets, +// fonts, locales — 188 items we don't need). Set before any Storybook import +// triggers config evaluation. +process.env.STIRLING_STORYBOOK = "true"; + +/** + * Storybook 9 ships essentials, interactions, and docs as built-ins, so the + * addon list is just the extras we want: theme switching + a11y auditing. + * + * Story files live next to their components in src/shared/ and src/portal/. + * MDX docs pages live in src/portal/docs/. + */ +const config: StorybookConfig = { + stories: [ + "../src/**/*.mdx", + "../src/**/*.stories.@(ts|tsx)", + ], + addons: ["@storybook/addon-themes", "@storybook/addon-a11y"], + framework: { + name: "@storybook/react-vite", + options: {}, + }, + typescript: { + reactDocgen: "react-docgen-typescript", + }, + // Serve the MSW worker file from portal/public so storybook can intercept + // network calls the same way the dev portal does. + staticDirs: ["../portal/public"], + viteFinal: async (config) => { + // Storybook resolves @app/* and @shared/* via explicit aliases here rather + // than via the per-mode tsconfig.*.vite.json — Storybook doesn't honour + // the --mode flag the way our vite.config.ts expects, so the safer path is + // to wire the aliases the storybook bundler actually uses. + config.resolve = config.resolve ?? {}; + config.resolve.alias = { + ...(config.resolve.alias ?? {}), + "@app": resolve(__dirname, "../src/portal"), + "@shared": resolve(__dirname, "../src/shared"), + }; + // Strip the editor's viteStaticCopy plugin — it pulls 188 PDF/locale + // assets that the portal-and-design-system Storybook doesn't need. + if (Array.isArray(config.plugins)) { + config.plugins = config.plugins.filter((p) => { + if (!p || typeof p !== "object" || Array.isArray(p)) return true; + const name = (p as { name?: string }).name; + return name !== "vite-plugin-static-copy"; + }); + } + return config; + }, +}; + +export default config; diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx new file mode 100644 index 0000000000..b8b0eb7c61 --- /dev/null +++ b/frontend/.storybook/preview.tsx @@ -0,0 +1,129 @@ +import type { Decorator, Preview } from "@storybook/react-vite"; +import { initialize, mswLoader } from "msw-storybook-addon"; +import { MemoryRouter } from "react-router-dom"; +import { useEffect } from "react"; +import { withThemeByDataAttribute } from "@storybook/addon-themes"; + +import { + TierProvider, + useTier, + type Tier, +} from "@app/contexts/TierContext"; +import { ThemeProvider } from "@app/contexts/ThemeContext"; +import { UIProvider } from "@app/contexts/UIContext"; +import { handlers } from "@app/mocks/handlers"; + +import "../src/shared/tokens/tokens.css"; +import "../src/shared/tokens/base.css"; + +// Start MSW once. Storybook runs in a browser so this uses the service worker. +initialize( + { onUnhandledRequest: "bypass" }, + handlers, +); + +/** + * Bridge between Storybook's `tier` global toolbar and the actual TierProvider. + * Without this the toolbar would just change a label; with it, every story + * that calls useTier() reflects the active toolbar value. + */ +function TierBridge({ + tier, + children, +}: { + tier: Tier; + children: React.ReactNode; +}) { + return {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"; + 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/eslint.config.mjs b/frontend/eslint.config.mjs index a963558ec1..82781df313 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -75,6 +75,40 @@ export default defineConfig( ], }, }, + // The shared/ layer is the seed of a future packages/shared-ui — it must + // only depend on third-party packages and on itself. If it ever imports + // from editor or portal layers, extraction to a standalone package later + // becomes a rewrite instead of a `git mv`. + { + files: ["src/shared/**/*.{js,mjs,jsx,ts,tsx}"], + rules: { + "no-restricted-imports": [ + "error", + { + patterns: [ + ...baseRestrictedImportPatterns, + { + regex: "^@app/", + message: + "src/shared/ must not depend on editor or portal layers. Use @shared/* or third-party imports only.", + }, + { + regex: "^@core/", + message: "src/shared/ must not depend on src/core/.", + }, + { + regex: "^@proprietary/", + message: "src/shared/ must not depend on src/proprietary/.", + }, + { + regex: "^@tauri-apps/", + message: "src/shared/ must remain web-compatible (no Tauri APIs).", + }, + ], + }, + ], + }, + }, // Folders that have been cleaned up and are now conformant - stricter rules enforced here { files: [ @@ -82,6 +116,8 @@ export default defineConfig( "src/proprietary/**/*.{js,mjs,jsx,ts,tsx}", "src/saas/**/*.{js,mjs,jsx,ts,tsx}", "src/prototypes/**/*.{js,mjs,jsx,ts,tsx}", + "src/portal/**/*.{js,mjs,jsx,ts,tsx}", + "src/shared/**/*.{js,mjs,jsx,ts,tsx}", ], languageOptions: { parserOptions: { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fd45f61e62..305e8a0de7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -88,6 +88,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", @@ -111,6 +115,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", @@ -118,6 +124,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", @@ -241,6 +248,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", @@ -257,6 +322,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", @@ -279,6 +381,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", @@ -297,6 +417,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", @@ -1922,6 +2066,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", @@ -1950,6 +2181,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", @@ -2088,6 +2340,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", @@ -2097,6 +2367,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", @@ -2580,6 +2875,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", @@ -3092,6 +3412,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", @@ -3481,6 +3844,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", @@ -4668,6 +5311,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", @@ -4961,6 +5649,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", @@ -5042,6 +5737,13 @@ "dev": true, "license": "MIT" }, + "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/node": { "version": "24.12.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", @@ -5091,6 +5793,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", @@ -5902,6 +6628,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", @@ -6108,6 +6844,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", @@ -6512,6 +7318,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", @@ -7662,6 +8478,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", @@ -7922,6 +8751,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", @@ -8297,6 +9139,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", @@ -8564,6 +9433,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", @@ -8853,6 +9732,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", @@ -8902,6 +9791,24 @@ "node": ">= 0.4" } }, + "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", @@ -9331,6 +10238,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", @@ -10473,6 +11387,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", @@ -10758,6 +11756,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", @@ -10978,6 +11983,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", @@ -11868,6 +12880,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", @@ -12299,6 +13356,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", @@ -12515,6 +13612,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", @@ -13056,6 +14160,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", @@ -13063,6 +14177,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", @@ -13085,6 +14719,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", @@ -13483,6 +15124,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", @@ -13761,6 +15415,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", @@ -13947,6 +15611,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/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -13957,6 +15634,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", @@ -14513,6 +16214,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", @@ -14758,6 +16466,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 18a89198e9..a93923569a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -102,6 +102,10 @@ "@iconify-json/material-symbols": "^1.2.53", "@iconify/utils": "^3.1.0", "@playwright/test": "^1.55.0", + "@storybook/addon-a11y": "^9.1.20", + "@storybook/addon-docs": "^9.1.20", + "@storybook/addon-themes": "^9.1.20", + "@storybook/react-vite": "^9.1.20", "@tauri-apps/cli": "^2.9.6", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.8.0", @@ -125,6 +129,8 @@ "jsdom": "^27.0.0", "license-checker": "^25.0.1", "madge": "^8.0.0", + "msw": "^2.14.6", + "msw-storybook-addon": "^2.0.7", "postcss": "^8.5.12", "postcss-cli": "^11.0.1", "postcss-preset-mantine": "^1.18.0", @@ -132,6 +138,7 @@ "prettier": "^3.8.1", "puppeteer": "^24.25.0", "rollup-plugin-visualizer": "^7.0.1", + "storybook": "^9.1.20", "tsx": "^4.21.0", "typescript": "^5.9.2", "typescript-eslint": "^8.44.1", @@ -147,5 +154,10 @@ "@testing-library/user-event", "@vitest/coverage-v8" ] + }, + "msw": { + "workerDirectory": [ + "portal\\public" + ] } } diff --git a/frontend/portal/index.html b/frontend/portal/index.html new file mode 100644 index 0000000000..69328bad8b --- /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 0000000000..485638072b --- /dev/null +++ b/frontend/portal/main.tsx @@ -0,0 +1,31 @@ +/// +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "@app/App"; +import { readMocksPreference } from "@app/mocks/preference"; + +import "@shared/tokens/tokens.css"; +import "@shared/tokens/base.css"; + +const root = document.getElementById("root"); +if (!root) throw new Error("No #root element"); + +// Start MSW before React mounts when the user has mocks enabled. The +// preference defaults to ON in dev / OFF in production. The toggle in the +// header flips it at runtime. +async function bootstrap(): Promise { + if (readMocksPreference()) { + // Dynamic import keeps MSW + every handler + every fixture out of any + // chunk that doesn't need to actually run the worker. + const { startMockWorker } = await import("@app/mocks/browser"); + await startMockWorker(); + } + + createRoot(root!).render( + + + , + ); +} + +void bootstrap(); diff --git a/frontend/portal/public/mockServiceWorker.js b/frontend/portal/public/mockServiceWorker.js new file mode 100644 index 0000000000..33dde9e770 --- /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/src/portal/App.tsx b/frontend/src/portal/App.tsx new file mode 100644 index 0000000000..d38484e5d8 --- /dev/null +++ b/frontend/src/portal/App.tsx @@ -0,0 +1,57 @@ +import { useEffect } from "react"; +import { BrowserRouter } from "react-router-dom"; +import { ThemeProvider } from "@app/contexts/ThemeContext"; +import { TierProvider } from "@app/contexts/TierContext"; +import { UIProvider, useUI } from "@app/contexts/UIContext"; +import { AppShell } from "@app/components/AppShell"; +import { AssistantButton } from "@app/components/AssistantButton"; +import { AssistantPanel } from "@app/components/AssistantPanel"; +import { SearchModal } from "@app/components/SearchModal"; +import { ViewRouter } from "@app/ViewRouter"; + +/** + * Global keyboard shortcuts. Lives below the UIProvider so it can dispatch + * into the overlay state. Currently just ⌘K / Ctrl+K to toggle the search + * palette. + */ +function GlobalShortcuts() { + const { toggleSearch, closeSearch } = useUI(); + + useEffect(() => { + function onKey(e: KeyboardEvent) { + const isCmdK = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k"; + if (isCmdK) { + e.preventDefault(); + toggleSearch(); + return; + } + if (e.key === "Escape") { + closeSearch(); + } + } + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [toggleSearch, closeSearch]); + + return null; +} + +export function App() { + return ( + + + + + + + + + + + + + + + + ); +} diff --git a/frontend/src/portal/ViewRouter.tsx b/frontend/src/portal/ViewRouter.tsx new file mode 100644 index 0000000000..1e892b5fe8 --- /dev/null +++ b/frontend/src/portal/ViewRouter.tsx @@ -0,0 +1,68 @@ +import { Navigate, Route, Routes } from "react-router-dom"; +import { Home } from "@app/views/Home"; +import { Placeholder } from "@app/views/Placeholder"; +import { VIEW_PATHS, type ViewId } from "@app/contexts/ViewContext"; + +const PLACEHOLDER_PHASES: Partial> = { + editor: "Phase 8 — Editor", + sources: "Phase 5 — Sources & Agents", + pipelines: "Phase 4 — Pipelines", + documents: "Phase 6 — Documents", + infrastructure: "Phase 7 — Infrastructure", + usage: "Phase 8 — Usage & Billing", + docs: "Phase 8 — Developer Docs", + settings: "Settings — modal overlay", +}; + +export function ViewRouter() { + return ( + + } /> + + } + /> + } + /> + } + /> + + } + /> + + } + /> + } + /> + } + /> + + } + /> + {/* Unknown paths land on Home. */} + } /> + + ); +} diff --git a/frontend/src/portal/api/assistant.ts b/frontend/src/portal/api/assistant.ts new file mode 100644 index 0000000000..95f5639aa0 --- /dev/null +++ b/frontend/src/portal/api/assistant.ts @@ -0,0 +1,15 @@ +import { httpJson } from "@app/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/src/portal/api/endpoints.ts b/frontend/src/portal/api/endpoints.ts new file mode 100644 index 0000000000..5f77c71c33 --- /dev/null +++ b/frontend/src/portal/api/endpoints.ts @@ -0,0 +1,29 @@ +import { httpJson } from "@app/api/http"; +import type { Endpoint, Vertical, VerticalKey } from "@shared/data/endpoints"; + +export type { + Endpoint, + EndpointSchema, + EndpointTierGate, + Vertical, + VerticalKey, +} from "@shared/data/endpoints"; + +/** GET /v1/endpoints — verticals plus their endpoints. */ +export async function fetchVerticals(): Promise { + return httpJson("/v1/endpoints"); +} + +/** GET /v1/endpoints/flat — flat list across every vertical. */ +export async function fetchAllEndpoints(): Promise { + return httpJson("/v1/endpoints/flat"); +} + +/** GET /v1/endpoints?vertical=… */ +export async function fetchEndpointsByVertical( + key: VerticalKey, +): Promise { + return httpJson( + `/v1/endpoints?vertical=${encodeURIComponent(key)}`, + ); +} diff --git a/frontend/src/portal/api/home.ts b/frontend/src/portal/api/home.ts new file mode 100644 index 0000000000..1496b81e68 --- /dev/null +++ b/frontend/src/portal/api/home.ts @@ -0,0 +1,44 @@ +import { httpJson } from "@app/api/http"; +import type { + ActivityEvent, + KpiEntry, + OnboardingStep, + RegionHealth, + UsageSeriesResponse, +} from "@app/mocks/home"; +import type { Tier } from "@app/contexts/TierContext"; + +export type { + ActivityEvent, + ActivityKind, + KpiEntry, + OnboardingStep, + RegionHealth, + UsagePoint, + UsageSeriesResponse, +} from "@app/mocks/home"; + +/** GET /v1/analytics/usage?window=30d */ +export async function fetchUsageSeries(): Promise { + 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/src/portal/api/http.ts b/frontend/src/portal/api/http.ts new file mode 100644 index 0000000000..88f18e7d53 --- /dev/null +++ b/frontend/src/portal/api/http.ts @@ -0,0 +1,79 @@ +/** + * Shared HTTP plumbing for the portal's service layer. + * + * Today everything is mocked in-process via {@link simulateNetwork}; when + * we wire up a real backend, the helpers in `api/*.ts` switch from + * `await simulateNetwork(); return MOCK_DATA;` to calling {@link httpJson}. + * Consumers don't change. + */ + +/** Simulated network latency, in ms. */ +const DEFAULT_LATENCY_MS = 120; +const DEFAULT_JITTER_MS = 80; + +/** + * Sleeps for a small randomised duration so mocked endpoints feel like real + * network calls (loading states actually show, race-condition bugs surface). + */ +export function simulateNetwork(options?: { + latencyMs?: number; + jitterMs?: number; +}): Promise { + const latency = options?.latencyMs ?? DEFAULT_LATENCY_MS; + const jitter = options?.jitterMs ?? DEFAULT_JITTER_MS; + const total = latency + Math.random() * jitter; + return new Promise((resolve) => window.setTimeout(resolve, total)); +} + +export interface HttpRequestOptions { + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + body?: unknown; + /** Extra headers; Content-Type and Accept are set automatically. */ + headers?: Record; + signal?: AbortSignal; +} + +export class HttpError extends Error { + constructor( + public readonly status: number, + public readonly statusText: string, + public readonly body: unknown, + ) { + super(`${status} ${statusText}`); + this.name = "HttpError"; + } +} + +/** + * Thin fetch wrapper for the future real backend. Currently unused — api + * modules call simulateNetwork() and return mocks directly. Wired here so + * the swap to a real backend is a per-endpoint change, not an architectural + * one. + */ +export async function httpJson( + 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/src/portal/api/notifications.ts b/frontend/src/portal/api/notifications.ts new file mode 100644 index 0000000000..679eb9b707 --- /dev/null +++ b/frontend/src/portal/api/notifications.ts @@ -0,0 +1,16 @@ +import { httpJson } from "@app/api/http"; +import type { Notification, NotificationCategory } from "@app/mocks/notifications"; + +export type { Notification, NotificationCategory }; + +/** GET /v1/notifications */ +export async function fetchNotifications(): Promise { + 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/src/portal/api/ops.ts b/frontend/src/portal/api/ops.ts new file mode 100644 index 0000000000..b843c30567 --- /dev/null +++ b/frontend/src/portal/api/ops.ts @@ -0,0 +1,37 @@ +import { HttpError, httpJson } from "@app/api/http"; +import type { FeaturedOp, OpResultMap } from "@app/mocks/ops"; + +export type { FeaturedOp, OpResultMap }; + +/** GET /v1/ops/featured */ +export async function fetchFeaturedOps(): Promise { + 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/src/portal/api/search.ts b/frontend/src/portal/api/search.ts new file mode 100644 index 0000000000..4a8106ed7b --- /dev/null +++ b/frontend/src/portal/api/search.ts @@ -0,0 +1,9 @@ +import { httpJson } from "@app/api/http"; +import type { QuickAction } from "@app/mocks/search"; + +export type { QuickAction }; + +/** GET /v1/search/quick-actions */ +export async function fetchQuickActions(): Promise { + return httpJson("/v1/search/quick-actions"); +} diff --git a/frontend/src/portal/components/AppShell.css b/frontend/src/portal/components/AppShell.css new file mode 100644 index 0000000000..1c7491d9a5 --- /dev/null +++ b/frontend/src/portal/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/src/portal/components/AppShell.tsx b/frontend/src/portal/components/AppShell.tsx new file mode 100644 index 0000000000..e2c61d79f6 --- /dev/null +++ b/frontend/src/portal/components/AppShell.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from "react"; +import { Sidebar } from "@app/components/Sidebar"; +import { Header } from "@app/components/Header"; +import "@app/components/AppShell.css"; + +/** + * Two-column layout: fixed-width sidebar on the left, sticky header + scrolling + * main column on the right. The Sidebar and Header read their state from + * context, so this shell stays prop-free. + */ +export function AppShell({ children }: { children: ReactNode }) { + return ( +
+ +
+
+
{children}
+
+
+ ); +} diff --git a/frontend/src/portal/components/AssistantButton.css b/frontend/src/portal/components/AssistantButton.css new file mode 100644 index 0000000000..ac412127ae --- /dev/null +++ b/frontend/src/portal/components/AssistantButton.css @@ -0,0 +1,31 @@ +.portal-assistant-btn { + position: fixed; + bottom: 1.5rem; + right: 1.5rem; + width: 3rem; + height: 3rem; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + color: #fff; + background: linear-gradient(135deg, var(--color-blue) 0%, var(--color-purple) 100%); + box-shadow: + 0 0.5rem 1.25rem rgba(59, 130, 246, 0.35), + inset 0 0.0625rem 0 rgba(255, 255, 255, 0.2); + transition: + transform var(--motion-base), + box-shadow var(--motion-base); + z-index: 40; +} + +.portal-assistant-btn:hover { + transform: scale(1.08); + box-shadow: + 0 0.75rem 1.75rem rgba(59, 130, 246, 0.45), + inset 0 0.0625rem 0 rgba(255, 255, 255, 0.25); +} + +.portal-assistant-btn:active { + transform: scale(1.02); +} diff --git a/frontend/src/portal/components/AssistantButton.tsx b/frontend/src/portal/components/AssistantButton.tsx new file mode 100644 index 0000000000..522217f705 --- /dev/null +++ b/frontend/src/portal/components/AssistantButton.tsx @@ -0,0 +1,38 @@ +import { useUI } from "@app/contexts/UIContext"; +import { SparklesIcon } from "@app/components/icons"; +import "@app/components/AssistantButton.css"; + +export function AssistantButton() { + const { assistantOpen, toggleAssistant } = useUI(); + return ( + + ); +} diff --git a/frontend/src/portal/components/AssistantPanel.css b/frontend/src/portal/components/AssistantPanel.css new file mode 100644 index 0000000000..6bc7eb2f50 --- /dev/null +++ b/frontend/src/portal/components/AssistantPanel.css @@ -0,0 +1,185 @@ +.portal-assistant { + position: fixed; + bottom: 5.5rem; + right: 1.5rem; + width: 23.75rem; + height: 32.5rem; + max-height: calc(100vh - 7rem); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-lg); + display: flex; + flex-direction: column; + overflow: hidden; + z-index: 35; + animation: fadeInUp var(--motion-enter) both; +} + +.portal-assistant__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.625rem 0.875rem; + color: var(--color-text-on-accent); + background: linear-gradient( + 135deg, + var(--color-blue) 0%, + var(--color-purple) 100% + ); +} + +.portal-assistant__header-left { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.portal-assistant__title { + font-size: 0.875rem; + font-weight: 600; +} + +.portal-assistant__close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + border-radius: var(--radius-sm); + color: rgba(255, 255, 255, 0.85); + transition: background var(--motion-fast), color var(--motion-fast); +} +.portal-assistant__close:hover { + background: rgba(255, 255, 255, 0.15); + color: var(--color-text-on-accent); +} + +.portal-assistant__messages { + flex: 1 1 auto; + overflow-y: auto; + padding: 0.875rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + background: var(--color-bg-subtle); +} + +.portal-assistant__suggestions { + margin-bottom: 0.25rem; +} + +.portal-assistant__suggestions-eyebrow { + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-section-label); + margin-bottom: 0.5rem; +} + +.portal-assistant__suggestions-list { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.portal-assistant__suggestion { + padding: 0.4375rem 0.625rem; + font-size: 0.8125rem; + text-align: left; + color: var(--color-text-2); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + transition: border-color var(--motion-fast), background var(--motion-fast); +} +.portal-assistant__suggestion:hover { + background: var(--color-bg-hover); + border-color: var(--color-blue-border); +} + +.portal-assistant__bubble { + max-width: 80%; + padding: 0.5rem 0.75rem; + font-size: 0.8125rem; + line-height: 1.5; + border-radius: var(--radius-md); + animation: fadeInUp var(--motion-enter) both; + word-wrap: break-word; +} + +.portal-assistant__bubble--user { + align-self: flex-end; + background: var(--color-blue); + color: var(--color-text-on-accent); + border-bottom-right-radius: 0.25rem; +} + +.portal-assistant__bubble--assistant { + align-self: flex-start; + background: var(--color-surface); + color: var(--color-text-2); + border: 1px solid var(--color-border); + border-bottom-left-radius: 0.25rem; +} + +.portal-assistant__typing { + display: inline-flex; + align-items: center; + gap: 0.25rem; +} +.portal-assistant__typing span { + width: 0.375rem; + height: 0.375rem; + border-radius: 50%; + background: var(--color-text-5); + animation: pulse 1.2s ease-in-out infinite; +} +.portal-assistant__typing span:nth-child(2) { + animation-delay: 0.15s; +} +.portal-assistant__typing span:nth-child(3) { + animation-delay: 0.3s; +} + +.portal-assistant__input-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.625rem 0.75rem; + border-top: 1px solid var(--color-border); + background: var(--color-surface); +} + +.portal-assistant__input { + flex: 1 1 auto; + font: inherit; + font-size: 0.8125rem; + background: transparent; + border: 1px solid var(--color-border-input); + border-radius: var(--radius-md); + padding: 0.4375rem 0.625rem; + color: var(--color-text-1); + outline: none; + transition: border-color var(--motion-fast); +} +.portal-assistant__input:focus { + border-color: var(--color-blue); +} + +.portal-assistant__send { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: var(--radius-md); + background: var(--grad-blue-btn); + color: var(--color-text-on-accent); + transition: opacity var(--motion-fast); +} +.portal-assistant__send:disabled { + opacity: 0.4; + cursor: not-allowed; +} diff --git a/frontend/src/portal/components/AssistantPanel.stories.tsx b/frontend/src/portal/components/AssistantPanel.stories.tsx new file mode 100644 index 0000000000..eee1b860fd --- /dev/null +++ b/frontend/src/portal/components/AssistantPanel.stories.tsx @@ -0,0 +1,58 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useEffect } from "react"; +import { http, HttpResponse, delay } from "msw"; +import { AssistantPanel } from "@app/components/AssistantPanel"; +import { useUI } from "@app/contexts/UIContext"; + +function ForceOpen() { + const { openAssistant } = useUI(); + useEffect(() => { + openAssistant(); + }, [openAssistant]); + return null; +} + +const meta: Meta = { + 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/src/portal/components/AssistantPanel.tsx b/frontend/src/portal/components/AssistantPanel.tsx new file mode 100644 index 0000000000..2356282e2e --- /dev/null +++ b/frontend/src/portal/components/AssistantPanel.tsx @@ -0,0 +1,167 @@ +import { useEffect, useRef, useState } from "react"; +import { useUI } from "@app/contexts/UIContext"; +import { useAsync } from "@app/hooks/useAsync"; +import { + fetchAssistantSuggestions, + getAssistantReply, +} from "@app/api/assistant"; +import { CloseIcon, SendIcon, SparklesIcon } from "@app/components/icons"; +import "@app/components/AssistantPanel.css"; + +interface Message { + id: number; + role: "user" | "assistant"; + text: string; +} + +export function AssistantPanel() { + const { assistantOpen, closeAssistant } = useUI(); + const { data: suggestions } = useAsync( + () => 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) 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/src/portal/components/DocumentTypeGrid.css b/frontend/src/portal/components/DocumentTypeGrid.css new file mode 100644 index 0000000000..9d97efd0c5 --- /dev/null +++ b/frontend/src/portal/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/src/portal/components/DocumentTypeGrid.stories.tsx b/frontend/src/portal/components/DocumentTypeGrid.stories.tsx new file mode 100644 index 0000000000..6853cc3144 --- /dev/null +++ b/frontend/src/portal/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 "@app/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/src/portal/components/DocumentTypeGrid.tsx b/frontend/src/portal/components/DocumentTypeGrid.tsx new file mode 100644 index 0000000000..7a28b52f2e --- /dev/null +++ b/frontend/src/portal/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 "@app/hooks/useAsync"; +import { + fetchVerticals, + type Endpoint, + type Vertical, + type VerticalKey, +} from "@app/api/endpoints"; +import "@app/components/DocumentTypeGrid.css"; + +type ActiveTab = VerticalKey | "all"; + +const TIER_LABEL = ["Free", "Paid", "Enterprise"] as const; +const TIER_TONE = ["success", "info", "purple"] as const; + +function tierBadge(tier: 0 | 1 | 2) { + return ( + + {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/src/portal/components/Header.css b/frontend/src/portal/components/Header.css new file mode 100644 index 0000000000..ae2d7eab05 --- /dev/null +++ b/frontend/src/portal/components/Header.css @@ -0,0 +1,176 @@ +.portal-header { + height: 3.25rem; + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0 1rem; + background: var(--color-header-bg); + border-bottom: 1px solid var(--color-header-border); + position: sticky; + top: 0; + z-index: 10; +} + +.portal-header__left { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 12.5rem; +} + +.portal-header__breadcrumb { + font-size: 0.8125rem; + font-weight: 500; + color: var(--color-header-text); +} + +/* Search trigger */ +.portal-header__search { + flex: 0 1 20rem; + margin: 0 auto; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0.625rem; + background: var(--color-search-bg); + border: 1px solid var(--color-search-border); + border-radius: var(--radius-md); + color: var(--color-search-text); + font-size: 0.75rem; + transition: border-color var(--motion-fast), background var(--motion-fast); +} +.portal-header__search:hover { + border-color: var(--color-search-border-hover); +} + +.portal-header__search-placeholder { + flex: 1 1 auto; + text-align: left; +} + +.portal-header__search-kbd { + font-family: var(--font-mono); + font-size: 0.6875rem; + padding: 0.0625rem 0.25rem; + border-radius: var(--radius-xs); + background: var(--color-bg-muted); + color: var(--color-text-4); +} + +/* Right cluster */ +.portal-header__right { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 0.375rem; +} + +.portal-header__icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: var(--radius-md); + color: var(--color-bell-stroke, var(--color-text-3)); + position: relative; + transition: background var(--motion-fast), color var(--motion-fast); +} +.portal-header__icon-btn:hover { + background: var(--color-bg-hover); + color: var(--color-text-1); +} + +.portal-header__bell-dot { + position: absolute; + top: 0.375rem; + right: 0.4375rem; + width: 0.4375rem; + height: 0.4375rem; + border-radius: 50%; + background: var(--color-red); + border: 2px solid var(--color-header-bg); + box-sizing: content-box; +} + +/* Tier switcher */ +.portal-header__tier { + position: relative; +} + +.portal-header__tier-btn { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.3125rem 0.625rem; + font-size: 0.75rem; + color: var(--color-text-2); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + transition: border-color var(--motion-fast), background var(--motion-fast); +} +.portal-header__tier-btn:hover { + background: var(--color-bg-hover); + border-color: var(--color-border-hover); +} + +.portal-header__tier-dot { + width: 0.4375rem; + height: 0.4375rem; + border-radius: 50%; +} + +.portal-header__tier-label { + font-weight: 500; +} + +.portal-header__tier-menu { + position: absolute; + top: calc(100% + 0.25rem); + right: 0; + min-width: 12rem; + margin: 0; + padding: 0.25rem; + list-style: none; + background: var(--color-dropdown-bg); + border: 1px solid var(--color-dropdown-border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + z-index: 20; + animation: fadeInUp var(--motion-enter) both; +} + +.portal-header__tier-option { + width: 100%; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.4375rem 0.625rem; + font-size: 0.8125rem; + color: var(--color-text-2); + border-radius: var(--radius-sm); + text-align: left; +} +.portal-header__tier-option:hover { + background: var(--color-dropdown-hover); +} +.portal-header__tier-option.is-active { + background: var(--color-nav-active); + color: var(--color-nav-active-text); +} + +/* Avatar */ +.portal-header__avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 50%; + background: var(--grad-blue-btn); + color: #fff; + font-size: 0.8125rem; + font-weight: 600; +} diff --git a/frontend/src/portal/components/Header.stories.tsx b/frontend/src/portal/components/Header.stories.tsx new file mode 100644 index 0000000000..26dbd53a3a --- /dev/null +++ b/frontend/src/portal/components/Header.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Header } from "@app/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/src/portal/components/Header.tsx b/frontend/src/portal/components/Header.tsx new file mode 100644 index 0000000000..66acde5e13 --- /dev/null +++ b/frontend/src/portal/components/Header.tsx @@ -0,0 +1,107 @@ +import { Avatar, Dropdown } from "@shared/components"; +import { useTheme } from "@app/contexts/ThemeContext"; +import { useTier, TIER_INFO, type Tier } from "@app/contexts/TierContext"; +import { useView, VIEW_LABELS } from "@app/contexts/ViewContext"; +import { useUI } from "@app/contexts/UIContext"; +import { + SearchIcon, + SunIcon, + MoonIcon, + ChevronDownIcon, +} from "@app/components/icons"; +import { NotificationsDropdown } from "@app/components/NotificationsDropdown"; +import { MocksToggle } from "@app/components/MocksToggle"; +import "@app/components/Header.css"; + +function ThemeToggle() { + const { theme, toggle } = useTheme(); + return ( + + ); +} + +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/src/portal/components/MocksToggle.css b/frontend/src/portal/components/MocksToggle.css new file mode 100644 index 0000000000..8e20dba0cf --- /dev/null +++ b/frontend/src/portal/components/MocksToggle.css @@ -0,0 +1,52 @@ +.portal-mocks-toggle { + display: inline-flex; + align-items: center; + gap: var(--space-1_5); + padding: var(--space-1) var(--space-2); + font-family: var(--font-mono); + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + border-radius: var(--radius-sm); + border: 1px dashed transparent; + transition: + background var(--motion-fast), + border-color var(--motion-fast), + color var(--motion-fast); +} + +.portal-mocks-toggle.is-on { + color: var(--color-amber-dark); + background: var(--color-amber-light); + border-color: var(--color-amber-border); +} + +.portal-mocks-toggle.is-off { + color: var(--color-text-4); + background: var(--color-bg-muted); + border-color: var(--color-border); +} + +.portal-mocks-toggle:hover { + filter: brightness(1.04); +} + +.portal-mocks-toggle.is-pending { + opacity: 0.6; + cursor: progress; +} + +.portal-mocks-toggle__dot { + width: 0.4375rem; + height: 0.4375rem; + border-radius: 50%; + background: currentColor; + box-shadow: 0 0 0 2px + color-mix(in srgb, currentColor 28%, transparent); +} + +.portal-mocks-toggle.is-off .portal-mocks-toggle__dot { + background: var(--color-text-5); + box-shadow: none; +} diff --git a/frontend/src/portal/components/MocksToggle.tsx b/frontend/src/portal/components/MocksToggle.tsx new file mode 100644 index 0000000000..549ec8e190 --- /dev/null +++ b/frontend/src/portal/components/MocksToggle.tsx @@ -0,0 +1,54 @@ +/// +import { useState } from "react"; +import { + readMocksPreference, + writeMocksPreference, +} from "@app/mocks/preference"; +import "@app/components/MocksToggle.css"; + +/** + * Dev-only header chip that flips MSW interception on and off. Persists the + * preference to localStorage so it survives reloads. Hidden entirely in + * production builds — there's no MSW worker to toggle there. + * + * Toggling reloads the page. Without a reload, components that already + * fetched data via useAsync keep showing the cached result, which makes the + * toggle feel like it does nothing. A reload gives a clean view of what the + * app looks like with/without mocks. + */ +export function MocksToggle() { + const [enabled] = useState(() => readMocksPreference()); + const [pending, setPending] = useState(false); + + if (!import.meta.env.DEV) return null; + + function toggle() { + if (pending) return; + setPending(true); + writeMocksPreference(!enabled); + window.location.reload(); + } + + return ( + + ); +} diff --git a/frontend/src/portal/components/NotificationsDropdown.css b/frontend/src/portal/components/NotificationsDropdown.css new file mode 100644 index 0000000000..325f8f4fb8 --- /dev/null +++ b/frontend/src/portal/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/src/portal/components/NotificationsDropdown.stories.tsx b/frontend/src/portal/components/NotificationsDropdown.stories.tsx new file mode 100644 index 0000000000..e8f1654e09 --- /dev/null +++ b/frontend/src/portal/components/NotificationsDropdown.stories.tsx @@ -0,0 +1,70 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse, delay } from "msw"; +import { NotificationsDropdown } from "@app/components/NotificationsDropdown"; +import { NOTIFICATIONS } from "@app/mocks/notifications"; + +const meta: Meta = { + 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/src/portal/components/NotificationsDropdown.tsx b/frontend/src/portal/components/NotificationsDropdown.tsx new file mode 100644 index 0000000000..c1db3d45e5 --- /dev/null +++ b/frontend/src/portal/components/NotificationsDropdown.tsx @@ -0,0 +1,124 @@ +import { useEffect, useState } from "react"; +import { Dropdown, EmptyState, Skeleton } from "@shared/components"; +import { BellIcon } from "@app/components/icons"; +import { useAsync, useSectionFlags } from "@app/hooks/useAsync"; +import { + fetchNotifications, + markAllNotificationsRead, + type Notification, + type NotificationCategory, +} from "@app/api/notifications"; +import "@app/components/NotificationsDropdown.css"; + +const CATEGORY_COLOUR: Record = { + 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); + + const [localItems, setLocalItems] = useState(null); + const visible = localItems ?? items ?? []; + + useEffect(() => { + if (items) setLocalItems(items); + }, [items]); + + async function onMarkAllRead() { + setLocalItems([]); + await markAllNotificationsRead(); + } + + const isEmpty = !isLoading && visible.length === 0; + const hasUnread = visible.length > 0; + + return ( + + + + + +
+ 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/src/portal/components/RecentActivity.css b/frontend/src/portal/components/RecentActivity.css new file mode 100644 index 0000000000..9fceadfe84 --- /dev/null +++ b/frontend/src/portal/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/src/portal/components/RecentActivity.stories.tsx b/frontend/src/portal/components/RecentActivity.stories.tsx new file mode 100644 index 0000000000..332944fd18 --- /dev/null +++ b/frontend/src/portal/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 "@app/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/src/portal/components/RecentActivity.tsx b/frontend/src/portal/components/RecentActivity.tsx new file mode 100644 index 0000000000..992db888d4 --- /dev/null +++ b/frontend/src/portal/components/RecentActivity.tsx @@ -0,0 +1,104 @@ +import { Card, EmptyState, Skeleton, StatusBadge } from "@shared/components"; +import { useAsync, useSectionFlags } from "@app/hooks/useAsync"; +import { + fetchRecentActivity, + type ActivityEvent, + type ActivityKind, +} from "@app/api/home"; +import "@app/components/RecentActivity.css"; + +const STATUS_TONE: Record< + ActivityEvent["status"], + "success" | "warning" | "danger" | "info" +> = { + success: "success", + warning: "warning", + danger: "danger", + info: "info", +}; + +const KIND_COLOUR: Record = { + "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/src/portal/components/SearchModal.css b/frontend/src/portal/components/SearchModal.css new file mode 100644 index 0000000000..6b139c975d --- /dev/null +++ b/frontend/src/portal/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/src/portal/components/SearchModal.stories.tsx b/frontend/src/portal/components/SearchModal.stories.tsx new file mode 100644 index 0000000000..c743fc4972 --- /dev/null +++ b/frontend/src/portal/components/SearchModal.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useEffect } from "react"; +import { http, HttpResponse } from "msw"; +import { SearchModal } from "@app/components/SearchModal"; +import { useUI } from "@app/contexts/UIContext"; + +function ForceOpen() { + const { openSearch } = useUI(); + useEffect(() => { + openSearch(); + }, [openSearch]); + return null; +} + +const meta: Meta = { + 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/src/portal/components/SearchModal.tsx b/frontend/src/portal/components/SearchModal.tsx new file mode 100644 index 0000000000..6d984370c4 --- /dev/null +++ b/frontend/src/portal/components/SearchModal.tsx @@ -0,0 +1,119 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { EmptyState, Modal, Skeleton } from "@shared/components"; +import { useUI } from "@app/contexts/UIContext"; +import { useAsync, useSectionFlags } from "@app/hooks/useAsync"; +import { fetchQuickActions, type QuickAction } from "@app/api/search"; +import { SearchIcon } from "@app/components/icons"; +import "@app/components/SearchModal.css"; + +export function SearchModal() { + const { searchOpen, closeSearch } = useUI(); + const inputRef = useRef(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/src/portal/components/Sidebar.css b/frontend/src/portal/components/Sidebar.css new file mode 100644 index 0000000000..4e8e52003a --- /dev/null +++ b/frontend/src/portal/components/Sidebar.css @@ -0,0 +1,128 @@ +.portal-sidebar { + width: 15rem; + height: 100vh; + background: var(--color-sidebar-bg); + border-right: 1px solid var(--color-sidebar-border); + display: flex; + flex-direction: column; + flex-shrink: 0; + position: sticky; + top: 0; +} + +/* Logo block */ +.portal-sidebar__logo { + height: 3.1875rem; /* 51px */ + padding: 0 0.875rem; + display: flex; + align-items: center; + gap: 0.5rem; + border-bottom: 1px solid var(--color-sidebar-divider); +} + +.portal-sidebar__wordmark { + font-family: var(--font-brand); + font-size: 1.125rem; + font-weight: 700; + color: var(--color-logo-text); + letter-spacing: 0.02em; +} + +.portal-sidebar__workspace { + margin-left: auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + border-radius: var(--radius-sm); + color: var(--color-text-4); + transition: background var(--motion-fast), color var(--motion-fast); +} +.portal-sidebar__workspace:hover { + background: var(--color-bg-hover); + color: var(--color-text-2); +} + +/* Nav body */ +.portal-sidebar__nav { + flex: 1 1 auto; + overflow-y: auto; + padding: 0.75rem 0.625rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.portal-sidebar__group { + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.portal-sidebar__divider { + height: 1px; + background: var(--color-sidebar-divider); + margin: 0.25rem 0; +} + +/* Footer */ +.portal-sidebar__footer { + border-top: 1px solid var(--color-sidebar-divider); + padding: 0.5rem 0.625rem 0.75rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.portal-sidebar__usage { + padding: 0.5rem 0.625rem; + font-size: 0.75rem; + color: var(--color-usage-text); +} + +.portal-sidebar__usage-line { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.portal-sidebar__usage-label { + color: var(--color-text-4); +} + +.portal-sidebar__usage-value { + color: var(--color-usage-value); + font-weight: 500; +} + +.portal-sidebar__usage-track { + margin-top: 0.5rem; + height: 0.25rem; + background: var(--color-usage-track); + border-radius: var(--radius-pill); + overflow: hidden; +} + +.portal-sidebar__usage-fill { + height: 100%; + background: var(--grad-blue-btn); + transition: width var(--motion-slow); +} + +.portal-sidebar__plan { + display: inline-flex; + align-items: center; + gap: 0.375rem; + color: var(--color-text-2); + font-weight: 500; +} + +.portal-sidebar__plan-dot { + width: 0.4375rem; + height: 0.4375rem; + border-radius: 50%; + background: var(--color-green); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-green) 30%, transparent); +} diff --git a/frontend/src/portal/components/Sidebar.stories.tsx b/frontend/src/portal/components/Sidebar.stories.tsx new file mode 100644 index 0000000000..dbfab1474e --- /dev/null +++ b/frontend/src/portal/components/Sidebar.stories.tsx @@ -0,0 +1,29 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Sidebar } from "@app/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/src/portal/components/Sidebar.tsx b/frontend/src/portal/components/Sidebar.tsx new file mode 100644 index 0000000000..34e51e362b --- /dev/null +++ b/frontend/src/portal/components/Sidebar.tsx @@ -0,0 +1,160 @@ +import { NavItem } from "@shared/components"; +import { useView, type ViewId } from "@app/contexts/ViewContext"; +import { useTier } from "@app/contexts/TierContext"; +import { + HomeIcon, + EditorIcon, + SourcesIcon, + PipelinesIcon, + DocumentsIcon, + InfrastructureIcon, + UsageIcon, + DocsIcon, + SettingsIcon, + ChevronDownIcon, +} from "@app/components/icons"; +import "@app/components/Sidebar.css"; + +interface NavEntry { + id: ViewId; + label: string; + icon: React.ReactNode; +} + +const GROUP_PRIMARY: NavEntry[] = [ + { id: "home", label: "Home", icon: }, +]; + +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(); + + if (tier === "free") { + const docs = 247; + const cap = 500; + const pct = (docs / cap) * 100; + return ( +
+
+ Docs processed + + {docs} / {cap} + +
+
+
+
+
+ ); + } + + const planLabel = tier === "pro" ? "Pay-as-you-go" : "Enterprise Plan"; + const docCount = tier === "pro" ? "12,481" : "1.84M"; + + return ( +
+
+ + + {planLabel} + + {docCount} 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/src/portal/components/SingleOpRunner.css b/frontend/src/portal/components/SingleOpRunner.css new file mode 100644 index 0000000000..47b998388a --- /dev/null +++ b/frontend/src/portal/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/src/portal/components/SingleOpRunner.stories.tsx b/frontend/src/portal/components/SingleOpRunner.stories.tsx new file mode 100644 index 0000000000..9a11e5f010 --- /dev/null +++ b/frontend/src/portal/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 "@app/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/src/portal/components/SingleOpRunner.tsx b/frontend/src/portal/components/SingleOpRunner.tsx new file mode 100644 index 0000000000..80f69417ec --- /dev/null +++ b/frontend/src/portal/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 "@app/contexts/ViewContext"; +import { useAsync, useSectionFlags } from "@app/hooks/useAsync"; +import { + fetchFeaturedOps, + runSingleOp, + type FeaturedOp, + type OpResultMap, +} from "@app/api/ops"; +import "@app/components/SingleOpRunner.css"; + +type Phase = "idle" | "running" | "done" | "error"; + +interface SingleOpRunnerProps { + open: boolean; + onClose: () => void; + /** Optional pre-selected op id (e.g. carousel deep-link). */ + initialOpId?: string; +} + +const SAMPLE_DOCS = [ + "certificate-of-insurance.pdf", + "loss-run-2025.pdf", + "invoice-acme-corp-q1.pdf", + "prior-auth-cigna-12471.pdf", + "contract-acme-msa-v3.pdf", +]; + +interface RunResult { + result: OpResultMap; + durationMs: number; + opId: string; +} + +export function SingleOpRunner({ + open, + onClose, + initialOpId, +}: SingleOpRunnerProps) { + const { setActiveView } = useView(); + const opsState = useAsync(() => 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/src/portal/components/UsageAreaChart.css b/frontend/src/portal/components/UsageAreaChart.css new file mode 100644 index 0000000000..c6d326c501 --- /dev/null +++ b/frontend/src/portal/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/src/portal/components/UsageAreaChart.stories.tsx b/frontend/src/portal/components/UsageAreaChart.stories.tsx new file mode 100644 index 0000000000..306584a193 --- /dev/null +++ b/frontend/src/portal/components/UsageAreaChart.stories.tsx @@ -0,0 +1,50 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { UsageAreaChart } from "@app/components/UsageAreaChart"; +import { buildUsageSeries } from "@app/mocks/home"; + +const meta: Meta = { + 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/src/portal/components/UsageAreaChart.tsx b/frontend/src/portal/components/UsageAreaChart.tsx new file mode 100644 index 0000000000..eec59e33fb --- /dev/null +++ b/frontend/src/portal/components/UsageAreaChart.tsx @@ -0,0 +1,271 @@ +import { useMemo, useRef, useState, type KeyboardEvent } from "react"; +import type { UsagePoint } from "@app/api/home"; +import "@app/components/UsageAreaChart.css"; + +interface UsageAreaChartProps { + data: UsagePoint[]; + /** Headline metric shown above the chart (e.g. total 30d, current value). */ + totalLabel?: string; + totalValue?: string; + deltaPct?: number; +} + +const PADDING = { top: 16, right: 20, bottom: 28, left: 32 } as const; +const VIEW = { width: 800, height: 240 } as const; + +function formatTick(date: string): string { + return new Date(date).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); +} + +function formatNumber(value: number): string { + if (value >= 1000) { + return `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`; + } + return value.toLocaleString(); +} + +export function UsageAreaChart({ + data, + totalLabel = "Docs processed · last 30 days", + totalValue, + deltaPct, +}: UsageAreaChartProps) { + const [hoverIndex, setHoverIndex] = useState(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. + const niceMax = Math.ceil(max / 500) * 500; + const yTicksCalc = [0, niceMax * 0.5, niceMax]; + + const innerW = VIEW.width - PADDING.left - PADDING.right; + const innerH = VIEW.height - PADDING.top - PADDING.bottom; + const xStep = innerW / Math.max(data.length - 1, 1); + + const pts = data.map((raw, i) => ({ + x: PADDING.left + i * xStep, + y: PADDING.top + innerH - (raw.value / niceMax) * innerH, + raw, + })); + + const linePathStr = pts + .map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(2)} ${p.y.toFixed(2)}`) + .join(" "); + const baseY = PADDING.top + innerH; + const areaPathStr = `${linePathStr} L ${pts[pts.length - 1].x.toFixed(2)} ${baseY} L ${pts[0].x.toFixed(2)} ${baseY} Z`; + + // X ticks: 5 evenly spread. + const xIdx: number[] = []; + const tickCount = 5; + for (let t = 0; t < tickCount; t++) { + xIdx.push(Math.round((t * (data.length - 1)) / (tickCount - 1))); + } + + return { + points: pts, + areaPath: areaPathStr, + linePath: linePathStr, + yMax: niceMax, + yTicks: yTicksCalc, + xTickIndices: xIdx, + }; + }, [data]); + + function onMouseMove(e: React.MouseEvent) { + 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/src/portal/components/WelcomeCarousel.css b/frontend/src/portal/components/WelcomeCarousel.css new file mode 100644 index 0000000000..36176917ee --- /dev/null +++ b/frontend/src/portal/components/WelcomeCarousel.css @@ -0,0 +1,223 @@ +.portal-carousel { + position: relative; + overflow: hidden; + padding: 2rem; + border-radius: var(--radius-xl); + background: var(--grad-banner); + border: 1px solid var(--color-border); + isolation: isolate; +} + +.portal-carousel__inner { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 20rem); + gap: 2rem; + align-items: center; + animation: fadeInUp var(--motion-enter) both; +} + +@media (max-width: 50rem) { + .portal-carousel__inner { + grid-template-columns: 1fr; + } +} + +.portal-carousel__text { + min-width: 0; +} + +.portal-carousel__eyebrow { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-blue); + margin-bottom: 0.5rem; +} + +.portal-carousel__title { + margin: 0 0 0.75rem; + font-size: 1.75rem; + font-weight: 700; + line-height: 1.15; + color: var(--color-text-1); +} + +.portal-carousel__sub { + margin: 0 0 1.25rem; + font-size: 0.9375rem; + line-height: 1.55; + color: var(--color-text-3); + max-width: 36rem; +} + +.portal-carousel__cta { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +/* Ornament container */ +.portal-carousel__ornament { + display: flex; + align-items: center; + justify-content: center; +} + +/* Doc card ornament (slide 0) */ +.portal-carousel__doc { + width: 100%; + padding: 1rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-md); +} +.portal-carousel__doc-title { + margin-top: 0.5rem; + font-size: 0.9375rem; + font-weight: 600; + color: var(--color-text-1); +} +.portal-carousel__doc-sub { + margin-top: 0.125rem; + font-size: 0.75rem; + color: var(--color-text-4); +} +.portal-carousel__doc-meta { + margin-top: 0.75rem; + font-size: 0.75rem; + color: var(--color-text-4); + display: flex; + gap: 0.375rem; + flex-wrap: wrap; +} + +/* Code ornament (slide 1) */ +.portal-carousel__code { + width: 100%; + margin: 0; + padding: 0.875rem 1rem; + background: var(--code-bg); + color: var(--code-text); + border: 1px solid var(--code-border); + border-radius: var(--radius-md); + font-family: var(--font-mono); + font-size: 0.75rem; + line-height: 1.65; + white-space: pre-wrap; + word-break: break-word; + box-shadow: var(--shadow-lg); +} +.portal-carousel__code-method { color: var(--code-fn); font-weight: 600; } +.portal-carousel__code-path { color: var(--code-keyword); } +.portal-carousel__code-key { color: var(--code-property); } +.portal-carousel__code-string { color: var(--code-string); } + +/* Eval ornament (slide 2) */ +.portal-carousel__eval { + width: 100%; + padding: 1rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-md); +} +.portal-carousel__eval-head { + display: flex; + align-items: baseline; + justify-content: space-between; +} +.portal-carousel__eval-title { + font-size: 0.875rem; + font-weight: 600; + color: var(--color-text-1); +} +.portal-carousel__eval-score { + font-size: 1.125rem; + font-weight: 700; + color: var(--color-green); +} +.portal-carousel__eval-sub { + font-size: 0.75rem; + color: var(--color-text-4); + margin-top: 0.125rem; +} +.portal-carousel__eval-bar { + margin-top: 0.75rem; + height: 0.375rem; + background: var(--color-bg-muted); + border-radius: var(--radius-pill); + overflow: hidden; +} +.portal-carousel__eval-fill { + height: 100%; + background: var(--grad-green-btn); +} +.portal-carousel__eval-meta { + margin-top: 0.625rem; + display: flex; + justify-content: space-between; + font-size: 0.6875rem; + color: var(--color-text-4); +} + +/* Decoration blobs */ +.portal-carousel__decor { + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; +} +.portal-carousel__blob { + position: absolute; + border-radius: 50%; + filter: blur(2.5rem); +} +.portal-carousel__blob--1 { + top: -3rem; + right: -2rem; + width: 16rem; + height: 16rem; + background: radial-gradient(circle, var(--color-purple), transparent 70%); + opacity: 0.4; +} +.portal-carousel__blob--2 { + bottom: -4rem; + right: 8rem; + width: 12rem; + height: 12rem; + background: radial-gradient(circle, var(--color-blue), transparent 70%); + opacity: 0.3; +} + +/* Pagination dots */ +.portal-carousel__dots { + position: absolute; + bottom: 0.875rem; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 0.375rem; + z-index: 2; +} +.portal-carousel__dot { + width: 0.4375rem; + height: 0.4375rem; + border-radius: 50%; + background: var(--color-text-5); + opacity: 0.4; + transition: opacity var(--motion-fast), background var(--motion-fast), + width var(--motion-fast); +} +.portal-carousel__dot:hover { + opacity: 0.7; +} +.portal-carousel__dot.is-active { + width: 1rem; + border-radius: var(--radius-pill); + background: var(--color-blue); + opacity: 1; +} diff --git a/frontend/src/portal/components/WelcomeCarousel.stories.tsx b/frontend/src/portal/components/WelcomeCarousel.stories.tsx new file mode 100644 index 0000000000..bfc3fd02fd --- /dev/null +++ b/frontend/src/portal/components/WelcomeCarousel.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { WelcomeCarousel } from "@app/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/src/portal/components/WelcomeCarousel.tsx b/frontend/src/portal/components/WelcomeCarousel.tsx new file mode 100644 index 0000000000..5934797ffe --- /dev/null +++ b/frontend/src/portal/components/WelcomeCarousel.tsx @@ -0,0 +1,211 @@ +import { useEffect, useState, type ReactNode } from "react"; +import { Button, StatusBadge } from "@shared/components"; +import { useView, type ViewId } from "@app/contexts/ViewContext"; +import "@app/components/WelcomeCarousel.css"; + +type SlideAction = + | { label: string; target: ViewId } + | { label: string; action: "try-op" }; + +interface Slide { + id: string; + eyebrow: string; + title: string; + sub: string; + durationMs: number; + primary: SlideAction; + secondary: SlideAction; + ornament: ReactNode; +} + +function EditorOrnament() { + return ( +
+ + 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={() => 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/src/portal/components/icons.tsx b/frontend/src/portal/components/icons.tsx new file mode 100644 index 0000000000..0018850c57 --- /dev/null +++ b/frontend/src/portal/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/src/portal/contexts/ThemeContext.tsx b/frontend/src/portal/contexts/ThemeContext.tsx new file mode 100644 index 0000000000..ad979f5e91 --- /dev/null +++ b/frontend/src/portal/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/src/portal/contexts/TierContext.tsx b/frontend/src/portal/contexts/TierContext.tsx new file mode 100644 index 0000000000..c7787bfab0 --- /dev/null +++ b/frontend/src/portal/contexts/TierContext.tsx @@ -0,0 +1,48 @@ +import { + createContext, + useContext, + useMemo, + useState, + type ReactNode, +} from "react"; + +export type Tier = "free" | "pro" | "enterprise"; + +export interface TierInfo { + label: string; + dotColor: string; +} + +export const TIER_INFO: Record = { + 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/src/portal/contexts/UIContext.tsx b/frontend/src/portal/contexts/UIContext.tsx new file mode 100644 index 0000000000..68154427fd --- /dev/null +++ b/frontend/src/portal/contexts/UIContext.tsx @@ -0,0 +1,69 @@ +import { + createContext, + useCallback, + useContext, + useMemo, + useState, + type ReactNode, +} from "react"; + +interface UIContextValue { + searchOpen: boolean; + openSearch: () => void; + closeSearch: () => void; + toggleSearch: () => void; + + assistantOpen: boolean; + openAssistant: () => void; + closeAssistant: () => void; + toggleAssistant: () => void; +} + +const UIContext = createContext(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; +} + +/** + * Convenience hook — registers a global keyboard shortcut. Caller owns the + * handler so the same hook works for ⌘K, ESC, etc. + */ +export function useKeyboardShortcut( + match: (e: KeyboardEvent) => boolean, + handler: (e: KeyboardEvent) => void, +) { + // Use a stable handler ref via useCallback to keep dependencies stable. + const onKey = useCallback( + (e: KeyboardEvent) => { + if (match(e)) handler(e); + }, + [match, handler], + ); + // Effect lives in the caller; keep this a pure utility. + return onKey; +} diff --git a/frontend/src/portal/contexts/ViewContext.tsx b/frontend/src/portal/contexts/ViewContext.tsx new file mode 100644 index 0000000000..28aa118e5d --- /dev/null +++ b/frontend/src/portal/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/src/portal/hooks/useAsync.ts b/frontend/src/portal/hooks/useAsync.ts new file mode 100644 index 0000000000..6837a08609 --- /dev/null +++ b/frontend/src/portal/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/src/portal/mocks/assistant.ts b/frontend/src/portal/mocks/assistant.ts new file mode 100644 index 0000000000..c5d9ed9cae --- /dev/null +++ b/frontend/src/portal/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/src/portal/mocks/browser.ts b/frontend/src/portal/mocks/browser.ts new file mode 100644 index 0000000000..c95505579e --- /dev/null +++ b/frontend/src/portal/mocks/browser.ts @@ -0,0 +1,23 @@ +import { setupWorker } from "msw/browser"; +import { handlers } from "@app/mocks/handlers"; + +export const worker = setupWorker(...handlers); + +let workerStarted = false; + +/** + * Start the MSW worker. Idempotent — calling repeatedly is safe. + * + * The toggle flips MSW by writing the preference to localStorage and + * reloading the page, so there's no need for a `stopMockWorker` counterpart: + * the next boot just decides whether to call this or not. + */ +export async function startMockWorker(): Promise { + if (workerStarted) return; + await worker.start({ + onUnhandledRequest: "bypass", + serviceWorker: { url: "/mockServiceWorker.js" }, + quiet: true, + }); + workerStarted = true; +} diff --git a/frontend/src/portal/mocks/handlers/assistant.ts b/frontend/src/portal/mocks/handlers/assistant.ts new file mode 100644 index 0000000000..bac5a08a7e --- /dev/null +++ b/frontend/src/portal/mocks/handlers/assistant.ts @@ -0,0 +1,21 @@ +import { http, HttpResponse, delay } from "msw"; +import { + ASSISTANT_SUGGESTIONS, + routeAssistantReply, +} from "@app/mocks/assistant"; + +interface AssistantMessageBody { + input: string; +} + +export const assistantHandlers = [ + http.get("/v1/assistant/suggestions", async () => { + return HttpResponse.json(ASSISTANT_SUGGESTIONS); + }), + + http.post("/v1/assistant/messages", async ({ request }) => { + const body = (await request.json()) as AssistantMessageBody; + await delay(600 + Math.random() * 300); + return HttpResponse.json({ reply: routeAssistantReply(body.input) }); + }), +]; diff --git a/frontend/src/portal/mocks/handlers/endpoints.ts b/frontend/src/portal/mocks/handlers/endpoints.ts new file mode 100644 index 0000000000..a87819cd4d --- /dev/null +++ b/frontend/src/portal/mocks/handlers/endpoints.ts @@ -0,0 +1,20 @@ +import { http, HttpResponse, delay } from "msw"; +import { ALL_ENDPOINTS, VERTICALS } from "@shared/data/endpoints"; + +export const endpointsHandlers = [ + http.get("/v1/endpoints", async ({ request }) => { + await delay(120); + const url = new URL(request.url); + const verticalKey = url.searchParams.get("vertical"); + if (verticalKey) { + const v = VERTICALS.find((x) => x.key === verticalKey); + return HttpResponse.json(v ? v.endpoints : []); + } + return HttpResponse.json(VERTICALS); + }), + + http.get("/v1/endpoints/flat", async () => { + await delay(120); + return HttpResponse.json(ALL_ENDPOINTS); + }), +]; diff --git a/frontend/src/portal/mocks/handlers/home.ts b/frontend/src/portal/mocks/handlers/home.ts new file mode 100644 index 0000000000..370a22a034 --- /dev/null +++ b/frontend/src/portal/mocks/handlers/home.ts @@ -0,0 +1,49 @@ +import { http, HttpResponse, delay } from "msw"; +import type { Tier } from "@app/contexts/TierContext"; +import { + buildUsageSeries, + buildUsageSeriesResponse, + enterpriseKpisFor, + FREE_KPIS, + FREE_ONBOARDING, + proKpisFor, + RECENT_ACTIVITY, + REGION_HEALTH, + type KpiEntry, +} from "@app/mocks/home"; + +function kpisFor(tier: Tier): KpiEntry[] { + if (tier === "free") return FREE_KPIS; + const docs30d = buildUsageSeries().reduce((sum, p) => sum + p.value, 0); + if (tier === "enterprise") return enterpriseKpisFor(docs30d); + return proKpisFor(docs30d); +} + +export const homeHandlers = [ + http.get("/v1/analytics/usage", async () => { + await delay(120); + return HttpResponse.json(buildUsageSeriesResponse()); + }), + + http.get("/v1/activity", async () => { + await delay(120); + return HttpResponse.json(RECENT_ACTIVITY); + }), + + http.get("/v1/home/kpis", async ({ request }) => { + await delay(120); + const url = new URL(request.url); + const tier = (url.searchParams.get("tier") ?? "pro") as Tier; + return HttpResponse.json(kpisFor(tier)); + }), + + http.get("/v1/regions/health", async () => { + await delay(120); + return HttpResponse.json(REGION_HEALTH); + }), + + http.get("/v1/onboarding", async () => { + await delay(120); + return HttpResponse.json(FREE_ONBOARDING); + }), +]; diff --git a/frontend/src/portal/mocks/handlers/index.ts b/frontend/src/portal/mocks/handlers/index.ts new file mode 100644 index 0000000000..07df35ed1f --- /dev/null +++ b/frontend/src/portal/mocks/handlers/index.ts @@ -0,0 +1,17 @@ +import { assistantHandlers } from "@app/mocks/handlers/assistant"; +import { endpointsHandlers } from "@app/mocks/handlers/endpoints"; +import { homeHandlers } from "@app/mocks/handlers/home"; +import { notificationsHandlers } from "@app/mocks/handlers/notifications"; +import { opsHandlers } from "@app/mocks/handlers/ops"; +import { searchHandlers } from "@app/mocks/handlers/search"; + +export const handlers = [ + ...homeHandlers, + ...opsHandlers, + ...notificationsHandlers, + ...assistantHandlers, + ...searchHandlers, + ...endpointsHandlers, +]; + +export { resetNotificationsStore } from "@app/mocks/handlers/notifications"; diff --git a/frontend/src/portal/mocks/handlers/notifications.ts b/frontend/src/portal/mocks/handlers/notifications.ts new file mode 100644 index 0000000000..52c6326097 --- /dev/null +++ b/frontend/src/portal/mocks/handlers/notifications.ts @@ -0,0 +1,21 @@ +import { http, HttpResponse, delay } from "msw"; +import { NOTIFICATIONS, type Notification } from "@app/mocks/notifications"; + +let store: Notification[] = [...NOTIFICATIONS]; + +export function resetNotificationsStore(seed?: Notification[]): void { + store = seed ? [...seed] : [...NOTIFICATIONS]; +} + +export const notificationsHandlers = [ + http.get("/v1/notifications", async () => { + await delay(120); + return HttpResponse.json(store); + }), + + http.post("/v1/notifications/mark-all-read", async () => { + await delay(120); + store = []; + return HttpResponse.json({ ok: true }); + }), +]; diff --git a/frontend/src/portal/mocks/handlers/ops.ts b/frontend/src/portal/mocks/handlers/ops.ts new file mode 100644 index 0000000000..5110803c82 --- /dev/null +++ b/frontend/src/portal/mocks/handlers/ops.ts @@ -0,0 +1,27 @@ +import { http, HttpResponse, delay } from "msw"; +import { FEATURED_OPS, OP_RESULTS } from "@app/mocks/ops"; + +export const opsHandlers = [ + http.get("/v1/ops/featured", async () => { + await delay(120); + return HttpResponse.json(FEATURED_OPS); + }), + + http.post("/v1/ops/:opId/run", async ({ params }) => { + const start = performance.now(); + await delay(800 + Math.random() * 200); + const opId = String(params.opId); + const result = OP_RESULTS[opId]; + if (!result) { + return HttpResponse.json({ error: `Unknown op: ${opId}` }, { status: 404 }); + } + const enriched = + opId === "sign-output" + ? { ...result, signed_at: new Date().toISOString() } + : result; + return HttpResponse.json({ + result: enriched, + durationMs: Math.round(performance.now() - start), + }); + }), +]; diff --git a/frontend/src/portal/mocks/handlers/search.ts b/frontend/src/portal/mocks/handlers/search.ts new file mode 100644 index 0000000000..7cb184199d --- /dev/null +++ b/frontend/src/portal/mocks/handlers/search.ts @@ -0,0 +1,8 @@ +import { http, HttpResponse } from "msw"; +import { QUICK_ACTIONS } from "@app/mocks/search"; + +export const searchHandlers = [ + http.get("/v1/search/quick-actions", () => { + return HttpResponse.json(QUICK_ACTIONS); + }), +]; diff --git a/frontend/src/portal/mocks/home.ts b/frontend/src/portal/mocks/home.ts new file mode 100644 index 0000000000..0d079b2e90 --- /dev/null +++ b/frontend/src/portal/mocks/home.ts @@ -0,0 +1,253 @@ +/** + * Mock data sources for the home dashboard. Imported only by api/home.ts — + * components never reach into this module directly. + * + * Once a backend exists, api/home.ts swaps to httpJson() calls and these + * fixtures can be deleted (or kept as test seeds). + */ + +export interface UsagePoint { + /** ISO date (YYYY-MM-DD). */ + date: string; + /** Docs processed on that day. */ + value: number; +} + +/** + * Server response for the usage-series endpoint. Returning the prior window's + * total alongside the points lets the client derive the headline delta + * deterministically from real data rather than carrying a hardcoded figure. + */ +export interface UsageSeriesResponse { + points: UsagePoint[]; + /** Equivalent docs total from the immediately prior 30-day window. */ + priorTotal: number; +} + +/** Builds 30 daily points ending today. Deterministic per day. */ +export function buildUsageSeries(): UsagePoint[] { + const points: UsagePoint[] = []; + const now = new Date(); + for (let i = 29; i >= 0; i--) { + const d = new Date(now); + d.setDate(now.getDate() - i); + const day = d.getDay(); + // Weekend dip + slow uptrend + bounded noise. + const weekend = day === 0 || day === 6 ? 0.55 : 1; + const trend = 1 + (30 - i) * 0.012; + const wobble = 1 + Math.sin(i * 1.3) * 0.18 + Math.cos(i * 0.6) * 0.09; + const base = 1450 * weekend * trend * wobble; + points.push({ + date: d.toISOString().slice(0, 10), + value: Math.round(base), + }); + } + return points; +} + +/** Builds the full usage payload with a plausible prior-window total. */ +export function buildUsageSeriesResponse(): UsageSeriesResponse { + const points = buildUsageSeries(); + const currentTotal = points.reduce((sum, p) => sum + p.value, 0); + // The current window's series simulates ~12% growth over the prior one. + const priorTotal = Math.round(currentTotal / 1.12); + return { points, priorTotal }; +} + +export type ActivityKind = + | "pipeline-run" + | "deploy" + | "drift" + | "eval" + | "agent" + | "billing"; + +export interface ActivityEvent { + id: string; + kind: ActivityKind; + /** Short action verb shown at the top of the row. */ + action: string; + /** Subject of the action (pipeline / endpoint / agent name). */ + subject: string; + /** One-line detail line under the action. */ + detail: string; + /** Relative-time string. */ + time: string; + status: "success" | "warning" | "danger" | "info"; +} + +export const RECENT_ACTIVITY: ActivityEvent[] = [ + { + id: "act-1", + kind: "pipeline-run", + action: "Pipeline run completed", + subject: "COI Compliance", + detail: "1,287 docs · 0.4% errors · P95 412 ms", + time: "2m ago", + status: "success", + }, + { + id: "act-2", + kind: "deploy", + action: "Deployed", + subject: "Prior Auth v3.1.0", + detail: "Promoted to us-east-1, eu-west-1 · golden set 36/36", + time: "14m ago", + status: "success", + }, + { + id: "act-3", + kind: "drift", + action: "Schema drift detected", + subject: "Invoice v3", + detail: "12 docs in 1h didn't match — confidence ↓ 0.07", + time: "1h ago", + status: "warning", + }, + { + id: "act-4", + kind: "eval", + action: "Eval set passed", + subject: "KYC Processor", + detail: "94% (26/28) — 2 cases sent to review", + time: "3h ago", + status: "success", + }, + { + id: "act-5", + kind: "agent", + action: "Agent escalated", + subject: "Contract Router", + detail: "Low-confidence DPA routed to L2 reviewer pool", + time: "5h ago", + status: "info", + }, + { + id: "act-6", + kind: "pipeline-run", + action: "Pipeline run failed", + subject: "Contract Review", + detail: "8% error rate · 14 docs sent to review queue", + time: "8h ago", + status: "danger", + }, + { + id: "act-7", + kind: "billing", + action: "Approaching cap", + subject: "Monthly usage", + detail: "389k of 500k docs · auto-upgrade disabled", + time: "yesterday", + status: "warning", + }, + { + id: "act-8", + kind: "deploy", + action: "Rolled back", + subject: "COI Compliance v2.3.7", + detail: "Confidence regressions on Carrier supplement", + time: "2d ago", + status: "warning", + }, +]; + +/** + * KPI labels are owned by the client (see `KPI_LABELS_BY_TIER` in Home.tsx) + * because they're product copy that should stay stable across loading / empty + * / ready states. The API only ships values + deltas. + */ +export interface KpiEntry { + value: string | number; + delta?: number; + description?: string; + deltaDirection?: "up" | "down" | "flat"; +} + +export const FREE_KPIS: KpiEntry[] = [ + { value: "247 / 500" }, + { value: 189 }, + { value: 3 }, + { value: 1 }, +]; + +export function proKpisFor(docs30d: number): KpiEntry[] { + return [ + { value: docs30d.toLocaleString(), delta: 0.12 }, + { value: 12, delta: 0.16 }, + { value: 7, delta: 0.4 }, + { value: "94.6%", delta: 0.02 }, + ]; +} + +export function enterpriseKpisFor(docs30d: number): KpiEntry[] { + return [ + { value: docs30d.toLocaleString(), delta: 0.18 }, + { value: "412 ms", delta: -0.05 }, + { value: "96.2%", delta: 0.01 }, + { value: "99.987%" }, + ]; +} + +export interface RegionHealth { + name: string; + status: "healthy" | "degraded" | "down"; + meta: string; +} + +export const REGION_HEALTH: RegionHealth[] = [ + { + name: "us-east-1", + status: "healthy", + meta: "2.1k/min · P95 287ms · 99.99% uptime", + }, + { + name: "eu-west-1", + status: "healthy", + meta: "1.4k/min · P95 312ms · 99.98% uptime", + }, + { + name: "ap-southeast-1", + status: "degraded", + meta: "412/min · P95 521ms · 99.92% uptime · degraded", + }, +]; + +export interface OnboardingStep { + id: string; + title: string; + blurb: string; + done: boolean; + /** What to render in the per-step CTA slot. */ + cta?: { kind: "try-op" } | { kind: "navigate"; target: string }; +} + +export const FREE_ONBOARDING: OnboardingStep[] = [ + { + id: "first-op", + title: "Run your first operation", + blurb: "Try extract, redact, or OCR on a sample document.", + done: true, + cta: { kind: "try-op" }, + }, + { + id: "connect-source", + title: "Connect a source", + blurb: "Attach an S3 bucket, webhook, or email inbox.", + done: false, + cta: { kind: "navigate", target: "sources" }, + }, + { + id: "build-pipeline", + title: "Build a pipeline", + blurb: "Compose ops into a repeatable workflow.", + done: false, + cta: { kind: "navigate", target: "pipelines" }, + }, + { + id: "wire-agent", + title: "Wire an agent", + blurb: "Expose Stirling via MCP or REST tool definitions.", + done: false, + cta: { kind: "navigate", target: "sources" }, + }, +]; diff --git a/frontend/src/portal/mocks/notifications.ts b/frontend/src/portal/mocks/notifications.ts new file mode 100644 index 0000000000..b267bbe34c --- /dev/null +++ b/frontend/src/portal/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/src/portal/mocks/ops.ts b/frontend/src/portal/mocks/ops.ts new file mode 100644 index 0000000000..fc1bd203d1 --- /dev/null +++ b/frontend/src/portal/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/src/portal/mocks/preference.ts b/frontend/src/portal/mocks/preference.ts new file mode 100644 index 0000000000..466a62e659 --- /dev/null +++ b/frontend/src/portal/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/src/portal/mocks/search.ts b/frontend/src/portal/mocks/search.ts new file mode 100644 index 0000000000..f8c5990c7f --- /dev/null +++ b/frontend/src/portal/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/src/portal/tsconfig.json b/frontend/src/portal/tsconfig.json new file mode 100644 index 0000000000..87c593cb42 --- /dev/null +++ b/frontend/src/portal/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": "../../", + "paths": { + "@app/*": ["src/portal/*", "src/shared/*"], + "@shared/*": ["src/shared/*"] + } + }, + "include": [ + "../global.d.ts", + ".", + "../shared", + "../../portal" + ] +} diff --git a/frontend/src/portal/views/Home.css b/frontend/src/portal/views/Home.css new file mode 100644 index 0000000000..0f7b435aa3 --- /dev/null +++ b/frontend/src/portal/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/src/portal/views/Home.stories.tsx b/frontend/src/portal/views/Home.stories.tsx new file mode 100644 index 0000000000..896ddbc199 --- /dev/null +++ b/frontend/src/portal/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 "@app/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/src/portal/views/Home.tsx b/frontend/src/portal/views/Home.tsx new file mode 100644 index 0000000000..5ea8f3f9be --- /dev/null +++ b/frontend/src/portal/views/Home.tsx @@ -0,0 +1,534 @@ +import { useMemo, useState } from "react"; +import { + Button, + Card, + EmptyState, + MetricCard, + Skeleton, + StatusBadge, +} from "@shared/components"; +import { useTier, type Tier } from "@app/contexts/TierContext"; +import { useView, type ViewId } from "@app/contexts/ViewContext"; +import { useAsync, useSectionFlags } from "@app/hooks/useAsync"; +import { + fetchHomeKpis, + fetchOnboarding, + fetchRegionHealth, + fetchUsageSeries, + type KpiEntry, + type OnboardingStep, + type RegionHealth, + type UsageSeriesResponse, +} from "@app/api/home"; +import { WelcomeCarousel } from "@app/components/WelcomeCarousel"; +import { DocumentTypeGrid } from "@app/components/DocumentTypeGrid"; +import { UsageAreaChart } from "@app/components/UsageAreaChart"; +import { RecentActivity } from "@app/components/RecentActivity"; +import { SingleOpRunner } from "@app/components/SingleOpRunner"; +import "@app/views/Home.css"; + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Product cards (Sources / Pipelines / Agents) */ +/* ──────────────────────────────────────────────────────────────────────── */ + +interface ProductCardProps { + accent: "blue" | "purple"; + badge?: string; + title: string; + blurb: string; + cta: string; + target: ViewId; +} + +function ProductCard({ + accent, + badge, + title, + blurb, + cta, + target, +}: ProductCardProps) { + const { setActiveView } = useView(); + return ( + +
+

{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 } = useAsync( + () => fetchHomeKpis(tier), + [tier], + ); + + return ( +
+ {labels.map((spec, i) => { + const fetched = 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 { data: usage } = useAsync( + () => fetchUsageSeries(), + [], + ); + const docs30d = useMemo( + () => usage?.points.reduce((sum, p) => sum + p.value, 0) ?? 0, + [usage], + ); + const deltaPct = useMemo(() => { + if (!usage || usage.priorTotal <= 0) return undefined; + return (docs30d - usage.priorTotal) / usage.priorTotal; + }, [usage, docs30d]); + return ( + + ); +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* 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/src/portal/views/Placeholder.css b/frontend/src/portal/views/Placeholder.css new file mode 100644 index 0000000000..0bc1f0c8ec --- /dev/null +++ b/frontend/src/portal/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/src/portal/views/Placeholder.stories.tsx b/frontend/src/portal/views/Placeholder.stories.tsx new file mode 100644 index 0000000000..81563ebe2a --- /dev/null +++ b/frontend/src/portal/views/Placeholder.stories.tsx @@ -0,0 +1,16 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Placeholder } from "@app/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/src/portal/views/Placeholder.tsx b/frontend/src/portal/views/Placeholder.tsx new file mode 100644 index 0000000000..4495818297 --- /dev/null +++ b/frontend/src/portal/views/Placeholder.tsx @@ -0,0 +1,27 @@ +import { Card } from "@shared/components"; +import { VIEW_LABELS, type ViewId } from "@app/contexts/ViewContext"; +import "@app/views/Placeholder.css"; + +interface PlaceholderProps { + view: ViewId; + phase?: string; +} + +export function Placeholder({ view, phase }: PlaceholderProps) { + return ( +
+ +
+ {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/src/shared/components/Avatar.css b/frontend/src/shared/components/Avatar.css new file mode 100644 index 0000000000..2f35904392 --- /dev/null +++ b/frontend/src/shared/components/Avatar.css @@ -0,0 +1,29 @@ +.sui-avatar { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 50%; + font-weight: 600; + color: #fff; + font-family: var(--font-sans); + letter-spacing: 0.01em; + overflow: hidden; + flex-shrink: 0; +} + +.sui-avatar--interactive { cursor: pointer; transition: transform var(--motion-fast); } +.sui-avatar--interactive:hover { transform: scale(1.05); } + +.sui-avatar--xs { width: 1.25rem; height: 1.25rem; font-size: 0.625rem; } +.sui-avatar--sm { width: 1.625rem; height: 1.625rem; font-size: 0.6875rem; } +.sui-avatar--md { width: 2rem; height: 2rem; font-size: 0.8125rem; } +.sui-avatar--lg { width: 2.5rem; height: 2.5rem; font-size: 1rem; } + +.sui-avatar__img { width: 100%; height: 100%; object-fit: cover; display: block; } + +.sui-avatar--blue { background: var(--grad-blue-btn); } +.sui-avatar--purple { background: var(--grad-purple-btn); } +.sui-avatar--green { background: var(--grad-green-btn); } +.sui-avatar--amber { background: linear-gradient(180deg, #fbbf24, #f59e0b); } +.sui-avatar--red { background: linear-gradient(180deg, #f87171, #ef4444); } +.sui-avatar--neutral { background: var(--color-bg-muted); color: var(--color-text-2); } diff --git a/frontend/src/shared/components/Avatar.stories.tsx b/frontend/src/shared/components/Avatar.stories.tsx new file mode 100644 index 0000000000..ac959f47af --- /dev/null +++ b/frontend/src/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/src/shared/components/Avatar.tsx b/frontend/src/shared/components/Avatar.tsx new file mode 100644 index 0000000000..5246e24006 --- /dev/null +++ b/frontend/src/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/src/shared/components/Banner.css b/frontend/src/shared/components/Banner.css new file mode 100644 index 0000000000..2617f39397 --- /dev/null +++ b/frontend/src/shared/components/Banner.css @@ -0,0 +1,49 @@ +.sui-banner { + display: flex; + align-items: flex-start; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-md); + border: 1px solid transparent; + font-size: 0.8125rem; + line-height: 1.5; +} + +.sui-banner--info { background: var(--color-blue-light); color: var(--color-text-2); border-color: var(--color-blue-border); } +.sui-banner--success { background: var(--color-green-light); color: var(--color-text-2); border-color: var(--color-green-border); } +.sui-banner--warning { background: var(--color-amber-light); color: var(--color-text-2); border-color: var(--color-amber-border); } +.sui-banner--danger { background: var(--color-red-light); color: var(--color-text-2); border-color: var(--color-red-border); } +.sui-banner--neutral { background: var(--color-bg-subtle); color: var(--color-text-2); border-color: var(--color-border); } + +.sui-banner__icon { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; +} + +.sui-banner--info .sui-banner__icon { color: var(--color-blue); } +.sui-banner--success .sui-banner__icon { color: var(--color-green); } +.sui-banner--warning .sui-banner__icon { color: var(--color-amber-dark); } +.sui-banner--danger .sui-banner__icon { color: var(--color-red); } + +.sui-banner__body { flex: 1 1 auto; min-width: 0; } +.sui-banner__title { font-weight: 600; color: var(--color-text-1); } +.sui-banner__desc { color: var(--color-text-3); margin-top: 0.125rem; } + +.sui-banner__action { flex: 0 0 auto; display: flex; align-items: center; } + +.sui-banner__close { + flex: 0 0 auto; + width: 1.5rem; height: 1.5rem; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); + color: var(--color-text-4); + font-size: 1rem; + line-height: 1; +} +.sui-banner__close:hover { background: rgba(0, 0, 0, 0.06); color: var(--color-text-1); } diff --git a/frontend/src/shared/components/Banner.stories.tsx b/frontend/src/shared/components/Banner.stories.tsx new file mode 100644 index 0000000000..55ea4ac16f --- /dev/null +++ b/frontend/src/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/src/shared/components/Banner.tsx b/frontend/src/shared/components/Banner.tsx new file mode 100644 index 0000000000..d017eaf33a --- /dev/null +++ b/frontend/src/shared/components/Banner.tsx @@ -0,0 +1,61 @@ +import type { ReactNode } from "react"; +import "@shared/components/Banner.css"; + +export type BannerTone = "info" | "success" | "warning" | "danger" | "neutral"; + +export interface BannerProps { + tone?: BannerTone; + title?: ReactNode; + description?: ReactNode; + /** Right-aligned action — typically a button. */ + action?: ReactNode; + /** When set, shows an × button that calls this handler. */ + onDismiss?: () => void; + /** Optional leading icon (caller supplies — keeps the primitive icon-set-agnostic). */ + icon?: ReactNode; + className?: string; + children?: ReactNode; +} + +/** + * Inline alert. Use `tone` to convey severity; pair with `action` for + * "Approaching cap → Upgrade" style flows. Use `Toast` (separate primitive) + * for transient/dismissible notifications layered over the UI. + */ +export function Banner({ + tone = "info", + title, + description, + action, + onDismiss, + icon, + className, + children, +}: BannerProps) { + return ( +
+ {icon &&
{icon}
} +
+ {title &&
{title}
} + {description &&
{description}
} + {children} +
+ {action &&
{action}
} + {onDismiss && ( + + )} +
+ ); +} diff --git a/frontend/src/shared/components/Button.css b/frontend/src/shared/components/Button.css new file mode 100644 index 0000000000..696422482e --- /dev/null +++ b/frontend/src/shared/components/Button.css @@ -0,0 +1,72 @@ +.sui-btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + border: 1px solid transparent; + border-radius: var(--radius-md); + font-family: var(--font-sans); + font-weight: 500; + cursor: pointer; + transition: transform var(--motion-fast), box-shadow var(--motion-fast), + background var(--motion-fast), color var(--motion-fast), border-color var(--motion-fast); + white-space: nowrap; + user-select: none; +} +.sui-btn:disabled { cursor: not-allowed; opacity: 0.55; } +.sui-btn:focus-visible { + outline: 2px solid var(--color-blue); + outline-offset: 2px; +} + +/* sizes */ +.sui-btn--md { font-size: 0.8125rem; padding: 0.4375rem 0.875rem; min-height: 2rem; } +.sui-btn--sm { font-size: 0.75rem; padding: 0.3125rem 0.625rem; min-height: 1.625rem; border-radius: var(--radius-sm); } + +.sui-btn--full { width: 100%; justify-content: center; } + +/* gradient (primary) */ +.sui-btn--gradient { + color: #fff; + background: var(--grad-blue-btn); + box-shadow: var(--shadow-blue); +} +.sui-btn--gradient.sui-btn--purple { background: var(--grad-purple-btn); } +.sui-btn--gradient.sui-btn--green { background: var(--grad-green-btn); } +.sui-btn--gradient:hover:not(:disabled) { + box-shadow: var(--shadow-blue-hover); + transform: translateY(-0.0625rem); +} +.sui-btn--gradient:active:not(:disabled) { transform: translateY(0); } + +/* outline (secondary) */ +.sui-btn--outline { + color: var(--color-text-2); + background: var(--color-surface); + border-color: var(--color-border); +} +.sui-btn--outline.sui-btn--blue { color: var(--color-blue); border-color: var(--color-blue-border); } +.sui-btn--outline.sui-btn--purple { color: var(--color-purple); border-color: var(--color-purple-border); } +.sui-btn--outline.sui-btn--green { color: var(--color-green); border-color: var(--color-green-border); } +.sui-btn--outline.sui-btn--amber { color: var(--color-amber); border-color: var(--color-amber-border); } +.sui-btn--outline.sui-btn--red { color: var(--color-red); border-color: var(--color-red-border); } +.sui-btn--outline:hover:not(:disabled) { + background: var(--color-bg-hover); + border-color: var(--color-border-hover); +} + +/* ghost (tertiary) */ +.sui-btn--ghost { + color: var(--color-text-3); + background: transparent; +} +.sui-btn--ghost:hover:not(:disabled) { color: var(--color-text-1); background: var(--color-bg-hover); } + +/* spinner */ +.sui-btn__spinner { + width: 0.875rem; height: 0.875rem; + border-radius: 50%; + border: 2px solid currentColor; + border-right-color: transparent; + animation: spin 0.7s linear infinite; +} +.sui-btn__label { display: inline-block; } diff --git a/frontend/src/shared/components/Button.stories.tsx b/frontend/src/shared/components/Button.stories.tsx new file mode 100644 index 0000000000..be23317de9 --- /dev/null +++ b/frontend/src/shared/components/Button.stories.tsx @@ -0,0 +1,45 @@ +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/src/shared/components/Button.tsx b/frontend/src/shared/components/Button.tsx new file mode 100644 index 0000000000..0c50addeec --- /dev/null +++ b/frontend/src/shared/components/Button.tsx @@ -0,0 +1,64 @@ +import type { ButtonHTMLAttributes, ReactNode } from 'react'; +import '@shared/components/Button.css'; + +export type ButtonVariant = 'gradient' | 'outline' | 'ghost'; +export type ButtonSize = 'sm' | 'md'; +export type ButtonAccent = 'blue' | 'purple' | 'green' | 'amber' | 'red'; + +export interface ButtonProps extends ButtonHTMLAttributes { + /** 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/src/shared/components/Card.css b/frontend/src/shared/components/Card.css new file mode 100644 index 0000000000..3fd63730d4 --- /dev/null +++ b/frontend/src/shared/components/Card.css @@ -0,0 +1,36 @@ +.sui-card { + position: relative; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); + transition: box-shadow var(--motion-fast), border-color var(--motion-fast), transform var(--motion-fast); +} +.sui-card--pad-none { padding: 0; overflow: hidden; } +.sui-card--pad-tight { padding: 0.75rem; } +.sui-card--pad-default { padding: 1.125rem 1.25rem; } +.sui-card--pad-loose { padding: 1.5rem 1.75rem; } + +.sui-card--interactive { cursor: pointer; } +.sui-card--interactive:hover { + border-color: var(--color-border-hover); + box-shadow: var(--shadow-lg); + transform: translateY(-0.0625rem); +} + +.sui-card--accent-blue::before, +.sui-card--accent-purple::before, +.sui-card--accent-green::before, +.sui-card--accent-amber::before, +.sui-card--accent-red::before { + content: ''; + position: absolute; + top: 0; left: 0; bottom: 0; + width: 0.25rem; + border-radius: var(--radius-lg) 0 0 var(--radius-lg); +} +.sui-card--accent-blue::before { background: var(--color-blue); } +.sui-card--accent-purple::before { background: var(--color-purple); } +.sui-card--accent-green::before { background: var(--color-green); } +.sui-card--accent-amber::before { background: var(--color-amber); } +.sui-card--accent-red::before { background: var(--color-red); } diff --git a/frontend/src/shared/components/Card.stories.tsx b/frontend/src/shared/components/Card.stories.tsx new file mode 100644 index 0000000000..10b31b9784 --- /dev/null +++ b/frontend/src/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/src/shared/components/Card.tsx b/frontend/src/shared/components/Card.tsx new file mode 100644 index 0000000000..13f1503ae1 --- /dev/null +++ b/frontend/src/shared/components/Card.tsx @@ -0,0 +1,45 @@ +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/src/shared/components/Checkbox.css b/frontend/src/shared/components/Checkbox.css new file mode 100644 index 0000000000..7ba307717f --- /dev/null +++ b/frontend/src/shared/components/Checkbox.css @@ -0,0 +1,69 @@ +.sui-check { + display: inline-flex; + align-items: flex-start; + gap: var(--space-2); + cursor: pointer; + font-size: 0.8125rem; + color: var(--color-text-2); +} + +.sui-check--disabled { opacity: 0.5; cursor: not-allowed; } + +.sui-check__input { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} + +.sui-check__box { + flex: 0 0 auto; + width: 1rem; + height: 1rem; + margin-top: 0.0625rem; + border-radius: var(--radius-xs); + border: 1.5px solid var(--color-border-hover); + background: var(--color-surface); + display: inline-flex; + align-items: center; + justify-content: center; + transition: background var(--motion-fast), border-color var(--motion-fast); + position: relative; +} + +.sui-check__input:focus-visible + .sui-check__box { + outline: 2px solid var(--color-blue); + outline-offset: 2px; +} + +.sui-check__input:checked + .sui-check__box { + background: var(--color-blue); + border-color: var(--color-blue); + color: #fff; +} + +.sui-check__tick { + opacity: 0; +} +.sui-check__input:checked + .sui-check__box .sui-check__tick { + opacity: 1; +} + +.sui-check--mixed .sui-check__box { + background: var(--color-blue); + border-color: var(--color-blue); +} +.sui-check--mixed .sui-check__tick { opacity: 0; } +.sui-check--mixed .sui-check__dash { + position: absolute; + width: 0.5rem; + height: 2px; + background: #fff; + border-radius: 1px; +} +.sui-check__dash { display: none; } +.sui-check--mixed .sui-check__dash { display: block; } + +.sui-check__text { display: flex; flex-direction: column; } +.sui-check__label { color: var(--color-text-1); font-weight: 500; } +.sui-check__desc { font-size: 0.75rem; color: var(--color-text-4); } diff --git a/frontend/src/shared/components/Checkbox.tsx b/frontend/src/shared/components/Checkbox.tsx new file mode 100644 index 0000000000..61aab0646f --- /dev/null +++ b/frontend/src/shared/components/Checkbox.tsx @@ -0,0 +1,67 @@ +import { forwardRef, type InputHTMLAttributes, type ReactNode } from "react"; +import "@shared/components/Checkbox.css"; + +export interface CheckboxProps + extends Omit, "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/src/shared/components/Chip.css b/frontend/src/shared/components/Chip.css new file mode 100644 index 0000000000..5191edb2fd --- /dev/null +++ b/frontend/src/shared/components/Chip.css @@ -0,0 +1,47 @@ +.sui-chip { + display: inline-flex; + align-items: center; + gap: var(--space-1_5); + border-radius: var(--radius-pill); + font-family: var(--font-sans); + font-weight: 500; + white-space: nowrap; + border: 1px solid transparent; + transition: background var(--motion-fast), border-color var(--motion-fast), transform var(--motion-fast); +} + +.sui-chip--sm { padding: var(--space-0_5) var(--space-2); font-size: 0.6875rem; } +.sui-chip--md { padding: var(--space-1) var(--space-2_5, 0.5rem); font-size: 0.75rem; } +.sui-chip--md { padding: var(--space-1) 0.625rem; font-size: 0.75rem; } + +.sui-chip--neutral { background: var(--color-bg-muted); color: var(--color-text-2); border-color: var(--color-border); } +.sui-chip--blue { background: var(--color-blue-light); color: var(--color-blue); border-color: var(--color-blue-border); } +.sui-chip--purple { background: var(--color-purple-light); color: var(--color-purple); border-color: var(--color-purple-border); } +.sui-chip--green { background: var(--color-green-light); color: var(--color-green); border-color: var(--color-green-border); } +.sui-chip--amber { background: var(--color-amber-light); color: var(--color-amber-dark); border-color: var(--color-amber-border); } +.sui-chip--red { background: var(--color-red-light); color: var(--color-red); border-color: var(--color-red-border); } + +.sui-chip--interactive { cursor: pointer; } +.sui-chip--interactive:hover { transform: translateY(-0.0625rem); filter: brightness(1.05); } + +.sui-chip__dot { + width: 0.4375rem; height: 0.4375rem; + border-radius: 50%; + background: currentColor; + opacity: 0.85; +} + +.sui-chip__icon { display: inline-flex; line-height: 1; } + +.sui-chip__remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1rem; height: 1rem; + border-radius: 50%; + color: inherit; + opacity: 0.6; + font-size: 0.875rem; + line-height: 1; +} +.sui-chip__remove:hover { opacity: 1; background: rgba(0, 0, 0, 0.08); } diff --git a/frontend/src/shared/components/Chip.stories.tsx b/frontend/src/shared/components/Chip.stories.tsx new file mode 100644 index 0000000000..c92b21344d --- /dev/null +++ b/frontend/src/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/src/shared/components/Chip.tsx b/frontend/src/shared/components/Chip.tsx new file mode 100644 index 0000000000..3cc670a2ab --- /dev/null +++ b/frontend/src/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/src/shared/components/CodeBlock.css b/frontend/src/shared/components/CodeBlock.css new file mode 100644 index 0000000000..4ad0f0675a --- /dev/null +++ b/frontend/src/shared/components/CodeBlock.css @@ -0,0 +1,51 @@ +.sui-code { + background: var(--code-bg); + color: var(--code-text); + border-radius: var(--radius-lg); + overflow: hidden; + border: 1px solid var(--code-border); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); +} +.sui-code__chrome { + display: flex; align-items: center; gap: 0.625rem; + background: var(--code-bg-header); + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--code-border); + font-size: 0.6875rem; + color: var(--code-muted); +} +.sui-code__dots { display: inline-flex; gap: 0.375rem; align-items: center; } +.sui-code__dots > span { + width: 0.625rem; height: 0.625rem; border-radius: 50%; + background: var(--code-dot); +} +.sui-code__caption { color: var(--code-muted); margin-left: 0.25rem; font-family: var(--font-mono); } +.sui-code__lang { + margin-left: auto; + font-family: var(--font-mono); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--code-dim); +} +.sui-code__copy { + background: transparent; + color: var(--code-text); + border: 1px solid var(--code-border); + border-radius: var(--radius-sm); + padding: 0.125rem 0.5rem; + font-size: 0.6875rem; + cursor: pointer; + font-family: var(--font-sans); + transition: background var(--motion-fast); +} +.sui-code__copy:hover { background: rgba(255, 255, 255, 0.05); } +.sui-code__pre { + margin: 0; + padding: 0.875rem 1rem; + font-family: var(--font-mono); + font-size: 0.78125rem; + line-height: 1.55; + overflow: auto; + white-space: pre; +} +.sui-code__pre code { white-space: inherit; color: inherit; font-family: inherit; } diff --git a/frontend/src/shared/components/CodeBlock.stories.tsx b/frontend/src/shared/components/CodeBlock.stories.tsx new file mode 100644 index 0000000000..cad00e08bb --- /dev/null +++ b/frontend/src/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/src/shared/components/CodeBlock.tsx b/frontend/src/shared/components/CodeBlock.tsx new file mode 100644 index 0000000000..f0e929d0cc --- /dev/null +++ b/frontend/src/shared/components/CodeBlock.tsx @@ -0,0 +1,75 @@ +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/src/shared/components/Drawer.css b/frontend/src/shared/components/Drawer.css new file mode 100644 index 0000000000..7f432cf281 --- /dev/null +++ b/frontend/src/shared/components/Drawer.css @@ -0,0 +1,94 @@ +.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/src/shared/components/Drawer.stories.tsx b/frontend/src/shared/components/Drawer.stories.tsx new file mode 100644 index 0000000000..2ddc046122 --- /dev/null +++ b/frontend/src/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/src/shared/components/Drawer.tsx b/frontend/src/shared/components/Drawer.tsx new file mode 100644 index 0000000000..add63fc20d --- /dev/null +++ b/frontend/src/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/src/shared/components/Dropdown.css b/frontend/src/shared/components/Dropdown.css new file mode 100644 index 0000000000..476307aaf3 --- /dev/null +++ b/frontend/src/shared/components/Dropdown.css @@ -0,0 +1,71 @@ +.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/src/shared/components/Dropdown.stories.tsx b/frontend/src/shared/components/Dropdown.stories.tsx new file mode 100644 index 0000000000..f86aeb04c7 --- /dev/null +++ b/frontend/src/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/src/shared/components/Dropdown.tsx b/frontend/src/shared/components/Dropdown.tsx new file mode 100644 index 0000000000..db624dc5fc --- /dev/null +++ b/frontend/src/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/src/shared/components/EmptyState.css b/frontend/src/shared/components/EmptyState.css new file mode 100644 index 0000000000..0cbc9c7716 --- /dev/null +++ b/frontend/src/shared/components/EmptyState.css @@ -0,0 +1,53 @@ +.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/src/shared/components/EmptyState.stories.tsx b/frontend/src/shared/components/EmptyState.stories.tsx new file mode 100644 index 0000000000..427ef829ea --- /dev/null +++ b/frontend/src/shared/components/EmptyState.stories.tsx @@ -0,0 +1,53 @@ +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/src/shared/components/EmptyState.tsx b/frontend/src/shared/components/EmptyState.tsx new file mode 100644 index 0000000000..f9c1dfbcb2 --- /dev/null +++ b/frontend/src/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/src/shared/components/FormField.css b/frontend/src/shared/components/FormField.css new file mode 100644 index 0000000000..0ae531bc65 --- /dev/null +++ b/frontend/src/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/src/shared/components/FormField.tsx b/frontend/src/shared/components/FormField.tsx new file mode 100644 index 0000000000..fa32201323 --- /dev/null +++ b/frontend/src/shared/components/FormField.tsx @@ -0,0 +1,78 @@ +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/src/shared/components/Forms.stories.tsx b/frontend/src/shared/components/Forms.stories.tsx new file mode 100644 index 0000000000..d26f838e53 --- /dev/null +++ b/frontend/src/shared/components/Forms.stories.tsx @@ -0,0 +1,228 @@ +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/src/shared/components/MethodBadge.css b/frontend/src/shared/components/MethodBadge.css new file mode 100644 index 0000000000..870bcc3b3d --- /dev/null +++ b/frontend/src/shared/components/MethodBadge.css @@ -0,0 +1,16 @@ +.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/src/shared/components/MethodBadge.stories.tsx b/frontend/src/shared/components/MethodBadge.stories.tsx new file mode 100644 index 0000000000..97c33145dd --- /dev/null +++ b/frontend/src/shared/components/MethodBadge.stories.tsx @@ -0,0 +1,33 @@ +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/src/shared/components/MethodBadge.tsx b/frontend/src/shared/components/MethodBadge.tsx new file mode 100644 index 0000000000..4d3978815d --- /dev/null +++ b/frontend/src/shared/components/MethodBadge.tsx @@ -0,0 +1,20 @@ +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/src/shared/components/MetricCard.css b/frontend/src/shared/components/MetricCard.css new file mode 100644 index 0000000000..04ba809b3c --- /dev/null +++ b/frontend/src/shared/components/MetricCard.css @@ -0,0 +1,45 @@ +.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/src/shared/components/Modal.css b/frontend/src/shared/components/Modal.css new file mode 100644 index 0000000000..b4adff82c5 --- /dev/null +++ b/frontend/src/shared/components/Modal.css @@ -0,0 +1,89 @@ +.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/src/shared/components/Modal.tsx b/frontend/src/shared/components/Modal.tsx new file mode 100644 index 0000000000..87fcdba6a6 --- /dev/null +++ b/frontend/src/shared/components/Modal.tsx @@ -0,0 +1,175 @@ +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/src/shared/components/NavItem.css b/frontend/src/shared/components/NavItem.css new file mode 100644 index 0000000000..e3c6c24a37 --- /dev/null +++ b/frontend/src/shared/components/NavItem.css @@ -0,0 +1,28 @@ +.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/src/shared/components/NavItem.stories.tsx b/frontend/src/shared/components/NavItem.stories.tsx new file mode 100644 index 0000000000..2cf5794a75 --- /dev/null +++ b/frontend/src/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/src/shared/components/NavItem.tsx b/frontend/src/shared/components/NavItem.tsx new file mode 100644 index 0000000000..33aaad4ef0 --- /dev/null +++ b/frontend/src/shared/components/NavItem.tsx @@ -0,0 +1,37 @@ +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/src/shared/components/PanelHeader.css b/frontend/src/shared/components/PanelHeader.css new file mode 100644 index 0000000000..faa692bcbf --- /dev/null +++ b/frontend/src/shared/components/PanelHeader.css @@ -0,0 +1,19 @@ +.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/src/shared/components/PanelHeader.stories.tsx b/frontend/src/shared/components/PanelHeader.stories.tsx new file mode 100644 index 0000000000..1aa37b9556 --- /dev/null +++ b/frontend/src/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/src/shared/components/PanelHeader.tsx b/frontend/src/shared/components/PanelHeader.tsx new file mode 100644 index 0000000000..92ccfd8c86 --- /dev/null +++ b/frontend/src/shared/components/PanelHeader.tsx @@ -0,0 +1,38 @@ +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/src/shared/components/ProgressBar.css b/frontend/src/shared/components/ProgressBar.css new file mode 100644 index 0000000000..aa9f34685a --- /dev/null +++ b/frontend/src/shared/components/ProgressBar.css @@ -0,0 +1,11 @@ +.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/src/shared/components/ProgressBar.stories.tsx b/frontend/src/shared/components/ProgressBar.stories.tsx new file mode 100644 index 0000000000..bc7db818e7 --- /dev/null +++ b/frontend/src/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/src/shared/components/ProgressBar.tsx b/frontend/src/shared/components/ProgressBar.tsx new file mode 100644 index 0000000000..e1c9f25b6e --- /dev/null +++ b/frontend/src/shared/components/ProgressBar.tsx @@ -0,0 +1,62 @@ +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) { + if (thresholded) { + fill = v >= 0.96 + ? 'linear-gradient(90deg, var(--color-red), #F87171)' + : v >= 0.80 + ? 'linear-gradient(90deg, var(--color-amber), #FBBF24)' + : 'linear-gradient(90deg, var(--color-blue), #60A5FA)'; + } else { + fill = 'linear-gradient(90deg, var(--color-blue), #60A5FA)'; + } + } + return ( +
+
+
+ ); +} diff --git a/frontend/src/shared/components/Radio.css b/frontend/src/shared/components/Radio.css new file mode 100644 index 0000000000..16b77b33b3 --- /dev/null +++ b/frontend/src/shared/components/Radio.css @@ -0,0 +1,56 @@ +.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/src/shared/components/Radio.tsx b/frontend/src/shared/components/Radio.tsx new file mode 100644 index 0000000000..64f2dfb3e2 --- /dev/null +++ b/frontend/src/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/src/shared/components/SectionDivider.css b/frontend/src/shared/components/SectionDivider.css new file mode 100644 index 0000000000..8999f6f0a3 --- /dev/null +++ b/frontend/src/shared/components/SectionDivider.css @@ -0,0 +1,5 @@ +.sui-divider { + height: 0.0625rem; + background: var(--color-sidebar-divider); + width: 100%; +} diff --git a/frontend/src/shared/components/SectionDivider.stories.tsx b/frontend/src/shared/components/SectionDivider.stories.tsx new file mode 100644 index 0000000000..b2b3f17ba5 --- /dev/null +++ b/frontend/src/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/src/shared/components/SectionDivider.tsx b/frontend/src/shared/components/SectionDivider.tsx new file mode 100644 index 0000000000..617b2a7ee0 --- /dev/null +++ b/frontend/src/shared/components/SectionDivider.tsx @@ -0,0 +1,22 @@ +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/src/shared/components/Select.css b/frontend/src/shared/components/Select.css new file mode 100644 index 0000000000..a8056dd3e7 --- /dev/null +++ b/frontend/src/shared/components/Select.css @@ -0,0 +1,45 @@ +.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/src/shared/components/Select.tsx b/frontend/src/shared/components/Select.tsx new file mode 100644 index 0000000000..c24dfc8976 --- /dev/null +++ b/frontend/src/shared/components/Select.tsx @@ -0,0 +1,64 @@ +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, "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/src/shared/components/Skeleton.css b/frontend/src/shared/components/Skeleton.css new file mode 100644 index 0000000000..abdd185acf --- /dev/null +++ b/frontend/src/shared/components/Skeleton.css @@ -0,0 +1,23 @@ +.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/src/shared/components/Skeleton.stories.tsx b/frontend/src/shared/components/Skeleton.stories.tsx new file mode 100644 index 0000000000..ed926004d6 --- /dev/null +++ b/frontend/src/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/src/shared/components/Skeleton.tsx b/frontend/src/shared/components/Skeleton.tsx new file mode 100644 index 0000000000..2ca009c225 --- /dev/null +++ b/frontend/src/shared/components/Skeleton.tsx @@ -0,0 +1,69 @@ +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/src/shared/components/Slider.css b/frontend/src/shared/components/Slider.css new file mode 100644 index 0000000000..7e5ef0a867 --- /dev/null +++ b/frontend/src/shared/components/Slider.css @@ -0,0 +1,65 @@ +.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/src/shared/components/Slider.tsx b/frontend/src/shared/components/Slider.tsx new file mode 100644 index 0000000000..6bf57d45d9 --- /dev/null +++ b/frontend/src/shared/components/Slider.tsx @@ -0,0 +1,61 @@ +import { forwardRef, type InputHTMLAttributes } from "react"; +import "@shared/components/Slider.css"; + +export interface SliderProps + extends Omit, "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/src/shared/components/Spinner.css b/frontend/src/shared/components/Spinner.css new file mode 100644 index 0000000000..4ef0ba6586 --- /dev/null +++ b/frontend/src/shared/components/Spinner.css @@ -0,0 +1,13 @@ +.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/src/shared/components/Spinner.stories.tsx b/frontend/src/shared/components/Spinner.stories.tsx new file mode 100644 index 0000000000..68ca648f45 --- /dev/null +++ b/frontend/src/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/src/shared/components/Spinner.tsx b/frontend/src/shared/components/Spinner.tsx new file mode 100644 index 0000000000..d383e2cd07 --- /dev/null +++ b/frontend/src/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/src/shared/components/Stack.css b/frontend/src/shared/components/Stack.css new file mode 100644 index 0000000000..39e6cf1c0a --- /dev/null +++ b/frontend/src/shared/components/Stack.css @@ -0,0 +1,31 @@ +.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/src/shared/components/Stack.stories.tsx b/frontend/src/shared/components/Stack.stories.tsx new file mode 100644 index 0000000000..e3a0c8a075 --- /dev/null +++ b/frontend/src/shared/components/Stack.stories.tsx @@ -0,0 +1,61 @@ +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/src/shared/components/Stack.tsx b/frontend/src/shared/components/Stack.tsx new file mode 100644 index 0000000000..be02f7f23f --- /dev/null +++ b/frontend/src/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/src/shared/components/StatusBadge.css b/frontend/src/shared/components/StatusBadge.css new file mode 100644 index 0000000000..40ebba9141 --- /dev/null +++ b/frontend/src/shared/components/StatusBadge.css @@ -0,0 +1,35 @@ +.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/src/shared/components/StatusBadge.stories.tsx b/frontend/src/shared/components/StatusBadge.stories.tsx new file mode 100644 index 0000000000..589aeace8a --- /dev/null +++ b/frontend/src/shared/components/StatusBadge.stories.tsx @@ -0,0 +1,44 @@ +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/src/shared/components/StatusBadge.tsx b/frontend/src/shared/components/StatusBadge.tsx new file mode 100644 index 0000000000..bb786e9e5c --- /dev/null +++ b/frontend/src/shared/components/StatusBadge.tsx @@ -0,0 +1,54 @@ +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/src/shared/components/Tabs.css b/frontend/src/shared/components/Tabs.css new file mode 100644 index 0000000000..928b54066c --- /dev/null +++ b/frontend/src/shared/components/Tabs.css @@ -0,0 +1,71 @@ +.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/src/shared/components/Tabs.stories.tsx b/frontend/src/shared/components/Tabs.stories.tsx new file mode 100644 index 0000000000..ef0c71acac --- /dev/null +++ b/frontend/src/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/src/shared/components/Tabs.tsx b/frontend/src/shared/components/Tabs.tsx new file mode 100644 index 0000000000..11bc162381 --- /dev/null +++ b/frontend/src/shared/components/Tabs.tsx @@ -0,0 +1,92 @@ +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/src/shared/components/Toast.css b/frontend/src/shared/components/Toast.css new file mode 100644 index 0000000000..0f150f652d --- /dev/null +++ b/frontend/src/shared/components/Toast.css @@ -0,0 +1,47 @@ +.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/src/shared/components/Toast.stories.tsx b/frontend/src/shared/components/Toast.stories.tsx new file mode 100644 index 0000000000..78da9dbcc9 --- /dev/null +++ b/frontend/src/shared/components/Toast.stories.tsx @@ -0,0 +1,31 @@ +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/src/shared/components/Toast.tsx b/frontend/src/shared/components/Toast.tsx new file mode 100644 index 0000000000..7bad1b97ea --- /dev/null +++ b/frontend/src/shared/components/Toast.tsx @@ -0,0 +1,132 @@ +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/src/shared/components/ToggleSwitch.css b/frontend/src/shared/components/ToggleSwitch.css new file mode 100644 index 0000000000..c58328e79c --- /dev/null +++ b/frontend/src/shared/components/ToggleSwitch.css @@ -0,0 +1,44 @@ +.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/src/shared/components/ToggleSwitch.stories.tsx b/frontend/src/shared/components/ToggleSwitch.stories.tsx new file mode 100644 index 0000000000..9dc2f0abd8 --- /dev/null +++ b/frontend/src/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/src/shared/components/ToggleSwitch.tsx b/frontend/src/shared/components/ToggleSwitch.tsx new file mode 100644 index 0000000000..099831c922 --- /dev/null +++ b/frontend/src/shared/components/ToggleSwitch.tsx @@ -0,0 +1,57 @@ +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/src/shared/components/index.ts b/frontend/src/shared/components/index.ts new file mode 100644 index 0000000000..7bb17bfc79 --- /dev/null +++ b/frontend/src/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/src/shared/data/Endpoints.stories.tsx b/frontend/src/shared/data/Endpoints.stories.tsx new file mode 100644 index 0000000000..96f6e60733 --- /dev/null +++ b/frontend/src/shared/data/Endpoints.stories.tsx @@ -0,0 +1,62 @@ +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/src/shared/data/Ops.stories.tsx b/frontend/src/shared/data/Ops.stories.tsx new file mode 100644 index 0000000000..96c813ca37 --- /dev/null +++ b/frontend/src/shared/data/Ops.stories.tsx @@ -0,0 +1,148 @@ +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/src/shared/data/endpoints.ts b/frontend/src/shared/data/endpoints.ts new file mode 100644 index 0000000000..6a44b3e8fd --- /dev/null +++ b/frontend/src/shared/data/endpoints.ts @@ -0,0 +1,395 @@ +/** + * 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/src/shared/data/ops.ts b/frontend/src/shared/data/ops.ts new file mode 100644 index 0000000000..6a7d77db1c --- /dev/null +++ b/frontend/src/shared/data/ops.ts @@ -0,0 +1,349 @@ +/** + * 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 { + 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: '#6366F1', blurb: 'Inspection, AI review, certification' }, + { name: 'Signing', color: 'var(--color-green)', blurb: 'Digital signatures, cert sign / remove' }, + { name: 'Page Formatting', color: '#F97316', blurb: 'Rotate, crop, layout, page-level edits' }, + { name: 'Extraction', color: '#14B8A6', blurb: 'OCR, format conversion, content pull' }, + { name: 'Removal', color: 'var(--color-amber)', blurb: 'Drop pages, blanks, images, partition splits' }, + { name: 'Automation', color: '#EC4899', blurb: 'AI-driven generation, translation, forms' }, + { name: 'Advanced Formatting', color: '#8B5CF6', 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/src/shared/tokens/Tokens.stories.tsx b/frontend/src/shared/tokens/Tokens.stories.tsx new file mode 100644 index 0000000000..282049b25b --- /dev/null +++ b/frontend/src/shared/tokens/Tokens.stories.tsx @@ -0,0 +1,115 @@ +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/src/shared/tokens/base.css b/frontend/src/shared/tokens/base.css new file mode 100644 index 0000000000..6df3c68147 --- /dev/null +++ b/frontend/src/shared/tokens/base.css @@ -0,0 +1,26 @@ +/* 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/src/shared/tokens/tokens.css b/frontend/src/shared/tokens/tokens.css new file mode 100644 index 0000000000..1c0e01b07c --- /dev/null +++ b/frontend/src/shared/tokens/tokens.css @@ -0,0 +1,329 @@ +/* + * Stirling design tokens — CSS custom properties. + * + * Mirrors src/tokens/tokens.ts so components can reference tokens via + * var(--color-...) without importing the JS module. Theme switching flips + * `data-theme="dark"` on and these variables cascade. + * + * 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-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-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; + + /* 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-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-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-amber: #fbbf24; + --color-amber-light: #2d2006; + --color-amber-border: #78350f; + --color-amber-dark: #fbbf24; + + --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; +} + +/* 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/src/shared/tokens/tokens.ts b/frontend/src/shared/tokens/tokens.ts new file mode 100644 index 0000000000..0e0fd71423 --- /dev/null +++ b/frontend/src/shared/tokens/tokens.ts @@ -0,0 +1,264 @@ +/** + * Stirling design tokens. + * + * Two parallel palettes (light + dark) plus a never-theme-switched CODE palette + * for syntax-highlighted blocks. Category accents are intentionally identical + * across both themes — the document types they represent are stable concepts, + * so their colour is part of their identity rather than a UI affordance. + * + * These TS constants are the source of truth; tokens.css mirrors them as CSS + * custom properties for runtime theme switching. Keep them in sync (run + * `pnpm gen:tokens` if/when a generator script lands). + */ + +export type ColorMode = 'light' | 'dark'; +export type Tier = 'free' | 'pro' | 'enterprise'; + +export interface ColorPalette { + // Text hierarchy + text1: string; + text2: string; + text3: string; + text4: string; + text5: string; + text6: string; + textMuted: string; + textPlaceholder: string; + + // Brand / status + blue: string; blueDark: string; blueLight: string; blueBorder: string; + purple: string; purpleLight: string; purpleBorder: string; + green: string; greenLight: string; greenBorder: string; greenDark: string; + red: string; redLight: string; redBorder: string; + amber: string; amberLight: string; amberBorder: string; amberDark: string; + + // Document category accents + insurance: string; + compliance: string; + finance: string; + legal: string; + healthcare: string; + government: string; + operations: string; + hr: string; + realestate: string; + energy: string; + + // Surface / border + bg: string; bgAlt: string; + surface: string; surfaceAlt: string; + border: string; borderLight: string; + borderInput: string; borderHover: string; + divider: string; + bgSubtle: string; bgHover: string; bgMuted: string; bgCode: string; + + // Code / tooltips / dropdowns + codeBg: string; codeText: string; + dropdownBg: string; dropdownBorder: string; dropdownHover: string; + tooltipBg: string; tooltipText: string; + + // Navigation + navActive: string; navActiveText: string; + navText: string; navHover: string; navHoverText: string; + sectionLabel: string; logoText: string; + + // Header + headerBg: string; headerBorder: string; headerText: string; + searchBg: string; searchBorder: string; searchBorderHover: string; searchText: string; + + // Badges / notifications / tier switcher / bell + badgeBg: string; badgeBorder: string; badgeRed: string; + notifBg: string; notifBorder: string; + notifItemBorder: string; notifItemHover: string; + notifTitle: string; notifDesc: string; notifTime: string; + tierBtnBg: string; tierBtnBorder: string; tierBtnText: string; tierBtnHover: string; + bellHover: string; bellStroke: string; + + // Sidebar + sidebarBg: string; sidebarBorder: string; sidebarDivider: string; + usageText: string; usageValue: string; usageTrack: string; + + toggleOff: string; +} + +export const COLORS_LIGHT: ColorPalette = { + text1: '#0f172a', text2: '#1a202c', text3: '#475569', text4: '#64748b', + text5: '#94a3b8', text6: '#6b7280', textMuted: '#8b92a1', textPlaceholder: '#9ca3af', + + blue: '#3B82F6', blueDark: '#2563eb', blueLight: '#eff6ff', blueBorder: '#bfdbfe', + purple: '#8B5CF6', purpleLight: '#f5f3ff', purpleBorder: '#ddd6fe', + green: '#10b981', greenLight: '#ecfdf5', greenBorder: '#a7f3d0', greenDark: '#16a34a', + red: '#ef4444', redLight: '#fee2e2', redBorder: '#fecaca', + amber: '#f59e0b', amberLight: '#fef3c7', amberBorder: '#fde68a', amberDark: '#92400e', + + insurance: '#0ea5e9', compliance: '#6366f1', finance: '#10b981', legal: '#3B82F6', + healthcare: '#8B5CF6', government: '#dc2626', operations: '#ec4899', hr: '#f59e0b', + realestate: '#84cc16', energy: '#f97316', + + bg: '#f8f9fb', bgAlt: '#f6f8fa', + surface: '#ffffff', surfaceAlt: '#ffffff', + border: '#e3e8ee', borderLight: '#eef0f2', + borderInput: '#e2e8f0', borderHover: '#cbd5e1', + divider: '#f0f0f0', + bgSubtle: '#f9fafb', bgHover: '#f8fafc', bgMuted: '#f3f4f6', bgCode: '#f1f5f9', + + codeBg: '#f1f5f9', codeText: '#0f172a', + dropdownBg: '#ffffff', dropdownBorder: '#e5e7eb', dropdownHover: '#f9fafb', + tooltipBg: '#1e293b', tooltipText: '#f8fafc', + + navActive: '#eff6ff', navActiveText: '#3B82F6', + navText: '#4b5563', navHover: '#f6f8fa', navHoverText: '#1a202c', + sectionLabel: '#9ca3af', logoText: '#111827', + + headerBg: '#ffffff', headerBorder: '#f0f0f0', headerText: '#111827', + searchBg: '#f9fafb', searchBorder: '#e5e7eb', searchBorderHover: '#d1d5db', searchText: '#9ca3af', + + badgeBg: '#ffffff', badgeBorder: '#e5e7eb', badgeRed: '#ef4444', + notifBg: '#ffffff', notifBorder: '#e5e7eb', + notifItemBorder: '#f9fafb', notifItemHover: '#fafbfc', + notifTitle: '#111827', notifDesc: '#6b7280', notifTime: '#9ca3af', + tierBtnBg: '#f9fafb', tierBtnBorder: '#e5e7eb', tierBtnText: '#374151', tierBtnHover: '#f3f4f6', + bellHover: '#f3f4f6', bellStroke: '#374151', + + sidebarBg: '#ffffff', sidebarBorder: '#e5e7eb', sidebarDivider: '#f0f0f0', + usageText: '#6b7280', usageValue: '#374151', usageTrack: '#f3f4f6', + + toggleOff: '#cbd5e1', +}; + +export const COLORS_DARK: ColorPalette = { + text1: '#f1f5f9', text2: '#e2e8f0', text3: '#94a3b8', text4: '#64748b', + text5: '#475569', text6: '#94a3b8', textMuted: '#64748b', textPlaceholder: '#475569', + + blue: '#60a5fa', blueDark: '#3b82f6', blueLight: '#1e293b', blueBorder: '#1e3a5f', + purple: '#a78bfa', purpleLight: '#1e1b2e', purpleBorder: '#2e2650', + green: '#34d399', greenLight: '#0d2818', greenBorder: '#065f46', greenDark: '#22c55e', + red: '#f87171', redLight: '#2d1215', redBorder: '#7f1d1d', + amber: '#fbbf24', amberLight: '#2d2006', amberBorder: '#78350f', amberDark: '#fbbf24', + + // Category accents — intentionally identical across themes; document + // categories are stable concepts, not mode-aware UI. + insurance: '#0ea5e9', compliance: '#6366f1', finance: '#10b981', legal: '#3B82F6', + healthcare: '#8B5CF6', government: '#dc2626', operations: '#ec4899', hr: '#f59e0b', + realestate: '#84cc16', energy: '#f97316', + + bg: '#090c14', bgAlt: '#0d1120', + surface: '#151c2e', surfaceAlt: '#1c2640', + border: '#283248', borderLight: '#1e2840', + borderInput: '#2e3c55', borderHover: '#3d4f6a', + divider: '#1e2840', + bgSubtle: '#111827', bgHover: '#1c2640', bgMuted: '#243044', bgCode: '#0b0f1a', + + codeBg: '#0b0f1a', codeText: '#e2e8f0', + dropdownBg: '#151c2e', dropdownBorder: '#2e3c55', dropdownHover: '#1c2640', + tooltipBg: '#e2e8f0', tooltipText: '#0f172a', + + navActive: '#172044', navActiveText: '#60a5fa', + navText: '#94a3b8', navHover: '#1c2640', navHoverText: '#e2e8f0', + sectionLabel: '#475569', logoText: '#f1f5f9', + + headerBg: '#0d1120', headerBorder: '#1e2840', headerText: '#f1f5f9', + searchBg: '#151c2e', searchBorder: '#2e3c55', searchBorderHover: '#3d4f6a', searchText: '#64748b', + + badgeBg: '#151c2e', badgeBorder: '#2e3c55', badgeRed: '#ef4444', + notifBg: '#151c2e', notifBorder: '#2e3c55', + notifItemBorder: '#1e2840', notifItemHover: '#1c2640', + notifTitle: '#f1f5f9', notifDesc: '#94a3b8', notifTime: '#64748b', + tierBtnBg: '#151c2e', tierBtnBorder: '#2e3c55', tierBtnText: '#94a3b8', tierBtnHover: '#1c2640', + bellHover: '#1c2640', bellStroke: '#94a3b8', + + sidebarBg: '#0d1120', sidebarBorder: '#1e2840', sidebarDivider: '#1e2840', + usageText: '#64748b', usageValue: '#94a3b8', usageTrack: '#1e2840', + + toggleOff: '#3d4f6a', +}; + +/** + * Code-block palette. Stays dark in both themes, like Stripe / Vercel. + * Use these tokens for syntax-highlighted blocks, terminal output, and + * inline code samples — anything where the reader expects "code aesthetics". + */ +export const CODE = { + bg: '#0f172a', + bgAlt: '#1e293b', + bgHeader: '#1a2332', + text: '#e2e8f0', + dim: '#64748b', + muted: '#94a3b8', + keyword: '#60a5fa', + string: '#fbbf24', + number: '#c084fc', + fn: '#34d399', + type: '#f472b6', + property: '#93c5fd', + comment: '#475569', + dot: '#475569', + border: '#1e293b', +} as const; + +/** Gradients shared across both modes (subtle tints). */ +export const GRADIENTS = { + blueBtnLight: 'linear-gradient(180deg, #5B9BF7 0%, #4C8BF5 50%, #3A7BE8 100%)', + blueBtnDark: 'linear-gradient(180deg, #3B82F6 0%, #2563EB 50%, #1D4ED8 100%)', + purpleBtn: 'linear-gradient(180deg, #a78bfa 0%, #8B5CF6 50%, #7c3aed 100%)', + greenBtn: 'linear-gradient(180deg, #34d399 0%, #10b981 50%, #059669 100%)', + bannerLight: 'linear-gradient(135deg, #eef2ff 0%, #ddd6fe 50%, #fce7f3 100%)', + bannerDark: 'linear-gradient(135deg, #0f172a 0%, #111827 50%, #1a1535 100%)', +} as const; + +export const SHADOWS = { + light: { + sm: 'inset 0 0 0 0.0625rem #e3e8ee', + md: 'inset 0 0 0 0.0625rem #e3e8ee, 0 0.0625rem 0.125rem rgba(15,23,42,0.04)', + lg: 'inset 0 0 0 0.0625rem #e3e8ee, 0 0.25rem 0.75rem rgba(15,23,42,0.06)', + blue: '0 0.0625rem 0.125rem rgba(59,130,246,0.25), inset 0 0.0625rem 0 rgba(255,255,255,0.2)', + blueHover: '0 0.125rem 0.375rem rgba(59,130,246,0.3), inset 0 0.0625rem 0 rgba(255,255,255,0.25)', + }, + dark: { + sm: 'inset 0 0 0 0.0625rem #283248', + md: 'inset 0 0 0 0.0625rem #283248, 0 0.0625rem 0.1875rem rgba(0,0,0,0.3)', + lg: 'inset 0 0 0 0.0625rem #283248, 0 0.5rem 1.5rem rgba(0,0,0,0.4)', + blue: '0 0.0625rem 0.1875rem rgba(59,130,246,0.35), inset 0 0.0625rem 0 rgba(255,255,255,0.1)', + blueHover: '0 0.125rem 0.5rem rgba(59,130,246,0.45), inset 0 0.0625rem 0 rgba(255,255,255,0.15)', + }, +} as const; + +/** Radii (px). */ +export const RADII = { + xs: 3, + sm: 4, + md: 6, + lg: 8, + xl: 12, + full: '50%' as const, + pill: 10, +} as const; + +/** Typography. */ +export const TYPE = { + fontSans: '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif', + fontMono: '"SF Mono", "Fira Code", Menlo, Consolas, "DejaVu Sans Mono", monospace', + fontBrand: '"Alumni Sans", "Inter", sans-serif', +} as const; + +/** Motion. */ +export const MOTION = { + fast: '0.15s ease', + base: '0.2s cubic-bezier(0.4, 0, 0.2, 1)', + slow: '0.3s ease', + // Component-level easings + enter: '0.22s cubic-bezier(0.4, 0, 0.2, 1)', + exit: '0.18s cubic-bezier(0.4, 0, 0.2, 1)', +} as const; + +/** Returns the resolved palette for a given mode. */ +export function palette(mode: ColorMode): ColorPalette { + return mode === 'dark' ? COLORS_DARK : COLORS_LIGHT; +} + +/** Tier visual mapping — used by the tier switcher and sidebar plan indicator. */ +export const TIER_INFO: Record = { + free: { label: 'Free Plan', dotColor: COLORS_LIGHT.green, chipColor: COLORS_LIGHT.greenLight }, + pro: { label: 'Pay-as-you-go', dotColor: COLORS_LIGHT.blue, chipColor: COLORS_LIGHT.blueLight }, + enterprise: { label: 'Enterprise Plan', dotColor: COLORS_LIGHT.purple, chipColor: COLORS_LIGHT.purpleLight }, +}; diff --git a/frontend/tsconfig.core.vite.json b/frontend/tsconfig.core.vite.json index daa02a7f05..ba68721388 100644 --- a/frontend/tsconfig.core.vite.json +++ b/frontend/tsconfig.core.vite.json @@ -2,7 +2,8 @@ "extends": "./tsconfig.json", "compilerOptions": { "paths": { - "@app/*": ["src/core/*"] + "@app/*": ["src/core/*"], + "@shared/*": ["src/shared/*"] } }, "exclude": ["src/proprietary", "src/desktop", "node_modules"] diff --git a/frontend/tsconfig.desktop.vite.json b/frontend/tsconfig.desktop.vite.json index abdd28a859..55d8614400 100644 --- a/frontend/tsconfig.desktop.vite.json +++ b/frontend/tsconfig.desktop.vite.json @@ -4,7 +4,8 @@ "paths": { "@app/*": ["src/desktop/*", "src/proprietary/*", "src/core/*"], "@proprietary/*": ["src/proprietary/*"], - "@core/*": ["src/core/*"] + "@core/*": ["src/core/*"], + "@shared/*": ["src/shared/*"] } }, "exclude": [ diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 37fc14efd0..f2fd04d2c0 100644 --- a/frontend/tsconfig.json +++ b/frontend/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/*": ["src/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/tsconfig.portal.vite.json b/frontend/tsconfig.portal.vite.json new file mode 100644 index 0000000000..4538e338df --- /dev/null +++ b/frontend/tsconfig.portal.vite.json @@ -0,0 +1,23 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "paths": { + "@app/*": ["src/portal/*", "src/shared/*"], + "@shared/*": ["src/shared/*"] + } + }, + "include": [ + "src", + "src/global.d.ts", + "vite.config.ts", + "portal" + ], + "exclude": [ + "src/core", + "src/proprietary", + "src/desktop", + "src/saas", + "src/prototypes", + "node_modules" + ] +} diff --git a/frontend/tsconfig.proprietary.vite.json b/frontend/tsconfig.proprietary.vite.json index 2cde30d2bf..7de025d24b 100644 --- a/frontend/tsconfig.proprietary.vite.json +++ b/frontend/tsconfig.proprietary.vite.json @@ -4,7 +4,8 @@ "paths": { "@app/*": ["src/proprietary/*", "src/core/*"], "@proprietary/*": ["src/proprietary/*"], - "@core/*": ["src/core/*"] + "@core/*": ["src/core/*"], + "@shared/*": ["src/shared/*"] } }, "exclude": [ diff --git a/frontend/tsconfig.prototypes.vite.json b/frontend/tsconfig.prototypes.vite.json index eae017ee6b..7d36be5198 100644 --- a/frontend/tsconfig.prototypes.vite.json +++ b/frontend/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/*": ["src/shared/*"] } }, "exclude": [ diff --git a/frontend/tsconfig.saas.vite.json b/frontend/tsconfig.saas.vite.json index c0cced8c04..e9bf37798b 100644 --- a/frontend/tsconfig.saas.vite.json +++ b/frontend/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/*": ["src/shared/*"] } }, "exclude": [ diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 65d2c0ae45..e94016553a 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,3 +1,4 @@ +import { resolve } from "node:path"; import { defineConfig, loadEnv, type PluginOption } from "vite"; import react from "@vitejs/plugin-react-swc"; import tsconfigPaths from "vite-tsconfig-paths"; @@ -9,6 +10,7 @@ const VALID_MODES = [ "saas", "desktop", "prototypes", + "portal", ] as const; type BuildMode = (typeof VALID_MODES)[number]; @@ -18,6 +20,7 @@ const TSCONFIG_MAP: Record = { saas: "./tsconfig.saas.vite.json", desktop: "./tsconfig.desktop.vite.json", prototypes: "./tsconfig.prototypes.vite.json", + portal: "./tsconfig.portal.vite.json", }; export default defineConfig(async ({ mode }) => { @@ -37,7 +40,10 @@ export default defineConfig(async ({ mode }) => { ? "core" : "proprietary"; - const tsconfigProject = TSCONFIG_MAP[effectiveMode]; + // Resolve to an absolute path because the portal mode shifts Vite's root, + // and tsconfig-paths would otherwise look for ./tsconfig.portal.vite.json + // relative to the new root (frontend/portal/) instead of frontend/. + const tsconfigProject = resolve(__dirname, TSCONFIG_MAP[effectiveMode]); // Backend proxy target: default localhost:8080. Override via BACKEND_URL env var // so the top-level dev launcher can wire a dynamically-assigned backend port. @@ -49,6 +55,12 @@ export default defineConfig(async ({ mode }) => { xfwd: true, }; + const isPortal = effectiveMode === "portal"; + // Storybook sets this env var in .storybook/main.ts before loading us. When + // it's set we treat the build as a portal build for asset-copying purposes. + const isStorybook = process.env.STIRLING_STORYBOOK === "true"; + const skipEditorAssets = isPortal || isStorybook; + return { plugins: [ react(), @@ -68,33 +80,40 @@ export default defineConfig(async ({ mode }) => { }) as PluginOption, ] : []), - viteStaticCopy({ - targets: [ - { - //provides static pdfium so embedpdf can run without cdn - src: "node_modules/@embedpdf/pdfium/dist/pdfium.wasm", - dest: "pdfium", - }, - { - // Copy jscanify vendor files to dist - src: "public/vendor/jscanify/*", - dest: "vendor/jscanify", - }, - { - // pdfjs-dist CMap data for CJK / non-latin glyph mapping — required - // when rendering PDFs inside workers where the default DOM fetch paths - // aren't available. - src: "node_modules/pdfjs-dist/cmaps/*", - dest: "pdfjs/cmaps", - }, - { - // pdfjs-dist standard font data (Helvetica/Times/etc.) — needed so - // workers can substitute non-embedded base 14 fonts without DOM access. - src: "node_modules/pdfjs-dist/standard_fonts/*", - dest: "pdfjs/standard_fonts", - }, - ], - }), + // Editor-only static asset copies (pdfium, pdfjs, jscanify). The portal + // does not render PDFs, so we skip these when building portal mode (or + // Storybook) to keep the bundle lean. + ...(skipEditorAssets + ? [] + : [ + viteStaticCopy({ + targets: [ + { + //provides static pdfium so embedpdf can run without cdn + src: "node_modules/@embedpdf/pdfium/dist/pdfium.wasm", + dest: "pdfium", + }, + { + // Copy jscanify vendor files to dist + src: "public/vendor/jscanify/*", + dest: "vendor/jscanify", + }, + { + // pdfjs-dist CMap data for CJK / non-latin glyph mapping — required + // when rendering PDFs inside workers where the default DOM fetch paths + // aren't available. + src: "node_modules/pdfjs-dist/cmaps/*", + dest: "pdfjs/cmaps", + }, + { + // pdfjs-dist standard font data (Helvetica/Times/etc.) — needed so + // workers can substitute non-embedded base 14 fonts without DOM access. + src: "node_modules/pdfjs-dist/standard_fonts/*", + dest: "pdfjs/standard_fonts", + }, + ], + }), + ]), ], server: { host: true, @@ -106,9 +125,16 @@ export default defineConfig(async ({ mode }) => { // tell vite to ignore watching `src-tauri` ignored: ["**/src-tauri/**"], }, - // Only use proxy in web mode - Tauri handles backend connections directly + // Portal mode's root is frontend/portal/. Its entry (portal/main.tsx) + // imports @app/* and @shared/* which resolve to files under frontend/src/. + // Without an explicit fs.allow Vite would reject those reads as "outside + // the project root." + fs: isPortal ? { allow: [resolve(__dirname)] } : undefined, + // The desktop build talks to the backend directly via Tauri; the portal + // is not wired to a backend yet (no API surface). Everything else proxies + // the editor's backend on :8080. proxy: - effectiveMode === "desktop" + effectiveMode === "desktop" || isPortal ? undefined : { "/api": backendProxy, @@ -120,6 +146,25 @@ export default defineConfig(async ({ mode }) => { "/v1/api-docs": backendProxy, }, }, + // The portal is rooted at frontend/portal/ so its dev URL is just `/` + // and the build emits an index.html instead of a portal.html. The editor's + // public/ holds PDF assets, locales, and login imagery — none of which + // the portal needs, so we skip the publicDir copy too. + ...(isPortal + ? { + root: "portal", + // public/ inside portal/ ships the MSW service worker (and any other + // portal-only static assets) — but does NOT pull in the editor's + // public/ which holds PDF assets, locales, and login imagery. + publicDir: "public", + build: { + // Relative to root, so this lands at frontend/dist-portal/ — outside + // the portal/ directory and parallel to the editor's dist/. + outDir: "../dist-portal", + emptyOutDir: true, + }, + } + : {}), base: env.RUN_SUBPATH ? `/${env.RUN_SUBPATH}` : "./", }; }); diff --git a/stirling-frontend/.gitignore b/stirling-frontend/.gitignore new file mode 100644 index 0000000000..d4d0bd6a26 --- /dev/null +++ b/stirling-frontend/.gitignore @@ -0,0 +1,7 @@ +node_modules +dist +storybook-static +.DS_Store +.env +.env.local +*.log diff --git a/stirling-frontend/.storybook/main.ts b/stirling-frontend/.storybook/main.ts new file mode 100644 index 0000000000..30ac4ec1bb --- /dev/null +++ b/stirling-frontend/.storybook/main.ts @@ -0,0 +1,19 @@ +import type { StorybookConfig } from '@storybook/react-vite'; + +const config: StorybookConfig = { + stories: ['../src/**/*.stories.@(ts|tsx|mdx)'], + addons: [ + '@storybook/addon-essentials', + '@storybook/addon-interactions', + '@storybook/addon-themes', + ], + framework: { + name: '@storybook/react-vite', + options: {}, + }, + typescript: { + reactDocgen: 'react-docgen-typescript', + }, +}; + +export default config; diff --git a/stirling-frontend/.storybook/preview.ts b/stirling-frontend/.storybook/preview.ts new file mode 100644 index 0000000000..20d0be97e9 --- /dev/null +++ b/stirling-frontend/.storybook/preview.ts @@ -0,0 +1,46 @@ +import type { Preview } from '@storybook/react'; +import { withThemeByDataAttribute } from '@storybook/addon-themes'; + +import '../src/tokens/tokens.css'; +import '../src/tokens/base.css'; + +const preview: Preview = { + 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)' }, + ], + }, + }, + globalTypes: { + tier: { + name: 'Tier', + description: 'Subscription tier', + defaultValue: 'pro', + toolbar: { + icon: 'star', + items: [ + { value: 'free', title: 'Free' }, + { value: 'pro', title: 'Pay-as-you-go' }, + { value: 'enterprise', title: 'Enterprise' }, + ], + dynamicTitle: true, + }, + }, + }, + decorators: [ + withThemeByDataAttribute({ + themes: { light: 'light', dark: 'dark' }, + defaultTheme: 'light', + attributeName: 'data-theme', + }), + ], +}; + +export default preview; diff --git a/stirling-frontend/MARKER.txt b/stirling-frontend/MARKER.txt new file mode 100644 index 0000000000..2fc2d476a5 --- /dev/null +++ b/stirling-frontend/MARKER.txt @@ -0,0 +1 @@ +If you can see this file, the folder exists at this path. diff --git a/stirling-frontend/README.md b/stirling-frontend/README.md new file mode 100644 index 0000000000..80374df419 --- /dev/null +++ b/stirling-frontend/README.md @@ -0,0 +1,101 @@ +# Stirling frontend + +A Vite + React 18 + TypeScript scaffold lifted from the `stirling-dev-portal.html` prototype. Three things ship in this repo: + +1. **Typed design tokens** — light + dark colour palettes, an always-dark code palette, gradients, shadows, radii, typography and motion. Available both as a TypeScript module (`src/tokens/tokens.ts`) and as CSS custom properties (`src/tokens/tokens.css`). Theme switching flips `data-theme` on ``. +2. **Typed data catalogues** — every endpoint (`src/data/endpoints.ts`) and every operation (`src/data/ops.ts`) from the prototype, lifted into immutable TypeScript constants. The endpoint catalogue carries route path, tier gate, JSON-shape schema, region availability and vertical accent for ~60 endpoints across 10 verticals. The op library carries the canonical 6-stage pipeline taxonomy (`PIPELINE_OPS`), the wider 100+-op library (`LIBRARY_OPS`), the pre-bundled one-click agents, and the source/destination rail options. +3. **Component primitives** — `Button`, `StatusBadge`, `MethodBadge`, `ToggleSwitch`, `ProgressBar`, `MetricCard`, `NavItem`, `PanelHeader`, `CodeBlock`, `SectionDivider`, `Card`. Each has its own CSS file referencing tokens via `var(--...)`, and each has a Storybook story exercising the variants. + +## Quickstart + +```bash +pnpm install # or npm install +pnpm dev # Vite dev server — runs src/App.tsx as a smoke test +pnpm storybook # Component explorer at http://localhost:6006 +pnpm build # Type-check + Vite production build +pnpm typecheck # Just tsc --noEmit +``` + +If you prefer npm or yarn the same scripts apply — they just call the underlying tools. + +## Layout + +``` +stirling-frontend/ +├── index.html # Vite entry +├── package.json +├── tsconfig.json +├── vite.config.ts +├── .storybook/ +│ ├── main.ts # Storybook config (vite, addons) +│ └── preview.ts # Theme decorator + tier toolbar +└── src/ + ├── main.tsx # React root + ├── App.tsx # Smoke-test surface (delete when ViewRouter lands) + ├── tokens/ + │ ├── tokens.ts # COLORS_LIGHT, COLORS_DARK, CODE, GRADIENTS, SHADOWS, RADII, TYPE, MOTION, palette() + │ ├── tokens.css # CSS custom properties, keyframes, prefers-reduced-motion + │ ├── base.css # Minimal reset + base typography + │ └── Tokens.stories.tsx # Live swatch grid + typography + motion preview + ├── data/ + │ ├── endpoints.ts # Endpoint, Vertical, VERTICALS, ALL_ENDPOINTS, lookupEndpoint, isEndpointAvailable + │ ├── ops.ts # PIPELINE_OPS, LIBRARY_OPS, PIPELINE_AGENTS, OP_CATEGORIES, SOURCE_OPTIONS, DESTINATION_OPTIONS + │ ├── Endpoints.stories.tsx # Vertical-grouped endpoint browser + │ └── Ops.stories.tsx # Stage-grouped + category-grouped op browsers + └── components/ + ├── Button.{tsx,css,stories.tsx} + ├── StatusBadge.{tsx,css,stories.tsx} + ├── MethodBadge.{tsx,css,stories.tsx} + ├── ToggleSwitch.{tsx,css,stories.tsx} + ├── ProgressBar.{tsx,css,stories.tsx} + ├── MetricCard.{tsx,css,stories.tsx} + ├── NavItem.{tsx,css,stories.tsx} + ├── PanelHeader.{tsx,css,stories.tsx} + ├── CodeBlock.{tsx,css,stories.tsx} + ├── SectionDivider.{tsx,css} + ├── Card.{tsx,css,stories.tsx} + └── index.ts +``` + +## Theme switching + +The runtime contract: set `data-theme="dark"` (or `"light"`) on ``. Every component reads colours via CSS custom properties, so flipping the attribute cascades everywhere without re-renders. + +```ts +function setTheme(mode: 'light' | 'dark') { + document.documentElement.setAttribute('data-theme', mode); +} +``` + +The `Tokens.stories.tsx` swatch grid renders against whatever theme Storybook's toolbar has selected, so designers can preview both modes side-by-side without code changes. + +## Tier system + +`Tier` (`'free' | 'pro' | 'enterprise'`) lives in `tokens.ts` and is the single shared definition. The endpoint helper `isEndpointAvailable(endpoint, tier)` already implements the gate logic. When the app shell lands, wrap it in a `useTier()` hook rather than threading the value through every component prop. Storybook's `tier` global toolbar (see `.storybook/preview.ts`) is set up to feed the right value in. + +## Extending + +**Add a new endpoint.** Append to the appropriate vertical array in `src/data/endpoints.ts`. `ALL_ENDPOINTS` and `ENDPOINTS_BY_PATH` rebuild automatically. + +**Add a new op.** For pipeline-stage ops, add to the appropriate stage in `PIPELINE_OPS`. For the wider library, append to `LIBRARY_OPS` with the right `kind` and `category`. `PIPELINE_OPS_INDEX` and `LIBRARY_OPS_INDEX` rebuild automatically. + +**Add a new primitive.** Drop a new folder `src/components/X/` with `X.tsx`, `X.css`, and `X.stories.tsx`, then re-export from `src/components/index.ts`. Use CSS custom properties for every colour — no hardcoded hexes outside the token files. + +**Add a new theme.** Open `tokens.ts`, add a new `ColorPalette` constant, and a parallel block to `tokens.css` under a new `[data-theme="..."]` selector. The component primitives don't care which theme is active. + +## Conventions + +- All colour references in component CSS go through `var(--color-...)` — never hardcode hex except inside `tokens.ts` / `tokens.css`. +- Category accents are theme-stable on purpose. Don't put them in the dark-mode override block. +- The `CODE` palette is also theme-stable. Code blocks stay dark in both modes (Stripe / Vercel pattern). +- `prefers-reduced-motion` is respected globally; do not author animations that ignore it. +- Components expose a `className` prop for callers to extend styles. Do not pass inline styles for colour overrides — add a prop and a CSS class instead. + +## Not included yet + +- App shell (Sidebar, Header, ViewRouter) +- Floating Assistant widget +- Icons — the prototype uses a Lucide-style sprite under `Icon.*`; recommendation is to depend on `lucide-react` directly rather than re-implementing. +- Charts — pull in Recharts when the UsageAreaChart and DashboardAreaChart surfaces land. +- The Mock layer (MSW) — drop in handlers keyed off `ALL_ENDPOINTS` so the data shapes here drive the fake API for free. +- The view surfaces themselves (Home, Pipelines, Sources, Documents, Infrastructure, …) — see Section 9 of `Stirling_Frontend_Breakdown.docx` for the staging plan. diff --git a/stirling-frontend/index.html b/stirling-frontend/index.html new file mode 100644 index 0000000000..b8be15875e --- /dev/null +++ b/stirling-frontend/index.html @@ -0,0 +1,15 @@ + + + + + + Stirling Developer Portal + + + + + +
+ + + diff --git a/stirling-frontend/package-lock.json b/stirling-frontend/package-lock.json new file mode 100644 index 0000000000..0d5ba1c500 --- /dev/null +++ b/stirling-frontend/package-lock.json @@ -0,0 +1,4909 @@ +{ + "name": "stirling-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "stirling-frontend", + "version": "0.1.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@storybook/addon-essentials": "^8.4.0", + "@storybook/addon-interactions": "^8.4.0", + "@storybook/addon-themes": "^8.4.0", + "@storybook/react-vite": "^8.4.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "storybook": "^8.4.0", + "typescript": "^5.6.3", + "vite": "^5.4.11" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "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/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "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-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "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-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.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", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "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.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@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/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@joshwooding/vite-plugin-react-docgen-typescript": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@joshwooding/vite-plugin-react-docgen-typescript/-/vite-plugin-react-docgen-typescript-0.5.0.tgz", + "integrity": "sha512-qYDdL7fPwLRI+bJNurVcis+tNgJmvWjH4YTBGXTA8xMuxFrnAz6E5o35iyzyKbq5J5Lr8mJGfrR5GXl+WGwhgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^10.0.0", + "magic-string": "^0.27.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" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@joshwooding/vite-plugin-react-docgen-typescript/node_modules/magic-string": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.27.0.tgz", + "integrity": "sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.13" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "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/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "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/rollup-android-arm-eabi": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", + "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", + "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", + "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", + "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", + "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", + "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", + "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", + "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", + "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@storybook/addon-actions": { + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-8.6.14.tgz", + "integrity": "sha512-mDQxylxGGCQSK7tJPkD144J8jWh9IU9ziJMHfB84PKpI/V5ZgqMDnpr2bssTrUaGDqU5e1/z8KcRF+Melhs9pQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "@types/uuid": "^9.0.1", + "dequal": "^2.0.2", + "polished": "^4.2.2", + "uuid": "^9.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.6.14" + } + }, + "node_modules/@storybook/addon-backgrounds": { + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/addon-backgrounds/-/addon-backgrounds-8.6.14.tgz", + "integrity": "sha512-l9xS8qWe5n4tvMwth09QxH2PmJbCctEvBAc1tjjRasAfrd69f7/uFK4WhwJAstzBTNgTc8VXI4w8ZR97i1sFbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "memoizerific": "^1.11.3", + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.6.14" + } + }, + "node_modules/@storybook/addon-controls": { + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/addon-controls/-/addon-controls-8.6.14.tgz", + "integrity": "sha512-IiQpkNJdiRyA4Mq9mzjZlvQugL/aE7hNgVxBBGPiIZG6wb6Ht9hNnBYpap5ZXXFKV9p2qVI0FZK445ONmAa+Cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "dequal": "^2.0.2", + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.6.14" + } + }, + "node_modules/@storybook/addon-docs": { + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-8.6.14.tgz", + "integrity": "sha512-Obpd0OhAF99JyU5pp5ci17YmpcQtMNgqW2pTXV8jAiiipWpwO++hNDeQmLmlSXB399XjtRDOcDVkoc7rc6JzdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mdx-js/react": "^3.0.0", + "@storybook/blocks": "8.6.14", + "@storybook/csf-plugin": "8.6.14", + "@storybook/react-dom-shim": "8.6.14", + "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": "^8.6.14" + } + }, + "node_modules/@storybook/addon-essentials": { + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/addon-essentials/-/addon-essentials-8.6.14.tgz", + "integrity": "sha512-5ZZSHNaW9mXMOFkoPyc3QkoNGdJHETZydI62/OASR0lmPlJ1065TNigEo5dJddmZNn0/3bkE8eKMAzLnO5eIdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/addon-actions": "8.6.14", + "@storybook/addon-backgrounds": "8.6.14", + "@storybook/addon-controls": "8.6.14", + "@storybook/addon-docs": "8.6.14", + "@storybook/addon-highlight": "8.6.14", + "@storybook/addon-measure": "8.6.14", + "@storybook/addon-outline": "8.6.14", + "@storybook/addon-toolbars": "8.6.14", + "@storybook/addon-viewport": "8.6.14", + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.6.14" + } + }, + "node_modules/@storybook/addon-highlight": { + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/addon-highlight/-/addon-highlight-8.6.14.tgz", + "integrity": "sha512-4H19OJlapkofiE9tM6K/vsepf4ir9jMm9T+zw5L85blJZxhKZIbJ6FO0TCG9PDc4iPt3L6+aq5B0X29s9zicNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.6.14" + } + }, + "node_modules/@storybook/addon-interactions": { + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/addon-interactions/-/addon-interactions-8.6.14.tgz", + "integrity": "sha512-8VmElhm2XOjh22l/dO4UmXxNOolGhNiSpBcls2pqWSraVh4a670EyYBZsHpkXqfNHo2YgKyZN3C91+9zfH79qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "@storybook/instrumenter": "8.6.14", + "@storybook/test": "8.6.14", + "polished": "^4.2.2", + "ts-dedent": "^2.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.6.14" + } + }, + "node_modules/@storybook/addon-measure": { + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/addon-measure/-/addon-measure-8.6.14.tgz", + "integrity": "sha512-1Tlyb72NX8aAqm6I6OICsUuGOP6hgnXcuFlXucyhKomPa6j3Eu2vKu561t/f0oGtAK2nO93Z70kVaEh5X+vaGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "tiny-invariant": "^1.3.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.6.14" + } + }, + "node_modules/@storybook/addon-outline": { + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/addon-outline/-/addon-outline-8.6.14.tgz", + "integrity": "sha512-CW857JvN6OxGWElqjlzJO2S69DHf+xO3WsEfT5mT3ZtIjmsvRDukdWfDU9bIYUFyA2lFvYjncBGjbK+I91XR7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.6.14" + } + }, + "node_modules/@storybook/addon-themes": { + "version": "8.6.18", + "resolved": "https://registry.npmjs.org/@storybook/addon-themes/-/addon-themes-8.6.18.tgz", + "integrity": "sha512-v+Xcxo/XB2ayw9E/RygGOqUIaPOXp9XEFv6EF7DRt41/RQkQ846yJBARTC2cH8kCb+7FYcvDLD3GN/ms7i8wNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.6.18" + } + }, + "node_modules/@storybook/addon-toolbars": { + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/addon-toolbars/-/addon-toolbars-8.6.14.tgz", + "integrity": "sha512-W/wEXT8h3VyZTVfWK/84BAcjAxTdtRiAkT2KAN0nbSHxxB5KEM1MjKpKu2upyzzMa3EywITqbfy4dP6lpkVTwQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.6.14" + } + }, + "node_modules/@storybook/addon-viewport": { + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/addon-viewport/-/addon-viewport-8.6.14.tgz", + "integrity": "sha512-gNzVQbMqRC+/4uQTPI2ZrWuRHGquTMZpdgB9DrD88VTEjNudP+J6r8myLfr2VvGksBbUMHkGHMXHuIhrBEnXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "memoizerific": "^1.11.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.6.14" + } + }, + "node_modules/@storybook/blocks": { + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/blocks/-/blocks-8.6.14.tgz", + "integrity": "sha512-rBMHAfA39AGHgkrDze4RmsnQTMw1ND5fGWobr9pDcJdnDKWQWNRD7Nrlxj0gFlN3n4D9lEZhWGdFrCbku7FVAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/icons": "^1.2.12", + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "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", + "storybook": "^8.6.14" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/@storybook/builder-vite": { + "version": "8.6.18", + "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-8.6.18.tgz", + "integrity": "sha512-XLqnOv4C36jlTd4uC8xpWBxv+7GV4/05zWJ0wAcU4qflorropUTirt4UQPGkwIzi+BVAhs9pJj+m4k0IWJtpHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/csf-plugin": "8.6.18", + "browser-assert": "^1.2.1", + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.6.18", + "vite": "^4.0.0 || ^5.0.0 || ^6.0.0" + } + }, + "node_modules/@storybook/builder-vite/node_modules/@storybook/csf-plugin": { + "version": "8.6.18", + "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-8.6.18.tgz", + "integrity": "sha512-x1ioz/L0CwaelCkHci3P31YtvwayN3FBftvwQOPbvRh9qeb4Cpz5IdVDmyvSxxYwXN66uAORNoqgjTi7B4/y5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "unplugin": "^1.3.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.6.18" + } + }, + "node_modules/@storybook/components": { + "version": "8.6.18", + "resolved": "https://registry.npmjs.org/@storybook/components/-/components-8.6.18.tgz", + "integrity": "sha512-55yViiZzPS/cPBuOeW4QGxGqrusjXVyxuknmbYCIwDtFyyvI/CgbjXRHdxNBaIjz+IlftxvBmmSaOqFG5+/dkA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0" + } + }, + "node_modules/@storybook/core": { + "version": "8.6.18", + "resolved": "https://registry.npmjs.org/@storybook/core/-/core-8.6.18.tgz", + "integrity": "sha512-dRBP2TnX6fGdS0T2mXBHjkS/3Nlu1ra1huovZVFuM67CYMzrhM/3hX/zru1vWSC5rqY93ZaAhjMciPW4pK5mMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/theming": "8.6.18", + "better-opn": "^3.0.2", + "browser-assert": "^1.2.1", + "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", + "jsdoc-type-pratt-parser": "^4.0.0", + "process": "^0.11.10", + "recast": "^0.23.5", + "semver": "^7.6.2", + "util": "^0.12.5", + "ws": "^8.2.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "prettier": "^2 || ^3" + }, + "peerDependenciesMeta": { + "prettier": { + "optional": true + } + } + }, + "node_modules/@storybook/core/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@storybook/csf-plugin": { + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-8.6.14.tgz", + "integrity": "sha512-dErtc9teAuN+eelN8FojzFE635xlq9cNGGGEu0WEmMUQ4iJ8pingvBO1N8X3scz4Ry7KnxX++NNf3J3gpxS8qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "unplugin": "^1.3.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.6.14" + } + }, + "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/instrumenter": { + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/instrumenter/-/instrumenter-8.6.14.tgz", + "integrity": "sha512-iG4MlWCcz1L7Yu8AwgsnfVAmMbvyRSk700Mfy2g4c8y5O+Cv1ejshE1LBBsCwHgkuqU0H4R0qu4g23+6UnUemQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "@vitest/utils": "^2.1.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.6.14" + } + }, + "node_modules/@storybook/manager-api": { + "version": "8.6.18", + "resolved": "https://registry.npmjs.org/@storybook/manager-api/-/manager-api-8.6.18.tgz", + "integrity": "sha512-BjIp12gEMgzFkEsgKpDIbZdnSWTZpm2dlws8WiPJCpgJtG+HWSxZ0/Ms30Au9yfwzQEKRSbV/5zpsKMGc2SIJw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0" + } + }, + "node_modules/@storybook/preview-api": { + "version": "8.6.18", + "resolved": "https://registry.npmjs.org/@storybook/preview-api/-/preview-api-8.6.18.tgz", + "integrity": "sha512-joXRXh3GdVvzhbfIgmix1xs90p8Q/nja7AhEAC2egn5Pl7SKsIYZUCYI6UdrQANb2myg9P552LKXfPect8llKg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0" + } + }, + "node_modules/@storybook/react": { + "version": "8.6.18", + "resolved": "https://registry.npmjs.org/@storybook/react/-/react-8.6.18.tgz", + "integrity": "sha512-BuLpzMkKtF+UCQCbi+lYVX9cdcAMG86Lu2dDn7UFkPi5HRNFq/zHPSvlz1XDgL0OYMtcqB1aoVzFzcyzUBhhjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/components": "8.6.18", + "@storybook/global": "^5.0.0", + "@storybook/manager-api": "8.6.18", + "@storybook/preview-api": "8.6.18", + "@storybook/react-dom-shim": "8.6.18", + "@storybook/theming": "8.6.18" + }, + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "@storybook/test": "8.6.18", + "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": "^8.6.18", + "typescript": ">= 4.2.x" + }, + "peerDependenciesMeta": { + "@storybook/test": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@storybook/react-dom-shim": { + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-8.6.14.tgz", + "integrity": "sha512-0hixr3dOy3f3M+HBofp3jtMQMS+sqzjKNgl7Arfuj3fvjmyXOks/yGjDImySR4imPtEllvPZfhiQNlejheaInw==", + "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": "^8.6.14" + } + }, + "node_modules/@storybook/react-vite": { + "version": "8.6.18", + "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-8.6.18.tgz", + "integrity": "sha512-qpSYyH2IizlEsI95MJTdIL6xpLSgiNCMoJpHu+IEqLnyvmecRR/YEZvcHalgdtawuXlimH0bAYuwIu3l8Vo6FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@joshwooding/vite-plugin-react-docgen-typescript": "0.5.0", + "@rollup/pluginutils": "^5.0.2", + "@storybook/builder-vite": "8.6.18", + "@storybook/react": "8.6.18", + "find-up": "^5.0.0", + "magic-string": "^0.30.0", + "react-docgen": "^7.0.0", + "resolve": "^1.22.8", + "tsconfig-paths": "^4.2.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "@storybook/test": "8.6.18", + "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": "^8.6.18", + "vite": "^4.0.0 || ^5.0.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "@storybook/test": { + "optional": true + } + } + }, + "node_modules/@storybook/react/node_modules/@storybook/react-dom-shim": { + "version": "8.6.18", + "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-8.6.18.tgz", + "integrity": "sha512-N4xULcAWZQTUv4jy1/d346Tyb4gufuC3UaLCuU/iVSZ1brYF4OW3ANr+096btbMxY8pR/65lmtoqr5CTGwnBvA==", + "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": "^8.6.18" + } + }, + "node_modules/@storybook/test": { + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/test/-/test-8.6.14.tgz", + "integrity": "sha512-GkPNBbbZmz+XRdrhMtkxPotCLOQ1BaGNp/gFZYdGDk2KmUWBKmvc5JxxOhtoXM2703IzNFlQHSSNnhrDZYuLlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "@storybook/instrumenter": "8.6.14", + "@testing-library/dom": "10.4.0", + "@testing-library/jest-dom": "6.5.0", + "@testing-library/user-event": "14.5.2", + "@vitest/expect": "2.0.5", + "@vitest/spy": "2.0.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.6.14" + } + }, + "node_modules/@storybook/theming": { + "version": "8.6.18", + "resolved": "https://registry.npmjs.org/@storybook/theming/-/theming-8.6.18.tgz", + "integrity": "sha512-n6OEjEtHupa2PdTwWzRepr7cO8NkDd4rgF6BKLitRbujOspLxzMBEqdphs+QLcuiCIgf33SqmEA64QWnbSMhPw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", + "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.5.0.tgz", + "integrity": "sha512-xGGHpBXYSHUUr6XsKBfs85TWlYKpTc37cSBBVrXcib2MkHLboWlkClhWF37JKlDb9KEq3dHs+f2xR7XJEWGBxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "lodash": "^4.17.21", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/user-event": { + "version": "14.5.2", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz", + "integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "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/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/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "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/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "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/uuid": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", + "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.0.5.tgz", + "integrity": "sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.0.5", + "@vitest/utils": "2.0.5", + "chai": "^5.1.1", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/expect/node_modules/@vitest/pretty-format": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.0.5.tgz", + "integrity": "sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/expect/node_modules/@vitest/utils": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.0.5.tgz", + "integrity": "sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.0.5", + "estree-walker": "^3.0.3", + "loupe": "^3.1.1", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/expect/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.0.5.tgz", + "integrity": "sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "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/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.29", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", + "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.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/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/browser-assert": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/browser-assert/-/browser-assert-1.2.1.tgz", + "integrity": "sha512-nfulgvOR6S4gt9UKCeGJOuSGBPGiFT6oQ/2UBnvTY/5aQ1PnksW72fhZkM30DzoRRv2WpwZf1vHHEr3mtuXIWQ==", + "dev": true + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001792", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "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/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.354", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.354.tgz", + "integrity": "sha512-JaBHwWcfIdmSAfWM5l3uwjGd431j8YEMikZ+K/2nXVuBqJKyZ0f+2h4n4JY5AyNiZmnY9qQr2RU3v9DxDmHMNg==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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/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", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "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/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "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-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsdoc-type-pratt-parser": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.8.0.tgz", + "integrity": "sha512-iZ8Bdb84lWRuGHamRXFyML07r21pcwBrLkHEuHgEY5UbCouBwv7ECknDRKzsQIXMiqpPymqtIf8TC/shYKB5rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "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/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/map-or-similar": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/map-or-similar/-/map-or-similar-1.5.0.tgz", + "integrity": "sha512-0aF7ZmVon1igznGI4VS30yugpduQW3y3GkcgGJOp7d8x8QrizhigUxjI/m2UojsXXto+jLAH3KSz+xOJTiORjg==", + "dev": true, + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoizerific": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/memoizerific/-/memoizerific-1.11.3.tgz", + "integrity": "sha512-/EuHYwAPdLtXwAwSZkh/Gutery6pD2KYd44oQLhAvQp/50mpyduZh8Q7PYHXTCJ+wuXxt7oij2LXyIJOOYFPog==", + "dev": true, + "license": "MIT", + "dependencies": { + "map-or-similar": "^1.5.0" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.44", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", + "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", + "dev": true, + "license": "MIT" + }, + "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/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "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/polished": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz", + "integrity": "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-docgen": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-7.1.1.tgz", + "integrity": "sha512-hlSJDQ2synMPKFZOsKo9Hi8WWZTC7POR8EmWvTSjow+VDgKzkmjQvFm2fk0tmRw+f0vTOIYKlarR0iL4996pdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.18.9", + "@babel/traverse": "^7.18.9", + "@babel/types": "^7.18.9", + "@types/babel__core": "^7.18.0", + "@types/babel__traverse": "^7.18.0", + "@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": ">=16.14.0" + } + }, + "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-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.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/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/redent/node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rollup": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", + "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.3", + "@rollup/rollup-android-arm64": "4.60.3", + "@rollup/rollup-darwin-arm64": "4.60.3", + "@rollup/rollup-darwin-x64": "4.60.3", + "@rollup/rollup-freebsd-arm64": "4.60.3", + "@rollup/rollup-freebsd-x64": "4.60.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", + "@rollup/rollup-linux-arm-musleabihf": "4.60.3", + "@rollup/rollup-linux-arm64-gnu": "4.60.3", + "@rollup/rollup-linux-arm64-musl": "4.60.3", + "@rollup/rollup-linux-loong64-gnu": "4.60.3", + "@rollup/rollup-linux-loong64-musl": "4.60.3", + "@rollup/rollup-linux-ppc64-gnu": "4.60.3", + "@rollup/rollup-linux-ppc64-musl": "4.60.3", + "@rollup/rollup-linux-riscv64-gnu": "4.60.3", + "@rollup/rollup-linux-riscv64-musl": "4.60.3", + "@rollup/rollup-linux-s390x-gnu": "4.60.3", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@rollup/rollup-openbsd-x64": "4.60.3", + "@rollup/rollup-openharmony-arm64": "4.60.3", + "@rollup/rollup-win32-arm64-msvc": "4.60.3", + "@rollup/rollup-win32-ia32-msvc": "4.60.3", + "@rollup/rollup-win32-x64-gnu": "4.60.3", + "@rollup/rollup-win32-x64-msvc": "4.60.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "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/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/storybook": { + "version": "8.6.18", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-8.6.18.tgz", + "integrity": "sha512-p8seiSI6FiVY6P3V0pG+5v7c8pDMehMAFRWEhG5XqIBSQszzOjDnW2rNvm3odoLKfo3V3P6Cs6Hv9ILzymULyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/core": "8.6.18" + }, + "bin": { + "getstorybook": "bin/index.cjs", + "sb": "bin/index.cjs", + "storybook": "bin/index.cjs" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "prettier": "^2 || ^3" + }, + "peerDependenciesMeta": { + "prettier": { + "optional": true + } + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "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/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "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/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "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/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "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/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "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/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/stirling-frontend/package.json b/stirling-frontend/package.json new file mode 100644 index 0000000000..69a2bc0b42 --- /dev/null +++ b/stirling-frontend/package.json @@ -0,0 +1,31 @@ +{ + "name": "stirling-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build", + "typecheck": "tsc --noEmit", + "lint": "eslint . --ext .ts,.tsx" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@storybook/addon-essentials": "^8.4.0", + "@storybook/addon-interactions": "^8.4.0", + "@storybook/addon-themes": "^8.4.0", + "@storybook/react-vite": "^8.4.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "storybook": "^8.4.0", + "typescript": "^5.6.3", + "vite": "^5.4.11" + } +} diff --git a/stirling-frontend/src/App.tsx b/stirling-frontend/src/App.tsx new file mode 100644 index 0000000000..c1569b8490 --- /dev/null +++ b/stirling-frontend/src/App.tsx @@ -0,0 +1,73 @@ +import { useState } from 'react'; +import { Button, StatusBadge, MethodBadge, MetricCard, ProgressBar, ToggleSwitch, NavItem, PanelHeader, CodeBlock, SectionDivider, Card } from './components'; + +/** + * Minimal smoke-test app. The real product surfaces live in Storybook for now — + * this file just proves the design-system primitives mount and theme correctly + * outside the Storybook chrome. Delete or replace with the real ViewRouter once + * the app shell lands. + */ +export function App() { + const [theme, setTheme] = useState<'light' | 'dark'>('light'); + const [toggle, setToggle] = useState(true); + + function flipTheme() { + const next = theme === 'light' ? 'dark' : 'light'; + setTheme(next); + document.documentElement.setAttribute('data-theme', next); + } + + return ( +
+ + Live + + + } + /> + +
+ + + +
+ + + +
+ + + + +
+ + + +
+ + /v1/coi + + +
+ + + + + + + + +
+ ); +} diff --git a/stirling-frontend/src/components/Button.css b/stirling-frontend/src/components/Button.css new file mode 100644 index 0000000000..696422482e --- /dev/null +++ b/stirling-frontend/src/components/Button.css @@ -0,0 +1,72 @@ +.sui-btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + border: 1px solid transparent; + border-radius: var(--radius-md); + font-family: var(--font-sans); + font-weight: 500; + cursor: pointer; + transition: transform var(--motion-fast), box-shadow var(--motion-fast), + background var(--motion-fast), color var(--motion-fast), border-color var(--motion-fast); + white-space: nowrap; + user-select: none; +} +.sui-btn:disabled { cursor: not-allowed; opacity: 0.55; } +.sui-btn:focus-visible { + outline: 2px solid var(--color-blue); + outline-offset: 2px; +} + +/* sizes */ +.sui-btn--md { font-size: 0.8125rem; padding: 0.4375rem 0.875rem; min-height: 2rem; } +.sui-btn--sm { font-size: 0.75rem; padding: 0.3125rem 0.625rem; min-height: 1.625rem; border-radius: var(--radius-sm); } + +.sui-btn--full { width: 100%; justify-content: center; } + +/* gradient (primary) */ +.sui-btn--gradient { + color: #fff; + background: var(--grad-blue-btn); + box-shadow: var(--shadow-blue); +} +.sui-btn--gradient.sui-btn--purple { background: var(--grad-purple-btn); } +.sui-btn--gradient.sui-btn--green { background: var(--grad-green-btn); } +.sui-btn--gradient:hover:not(:disabled) { + box-shadow: var(--shadow-blue-hover); + transform: translateY(-0.0625rem); +} +.sui-btn--gradient:active:not(:disabled) { transform: translateY(0); } + +/* outline (secondary) */ +.sui-btn--outline { + color: var(--color-text-2); + background: var(--color-surface); + border-color: var(--color-border); +} +.sui-btn--outline.sui-btn--blue { color: var(--color-blue); border-color: var(--color-blue-border); } +.sui-btn--outline.sui-btn--purple { color: var(--color-purple); border-color: var(--color-purple-border); } +.sui-btn--outline.sui-btn--green { color: var(--color-green); border-color: var(--color-green-border); } +.sui-btn--outline.sui-btn--amber { color: var(--color-amber); border-color: var(--color-amber-border); } +.sui-btn--outline.sui-btn--red { color: var(--color-red); border-color: var(--color-red-border); } +.sui-btn--outline:hover:not(:disabled) { + background: var(--color-bg-hover); + border-color: var(--color-border-hover); +} + +/* ghost (tertiary) */ +.sui-btn--ghost { + color: var(--color-text-3); + background: transparent; +} +.sui-btn--ghost:hover:not(:disabled) { color: var(--color-text-1); background: var(--color-bg-hover); } + +/* spinner */ +.sui-btn__spinner { + width: 0.875rem; height: 0.875rem; + border-radius: 50%; + border: 2px solid currentColor; + border-right-color: transparent; + animation: spin 0.7s linear infinite; +} +.sui-btn__label { display: inline-block; } diff --git a/stirling-frontend/src/components/Button.stories.tsx b/stirling-frontend/src/components/Button.stories.tsx new file mode 100644 index 0000000000..767349e489 --- /dev/null +++ b/stirling-frontend/src/components/Button.stories.tsx @@ -0,0 +1,45 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Button } from './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/stirling-frontend/src/components/Button.tsx b/stirling-frontend/src/components/Button.tsx new file mode 100644 index 0000000000..227e0c4f46 --- /dev/null +++ b/stirling-frontend/src/components/Button.tsx @@ -0,0 +1,64 @@ +import type { ButtonHTMLAttributes, ReactNode } from 'react'; +import './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/stirling-frontend/src/components/Card.css b/stirling-frontend/src/components/Card.css new file mode 100644 index 0000000000..77aa265e8c --- /dev/null +++ b/stirling-frontend/src/components/Card.css @@ -0,0 +1,35 @@ +.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-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/stirling-frontend/src/components/Card.stories.tsx b/stirling-frontend/src/components/Card.stories.tsx new file mode 100644 index 0000000000..e015232565 --- /dev/null +++ b/stirling-frontend/src/components/Card.stories.tsx @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Card } from './Card'; +import { Button } from './Button'; +import { StatusBadge } from './StatusBadge'; +import { MetricCard } from './MetricCard'; + +const meta: Meta = { + title: 'Primitives/Card', + component: Card, + parameters: { layout: 'padded' }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + +

Set up a PDF pipeline

+

+ A pipeline is an easy way to standardise PDF workflows, processes, and policies. +

+ +
+ ), +}; + +export const Accents: Story = { + render: () => ( +
+ {(['blue', 'purple', 'green', 'amber', 'red'] as const).map((a) => ( + +
+ + {a} + + Healthy +
+

Card title

+

Accent shown as a 0.25rem strip on the left edge.

+
+ ))} +
+ ), +}; + +export const Interactive: Story = { + render: () => ( + alert('open')}> + + + ), +}; diff --git a/stirling-frontend/src/components/Card.tsx b/stirling-frontend/src/components/Card.tsx new file mode 100644 index 0000000000..f1272ea165 --- /dev/null +++ b/stirling-frontend/src/components/Card.tsx @@ -0,0 +1,41 @@ +import type { HTMLAttributes, ReactNode } from 'react'; +import './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. */ + padding?: '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/stirling-frontend/src/components/CodeBlock.css b/stirling-frontend/src/components/CodeBlock.css new file mode 100644 index 0000000000..4ad0f0675a --- /dev/null +++ b/stirling-frontend/src/components/CodeBlock.css @@ -0,0 +1,51 @@ +.sui-code { + background: var(--code-bg); + color: var(--code-text); + border-radius: var(--radius-lg); + overflow: hidden; + border: 1px solid var(--code-border); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); +} +.sui-code__chrome { + display: flex; align-items: center; gap: 0.625rem; + background: var(--code-bg-header); + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--code-border); + font-size: 0.6875rem; + color: var(--code-muted); +} +.sui-code__dots { display: inline-flex; gap: 0.375rem; align-items: center; } +.sui-code__dots > span { + width: 0.625rem; height: 0.625rem; border-radius: 50%; + background: var(--code-dot); +} +.sui-code__caption { color: var(--code-muted); margin-left: 0.25rem; font-family: var(--font-mono); } +.sui-code__lang { + margin-left: auto; + font-family: var(--font-mono); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--code-dim); +} +.sui-code__copy { + background: transparent; + color: var(--code-text); + border: 1px solid var(--code-border); + border-radius: var(--radius-sm); + padding: 0.125rem 0.5rem; + font-size: 0.6875rem; + cursor: pointer; + font-family: var(--font-sans); + transition: background var(--motion-fast); +} +.sui-code__copy:hover { background: rgba(255, 255, 255, 0.05); } +.sui-code__pre { + margin: 0; + padding: 0.875rem 1rem; + font-family: var(--font-mono); + font-size: 0.78125rem; + line-height: 1.55; + overflow: auto; + white-space: pre; +} +.sui-code__pre code { white-space: inherit; color: inherit; font-family: inherit; } diff --git a/stirling-frontend/src/components/CodeBlock.stories.tsx b/stirling-frontend/src/components/CodeBlock.stories.tsx new file mode 100644 index 0000000000..77257ac188 --- /dev/null +++ b/stirling-frontend/src/components/CodeBlock.stories.tsx @@ -0,0 +1,55 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { CodeBlock } from './CodeBlock'; + +const meta: Meta = { + title: 'Primitives/CodeBlock', + component: CodeBlock, + parameters: { layout: 'padded' }, +}; +export default meta; +type Story = StoryObj; + +export const Curl: Story = { + args: { + lang: 'curl', + code: `curl https://api.stirling.com/v1/coi \\ + -H "Authorization: Bearer sk_live_a3f8..." \\ + -F "file=@certificate.pdf"`, + }, +}; + +export const JsonResponse: Story = { + args: { + lang: 'json', + code: `{ + "holder": "Umbrella Corp.", + "insurer": "Hartford", + "policy_number": "POL-2026-0142", + "general_aggregate": 2000000, + "expiry_date": "2026-12-31", + "additional_insured": true +}`, + }, +}; + +export const Python: Story = { + args: { + lang: 'python', + code: `from stirling import Stirling + +s = Stirling(api_key="sk_live_a3f8...") +result = s.coi.extract(file="certificate.pdf") +print(result.holder, result.expiry_date)`, + }, +}; + +export const WithCaption: Story = { + args: { + lang: 'typescript', + caption: 'src/pipelines/coi.ts', + code: `import { Stirling } from '@stirling/sdk'; + +const stirling = new Stirling({ apiKey: process.env.STIRLING_KEY! }); +const result = await stirling.coi.extract({ file });`, + }, +}; diff --git a/stirling-frontend/src/components/CodeBlock.tsx b/stirling-frontend/src/components/CodeBlock.tsx new file mode 100644 index 0000000000..11b232ca3f --- /dev/null +++ b/stirling-frontend/src/components/CodeBlock.tsx @@ -0,0 +1,75 @@ +import { useState, type ReactNode } from 'react'; +import './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/stirling-frontend/src/components/MethodBadge.css b/stirling-frontend/src/components/MethodBadge.css new file mode 100644 index 0000000000..870bcc3b3d --- /dev/null +++ b/stirling-frontend/src/components/MethodBadge.css @@ -0,0 +1,16 @@ +.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/stirling-frontend/src/components/MethodBadge.stories.tsx b/stirling-frontend/src/components/MethodBadge.stories.tsx new file mode 100644 index 0000000000..85f689c13e --- /dev/null +++ b/stirling-frontend/src/components/MethodBadge.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { MethodBadge, type HttpMethod } from './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/stirling-frontend/src/components/MethodBadge.tsx b/stirling-frontend/src/components/MethodBadge.tsx new file mode 100644 index 0000000000..c3c6a99eed --- /dev/null +++ b/stirling-frontend/src/components/MethodBadge.tsx @@ -0,0 +1,20 @@ +import './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/stirling-frontend/src/components/MetricCard.css b/stirling-frontend/src/components/MetricCard.css new file mode 100644 index 0000000000..ad21dbf5a3 --- /dev/null +++ b/stirling-frontend/src/components/MetricCard.css @@ -0,0 +1,41 @@ +.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; +} +.sui-metric--primary { background: var(--color-bg-subtle); } +.sui-metric--interactive { cursor: pointer; } +.sui-metric--interactive:hover { + border-color: var(--color-border-hover); + box-shadow: var(--shadow-lg); + transform: translateY(-0.0625rem); +} +.sui-metric--interactive:focus-visible { + outline: 2px solid var(--color-blue); + outline-offset: 2px; +} + +.sui-metric__header { + display: flex; align-items: center; justify-content: space-between; + gap: 0.5rem; +} +.sui-metric__label { font-size: 0.75rem; color: var(--color-text-4); font-weight: 500; letter-spacing: 0.01em; } +.sui-metric__icon { color: var(--color-text-5); display: inline-flex; } +.sui-metric__value { font-size: 1.625rem; font-weight: 600; color: var(--color-text-1); line-height: 1.1; } +.sui-metric__footer { + display: flex; align-items: center; gap: 0.5rem; + font-size: 0.75rem; +} +.sui-metric__delta { display: inline-flex; align-items: center; gap: 0.1875rem; font-weight: 500; } +.sui-metric__delta-arrow { font-size: 0.6875rem; } +.sui-metric__delta--up { color: var(--color-green); } +.sui-metric__delta--down { color: var(--color-red); } +.sui-metric__delta--flat { color: var(--color-text-5); } +.sui-metric__desc { color: var(--color-text-4); } diff --git a/stirling-frontend/src/components/MetricCard.stories.tsx b/stirling-frontend/src/components/MetricCard.stories.tsx new file mode 100644 index 0000000000..f9145d8bc5 --- /dev/null +++ b/stirling-frontend/src/components/MetricCard.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { MetricCard } from './MetricCard'; + +const meta: Meta = { + title: 'Primitives/MetricCard', + component: MetricCard, + parameters: { layout: 'centered' }, + args: { label: 'Docs processed', value: '14,238' }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithDelta: Story = { + args: { label: 'Docs processed (24h)', value: '14,238', delta: 0.12, description: 'vs previous 24h' }, +}; + +export const Decrease: Story = { + args: { label: 'Error rate', value: '0.42%', delta: -0.18, description: 'lower is better' }, +}; + +export const KpiStrip: Story = { + render: () => ( +
+ + + + +
+ ), +}; + +export const Interactive: Story = { + args: { + label: 'Sources', + value: 12, + description: 'Click to view all', + onClick: () => alert('navigate to /sources'), + }, +}; diff --git a/stirling-frontend/src/components/MetricCard.tsx b/stirling-frontend/src/components/MetricCard.tsx new file mode 100644 index 0000000000..16642157e0 --- /dev/null +++ b/stirling-frontend/src/components/MetricCard.tsx @@ -0,0 +1,83 @@ +import type { ReactNode } from 'react'; +import './MetricCard.css'; + +export type DeltaDirection = 'up' | 'down' | 'flat'; + +export interface MetricCardProps { + label: string; + value: string | number; + /** Optional description shown under the delta line. */ + description?: string; + /** Numeric delta as a fraction (0.12 = +12%). The sign drives direction unless `deltaDirection` is set. */ + delta?: number; + /** Override the inferred direction — useful when you only want the colour, not the value. */ + deltaDirection?: DeltaDirection; + /** Visual emphasis. `primary` = darker surface, used for hero metrics. */ + emphasis?: 'default' | 'primary'; + /** Optional icon shown in the top-right corner. */ + icon?: ReactNode; + onClick?: () => void; + className?: string; +} + +function inferDirection(delta?: number): DeltaDirection { + if (delta === undefined || delta === 0) return 'flat'; + return delta > 0 ? 'up' : 'down'; +} + +function formatDelta(delta: number) { + const pct = Math.round(Math.abs(delta) * 100); + return `${pct}%`; +} + +/** + * Stirling's standard KPI card. Used on Home, Sources, Documents, Audit and + * every Infrastructure tab — the prototype calls these the metric strip. + */ +export function MetricCard({ + label, + value, + description, + delta, + deltaDirection, + emphasis = 'default', + icon, + onClick, + className, +}: MetricCardProps) { + const dir = deltaDirection ?? inferDirection(delta); + const interactive = !!onClick; + return ( +
{ if (e.key === 'Enter' || e.key === ' ') onClick!(); } : undefined} + > +
+ {label} + {icon && {icon}} +
+
{value}
+ {(delta !== undefined || description) && ( +
+ {delta !== undefined && ( + + + {dir === 'up' ? '↑' : dir === 'down' ? '↓' : '·'} + + {formatDelta(delta)} + + )} + {description && {description}} +
+ )} +
+ ); +} diff --git a/stirling-frontend/src/components/NavItem.css b/stirling-frontend/src/components/NavItem.css new file mode 100644 index 0000000000..e3c6c24a37 --- /dev/null +++ b/stirling-frontend/src/components/NavItem.css @@ -0,0 +1,28 @@ +.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/stirling-frontend/src/components/NavItem.stories.tsx b/stirling-frontend/src/components/NavItem.stories.tsx new file mode 100644 index 0000000000..740d11a0fb --- /dev/null +++ b/stirling-frontend/src/components/NavItem.stories.tsx @@ -0,0 +1,59 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { NavItem } from './NavItem'; +import { SectionDivider } from './SectionDivider'; + +const HomeIcon = () => ( + + + +); + +const meta: Meta = { + title: 'Primitives/NavItem', + component: NavItem, + parameters: { layout: 'padded' }, +}; +export default meta; +type Story = StoryObj; + +export const Active: Story = { + args: { id: 'home', label: 'Home', icon: , isActive: true }, +}; + +export const Inactive: Story = { + args: { id: 'editor', label: 'Editor', icon: }, +}; + +export const SidebarMockup: Story = { + render: () => { + const [active, setActive] = useState('pipelines'); + const items = [ + { id: 'home', label: 'Home' }, + 'divider', + { id: 'editor', label: 'Editor' }, + { id: 'sources', label: 'Sources' }, + { id: 'pipelines', label: 'Pipelines' }, + { id: 'documents', label: 'Documents' }, + 'divider', + { id: 'infra', label: 'Infrastructure' }, + { id: 'usage', label: 'Usage & Billing' }, + { id: 'docs', label: 'Developer Docs' }, + ] as const; + return ( +
+ {items.map((item, idx) => + item === 'divider' + ? + : } isActive={active === item.id} onClick={setActive} /> + )} +
+ ); + }, +}; diff --git a/stirling-frontend/src/components/NavItem.tsx b/stirling-frontend/src/components/NavItem.tsx new file mode 100644 index 0000000000..ebf02d3e48 --- /dev/null +++ b/stirling-frontend/src/components/NavItem.tsx @@ -0,0 +1,37 @@ +import type { ReactNode } from 'react'; +import './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/stirling-frontend/src/components/PanelHeader.css b/stirling-frontend/src/components/PanelHeader.css new file mode 100644 index 0000000000..faa692bcbf --- /dev/null +++ b/stirling-frontend/src/components/PanelHeader.css @@ -0,0 +1,19 @@ +.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/stirling-frontend/src/components/PanelHeader.stories.tsx b/stirling-frontend/src/components/PanelHeader.stories.tsx new file mode 100644 index 0000000000..b8e1d787cc --- /dev/null +++ b/stirling-frontend/src/components/PanelHeader.stories.tsx @@ -0,0 +1,38 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { PanelHeader } from './PanelHeader'; +import { Button } from './Button'; +import { StatusBadge } from './StatusBadge'; + +const meta: Meta = { + title: 'Primitives/PanelHeader', + component: PanelHeader, + parameters: { layout: 'padded' }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { title: 'Pipelines' }, +}; + +export const WithSubtitleAndBack: Story = { + args: { + title: 'COI compliance pipeline', + subtitle: 'Forked from standard · Insurance · Draft', + onBack: () => {}, + }, +}; + +export const WithActions: Story = { + args: { + title: 'API keys', + subtitle: '12 active · 1 due for rotation', + actions: ( + <> + Healthy + + + + ), + }, +}; diff --git a/stirling-frontend/src/components/PanelHeader.tsx b/stirling-frontend/src/components/PanelHeader.tsx new file mode 100644 index 0000000000..22fcd1cdc0 --- /dev/null +++ b/stirling-frontend/src/components/PanelHeader.tsx @@ -0,0 +1,38 @@ +import type { ReactNode } from 'react'; +import './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/stirling-frontend/src/components/ProgressBar.css b/stirling-frontend/src/components/ProgressBar.css new file mode 100644 index 0000000000..aa9f34685a --- /dev/null +++ b/stirling-frontend/src/components/ProgressBar.css @@ -0,0 +1,11 @@ +.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/stirling-frontend/src/components/ProgressBar.stories.tsx b/stirling-frontend/src/components/ProgressBar.stories.tsx new file mode 100644 index 0000000000..5418e76a4a --- /dev/null +++ b/stirling-frontend/src/components/ProgressBar.stories.tsx @@ -0,0 +1,52 @@ +import type { CSSProperties, ComponentProps } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { ProgressBar } from './ProgressBar'; + +const meta: Meta = { + title: 'Primitives/ProgressBar', + component: ProgressBar, + parameters: { layout: 'padded' }, + args: { value: 0.45 }, + argTypes: { + value: { control: { type: 'range', min: 0, max: 1, step: 0.01 } }, + }, +}; +export default meta; +type Story = StoryObj; + +const sleeve: CSSProperties = { width: 320, display: 'flex', flexDirection: 'column', gap: 8 }; + +export const Default: Story = { + render: (args: ComponentProps) => ( +
+ +
+ ), +}; + +export const Thresholded: Story = { + render: () => ( +
+
+ + +
+
+ + +
+
+ + +
+
+ ), +}; + +export const Taller: Story = { + render: () => ( +
+ +
+ ), +}; diff --git a/stirling-frontend/src/components/ProgressBar.tsx b/stirling-frontend/src/components/ProgressBar.tsx new file mode 100644 index 0000000000..2f7443de46 --- /dev/null +++ b/stirling-frontend/src/components/ProgressBar.tsx @@ -0,0 +1,62 @@ +import './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) { + if (thresholded) { + fill = v >= 0.96 + ? 'linear-gradient(90deg, var(--color-red), #F87171)' + : v >= 0.80 + ? 'linear-gradient(90deg, var(--color-amber), #FBBF24)' + : 'linear-gradient(90deg, var(--color-blue), #60A5FA)'; + } else { + fill = 'linear-gradient(90deg, var(--color-blue), #60A5FA)'; + } + } + return ( +
+
+
+ ); +} diff --git a/stirling-frontend/src/components/SectionDivider.css b/stirling-frontend/src/components/SectionDivider.css new file mode 100644 index 0000000000..8999f6f0a3 --- /dev/null +++ b/stirling-frontend/src/components/SectionDivider.css @@ -0,0 +1,5 @@ +.sui-divider { + height: 0.0625rem; + background: var(--color-sidebar-divider); + width: 100%; +} diff --git a/stirling-frontend/src/components/SectionDivider.tsx b/stirling-frontend/src/components/SectionDivider.tsx new file mode 100644 index 0000000000..02bb454a8c --- /dev/null +++ b/stirling-frontend/src/components/SectionDivider.tsx @@ -0,0 +1,22 @@ +import './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/stirling-frontend/src/components/StatusBadge.css b/stirling-frontend/src/components/StatusBadge.css new file mode 100644 index 0000000000..40ebba9141 --- /dev/null +++ b/stirling-frontend/src/components/StatusBadge.css @@ -0,0 +1,35 @@ +.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/stirling-frontend/src/components/StatusBadge.stories.tsx b/stirling-frontend/src/components/StatusBadge.stories.tsx new file mode 100644 index 0000000000..ebddfc8145 --- /dev/null +++ b/stirling-frontend/src/components/StatusBadge.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { StatusBadge } from './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/stirling-frontend/src/components/StatusBadge.tsx b/stirling-frontend/src/components/StatusBadge.tsx new file mode 100644 index 0000000000..25de132801 --- /dev/null +++ b/stirling-frontend/src/components/StatusBadge.tsx @@ -0,0 +1,54 @@ +import type { ReactNode } from 'react'; +import './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/stirling-frontend/src/components/ToggleSwitch.css b/stirling-frontend/src/components/ToggleSwitch.css new file mode 100644 index 0000000000..c58328e79c --- /dev/null +++ b/stirling-frontend/src/components/ToggleSwitch.css @@ -0,0 +1,44 @@ +.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/stirling-frontend/src/components/ToggleSwitch.stories.tsx b/stirling-frontend/src/components/ToggleSwitch.stories.tsx new file mode 100644 index 0000000000..d759064374 --- /dev/null +++ b/stirling-frontend/src/components/ToggleSwitch.stories.tsx @@ -0,0 +1,43 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { ToggleSwitch } from './ToggleSwitch'; + +const meta: Meta = { + title: 'Primitives/ToggleSwitch', + component: ToggleSwitch, + parameters: { layout: 'centered' }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => { + const [on, setOn] = useState(false); + return ; + }, +}; + +export const WithLabel: Story = { + render: () => { + const [on, setOn] = useState(true); + return ( + + ); + }, +}; + +export const Disabled: Story = { + render: () => {}} label="HYOK active" description="Enterprise only" />, +}; + +export const SizeSmall: Story = { + render: () => { + const [on, setOn] = useState(true); + return ; + }, +}; diff --git a/stirling-frontend/src/components/ToggleSwitch.tsx b/stirling-frontend/src/components/ToggleSwitch.tsx new file mode 100644 index 0000000000..bd9d923b4d --- /dev/null +++ b/stirling-frontend/src/components/ToggleSwitch.tsx @@ -0,0 +1,57 @@ +import { useId } from 'react'; +import './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/stirling-frontend/src/components/index.ts b/stirling-frontend/src/components/index.ts new file mode 100644 index 0000000000..7945dde4d7 --- /dev/null +++ b/stirling-frontend/src/components/index.ts @@ -0,0 +1,11 @@ +export * from './Button'; +export * from './StatusBadge'; +export * from './MethodBadge'; +export * from './ToggleSwitch'; +export * from './ProgressBar'; +export * from './MetricCard'; +export * from './NavItem'; +export * from './PanelHeader'; +export * from './CodeBlock'; +export * from './SectionDivider'; +export * from './Card'; diff --git a/stirling-frontend/src/data/Endpoints.stories.tsx b/stirling-frontend/src/data/Endpoints.stories.tsx new file mode 100644 index 0000000000..cbabc9855b --- /dev/null +++ b/stirling-frontend/src/data/Endpoints.stories.tsx @@ -0,0 +1,62 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { VERTICALS, ALL_ENDPOINTS } from './endpoints'; +import { MethodBadge } from '../components/MethodBadge'; +import { StatusBadge } from '../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/stirling-frontend/src/data/Ops.stories.tsx b/stirling-frontend/src/data/Ops.stories.tsx new file mode 100644 index 0000000000..3cbfa7ca86 --- /dev/null +++ b/stirling-frontend/src/data/Ops.stories.tsx @@ -0,0 +1,148 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { PIPELINE_OPS, LIBRARY_OPS, OP_CATEGORIES, PIPELINE_AGENTS, SOURCE_OPTIONS, DESTINATION_OPTIONS, type OpKind } from './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/stirling-frontend/src/data/endpoints.ts b/stirling-frontend/src/data/endpoints.ts new file mode 100644 index 0000000000..8d40f35db9 --- /dev/null +++ b/stirling-frontend/src/data/endpoints.ts @@ -0,0 +1,395 @@ +/** + * 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 '../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/stirling-frontend/src/data/ops.ts b/stirling-frontend/src/data/ops.ts new file mode 100644 index 0000000000..6a7d77db1c --- /dev/null +++ b/stirling-frontend/src/data/ops.ts @@ -0,0 +1,349 @@ +/** + * 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 { + 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: '#6366F1', blurb: 'Inspection, AI review, certification' }, + { name: 'Signing', color: 'var(--color-green)', blurb: 'Digital signatures, cert sign / remove' }, + { name: 'Page Formatting', color: '#F97316', blurb: 'Rotate, crop, layout, page-level edits' }, + { name: 'Extraction', color: '#14B8A6', blurb: 'OCR, format conversion, content pull' }, + { name: 'Removal', color: 'var(--color-amber)', blurb: 'Drop pages, blanks, images, partition splits' }, + { name: 'Automation', color: '#EC4899', blurb: 'AI-driven generation, translation, forms' }, + { name: 'Advanced Formatting', color: '#8B5CF6', 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/stirling-frontend/src/main.tsx b/stirling-frontend/src/main.tsx new file mode 100644 index 0000000000..f616435722 --- /dev/null +++ b/stirling-frontend/src/main.tsx @@ -0,0 +1,15 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { App } from './App'; + +import './tokens/tokens.css'; +import './tokens/base.css'; + +const root = document.getElementById('root'); +if (!root) throw new Error('No #root element'); + +createRoot(root).render( + + + , +); diff --git a/stirling-frontend/src/tokens/Tokens.stories.tsx b/stirling-frontend/src/tokens/Tokens.stories.tsx new file mode 100644 index 0000000000..282049b25b --- /dev/null +++ b/stirling-frontend/src/tokens/Tokens.stories.tsx @@ -0,0 +1,115 @@ +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/stirling-frontend/src/tokens/base.css b/stirling-frontend/src/tokens/base.css new file mode 100644 index 0000000000..6df3c68147 --- /dev/null +++ b/stirling-frontend/src/tokens/base.css @@ -0,0 +1,26 @@ +/* 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/stirling-frontend/src/tokens/tokens.css b/stirling-frontend/src/tokens/tokens.css new file mode 100644 index 0000000000..2eb3bcd460 --- /dev/null +++ b/stirling-frontend/src/tokens/tokens.css @@ -0,0 +1,281 @@ +/* + * Stirling design tokens — CSS custom properties. + * + * Mirrors src/tokens/tokens.ts so components can reference tokens via + * var(--color-...) without importing the JS module. Theme switching flips + * `data-theme="dark"` on and these variables cascade. + * + * 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; + --color-text-5: #94a3b8; + --color-text-6: #6b7280; + --color-text-muted: #8b92a1; + --color-text-placeholder: #9ca3af; + + /* 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-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-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; + + /* 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-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; + --color-text-5: #475569; + --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-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-amber: #fbbf24; + --color-amber-light: #2d2006; + --color-amber-border: #78350f; + --color-amber-dark: #fbbf24; + + --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; +} + +/* Radii / typography / motion — 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); +} + +/* 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; + } +} diff --git a/stirling-frontend/src/tokens/tokens.ts b/stirling-frontend/src/tokens/tokens.ts new file mode 100644 index 0000000000..0e0fd71423 --- /dev/null +++ b/stirling-frontend/src/tokens/tokens.ts @@ -0,0 +1,264 @@ +/** + * Stirling design tokens. + * + * Two parallel palettes (light + dark) plus a never-theme-switched CODE palette + * for syntax-highlighted blocks. Category accents are intentionally identical + * across both themes — the document types they represent are stable concepts, + * so their colour is part of their identity rather than a UI affordance. + * + * These TS constants are the source of truth; tokens.css mirrors them as CSS + * custom properties for runtime theme switching. Keep them in sync (run + * `pnpm gen:tokens` if/when a generator script lands). + */ + +export type ColorMode = 'light' | 'dark'; +export type Tier = 'free' | 'pro' | 'enterprise'; + +export interface ColorPalette { + // Text hierarchy + text1: string; + text2: string; + text3: string; + text4: string; + text5: string; + text6: string; + textMuted: string; + textPlaceholder: string; + + // Brand / status + blue: string; blueDark: string; blueLight: string; blueBorder: string; + purple: string; purpleLight: string; purpleBorder: string; + green: string; greenLight: string; greenBorder: string; greenDark: string; + red: string; redLight: string; redBorder: string; + amber: string; amberLight: string; amberBorder: string; amberDark: string; + + // Document category accents + insurance: string; + compliance: string; + finance: string; + legal: string; + healthcare: string; + government: string; + operations: string; + hr: string; + realestate: string; + energy: string; + + // Surface / border + bg: string; bgAlt: string; + surface: string; surfaceAlt: string; + border: string; borderLight: string; + borderInput: string; borderHover: string; + divider: string; + bgSubtle: string; bgHover: string; bgMuted: string; bgCode: string; + + // Code / tooltips / dropdowns + codeBg: string; codeText: string; + dropdownBg: string; dropdownBorder: string; dropdownHover: string; + tooltipBg: string; tooltipText: string; + + // Navigation + navActive: string; navActiveText: string; + navText: string; navHover: string; navHoverText: string; + sectionLabel: string; logoText: string; + + // Header + headerBg: string; headerBorder: string; headerText: string; + searchBg: string; searchBorder: string; searchBorderHover: string; searchText: string; + + // Badges / notifications / tier switcher / bell + badgeBg: string; badgeBorder: string; badgeRed: string; + notifBg: string; notifBorder: string; + notifItemBorder: string; notifItemHover: string; + notifTitle: string; notifDesc: string; notifTime: string; + tierBtnBg: string; tierBtnBorder: string; tierBtnText: string; tierBtnHover: string; + bellHover: string; bellStroke: string; + + // Sidebar + sidebarBg: string; sidebarBorder: string; sidebarDivider: string; + usageText: string; usageValue: string; usageTrack: string; + + toggleOff: string; +} + +export const COLORS_LIGHT: ColorPalette = { + text1: '#0f172a', text2: '#1a202c', text3: '#475569', text4: '#64748b', + text5: '#94a3b8', text6: '#6b7280', textMuted: '#8b92a1', textPlaceholder: '#9ca3af', + + blue: '#3B82F6', blueDark: '#2563eb', blueLight: '#eff6ff', blueBorder: '#bfdbfe', + purple: '#8B5CF6', purpleLight: '#f5f3ff', purpleBorder: '#ddd6fe', + green: '#10b981', greenLight: '#ecfdf5', greenBorder: '#a7f3d0', greenDark: '#16a34a', + red: '#ef4444', redLight: '#fee2e2', redBorder: '#fecaca', + amber: '#f59e0b', amberLight: '#fef3c7', amberBorder: '#fde68a', amberDark: '#92400e', + + insurance: '#0ea5e9', compliance: '#6366f1', finance: '#10b981', legal: '#3B82F6', + healthcare: '#8B5CF6', government: '#dc2626', operations: '#ec4899', hr: '#f59e0b', + realestate: '#84cc16', energy: '#f97316', + + bg: '#f8f9fb', bgAlt: '#f6f8fa', + surface: '#ffffff', surfaceAlt: '#ffffff', + border: '#e3e8ee', borderLight: '#eef0f2', + borderInput: '#e2e8f0', borderHover: '#cbd5e1', + divider: '#f0f0f0', + bgSubtle: '#f9fafb', bgHover: '#f8fafc', bgMuted: '#f3f4f6', bgCode: '#f1f5f9', + + codeBg: '#f1f5f9', codeText: '#0f172a', + dropdownBg: '#ffffff', dropdownBorder: '#e5e7eb', dropdownHover: '#f9fafb', + tooltipBg: '#1e293b', tooltipText: '#f8fafc', + + navActive: '#eff6ff', navActiveText: '#3B82F6', + navText: '#4b5563', navHover: '#f6f8fa', navHoverText: '#1a202c', + sectionLabel: '#9ca3af', logoText: '#111827', + + headerBg: '#ffffff', headerBorder: '#f0f0f0', headerText: '#111827', + searchBg: '#f9fafb', searchBorder: '#e5e7eb', searchBorderHover: '#d1d5db', searchText: '#9ca3af', + + badgeBg: '#ffffff', badgeBorder: '#e5e7eb', badgeRed: '#ef4444', + notifBg: '#ffffff', notifBorder: '#e5e7eb', + notifItemBorder: '#f9fafb', notifItemHover: '#fafbfc', + notifTitle: '#111827', notifDesc: '#6b7280', notifTime: '#9ca3af', + tierBtnBg: '#f9fafb', tierBtnBorder: '#e5e7eb', tierBtnText: '#374151', tierBtnHover: '#f3f4f6', + bellHover: '#f3f4f6', bellStroke: '#374151', + + sidebarBg: '#ffffff', sidebarBorder: '#e5e7eb', sidebarDivider: '#f0f0f0', + usageText: '#6b7280', usageValue: '#374151', usageTrack: '#f3f4f6', + + toggleOff: '#cbd5e1', +}; + +export const COLORS_DARK: ColorPalette = { + text1: '#f1f5f9', text2: '#e2e8f0', text3: '#94a3b8', text4: '#64748b', + text5: '#475569', text6: '#94a3b8', textMuted: '#64748b', textPlaceholder: '#475569', + + blue: '#60a5fa', blueDark: '#3b82f6', blueLight: '#1e293b', blueBorder: '#1e3a5f', + purple: '#a78bfa', purpleLight: '#1e1b2e', purpleBorder: '#2e2650', + green: '#34d399', greenLight: '#0d2818', greenBorder: '#065f46', greenDark: '#22c55e', + red: '#f87171', redLight: '#2d1215', redBorder: '#7f1d1d', + amber: '#fbbf24', amberLight: '#2d2006', amberBorder: '#78350f', amberDark: '#fbbf24', + + // Category accents — intentionally identical across themes; document + // categories are stable concepts, not mode-aware UI. + insurance: '#0ea5e9', compliance: '#6366f1', finance: '#10b981', legal: '#3B82F6', + healthcare: '#8B5CF6', government: '#dc2626', operations: '#ec4899', hr: '#f59e0b', + realestate: '#84cc16', energy: '#f97316', + + bg: '#090c14', bgAlt: '#0d1120', + surface: '#151c2e', surfaceAlt: '#1c2640', + border: '#283248', borderLight: '#1e2840', + borderInput: '#2e3c55', borderHover: '#3d4f6a', + divider: '#1e2840', + bgSubtle: '#111827', bgHover: '#1c2640', bgMuted: '#243044', bgCode: '#0b0f1a', + + codeBg: '#0b0f1a', codeText: '#e2e8f0', + dropdownBg: '#151c2e', dropdownBorder: '#2e3c55', dropdownHover: '#1c2640', + tooltipBg: '#e2e8f0', tooltipText: '#0f172a', + + navActive: '#172044', navActiveText: '#60a5fa', + navText: '#94a3b8', navHover: '#1c2640', navHoverText: '#e2e8f0', + sectionLabel: '#475569', logoText: '#f1f5f9', + + headerBg: '#0d1120', headerBorder: '#1e2840', headerText: '#f1f5f9', + searchBg: '#151c2e', searchBorder: '#2e3c55', searchBorderHover: '#3d4f6a', searchText: '#64748b', + + badgeBg: '#151c2e', badgeBorder: '#2e3c55', badgeRed: '#ef4444', + notifBg: '#151c2e', notifBorder: '#2e3c55', + notifItemBorder: '#1e2840', notifItemHover: '#1c2640', + notifTitle: '#f1f5f9', notifDesc: '#94a3b8', notifTime: '#64748b', + tierBtnBg: '#151c2e', tierBtnBorder: '#2e3c55', tierBtnText: '#94a3b8', tierBtnHover: '#1c2640', + bellHover: '#1c2640', bellStroke: '#94a3b8', + + sidebarBg: '#0d1120', sidebarBorder: '#1e2840', sidebarDivider: '#1e2840', + usageText: '#64748b', usageValue: '#94a3b8', usageTrack: '#1e2840', + + toggleOff: '#3d4f6a', +}; + +/** + * Code-block palette. Stays dark in both themes, like Stripe / Vercel. + * Use these tokens for syntax-highlighted blocks, terminal output, and + * inline code samples — anything where the reader expects "code aesthetics". + */ +export const CODE = { + bg: '#0f172a', + bgAlt: '#1e293b', + bgHeader: '#1a2332', + text: '#e2e8f0', + dim: '#64748b', + muted: '#94a3b8', + keyword: '#60a5fa', + string: '#fbbf24', + number: '#c084fc', + fn: '#34d399', + type: '#f472b6', + property: '#93c5fd', + comment: '#475569', + dot: '#475569', + border: '#1e293b', +} as const; + +/** Gradients shared across both modes (subtle tints). */ +export const GRADIENTS = { + blueBtnLight: 'linear-gradient(180deg, #5B9BF7 0%, #4C8BF5 50%, #3A7BE8 100%)', + blueBtnDark: 'linear-gradient(180deg, #3B82F6 0%, #2563EB 50%, #1D4ED8 100%)', + purpleBtn: 'linear-gradient(180deg, #a78bfa 0%, #8B5CF6 50%, #7c3aed 100%)', + greenBtn: 'linear-gradient(180deg, #34d399 0%, #10b981 50%, #059669 100%)', + bannerLight: 'linear-gradient(135deg, #eef2ff 0%, #ddd6fe 50%, #fce7f3 100%)', + bannerDark: 'linear-gradient(135deg, #0f172a 0%, #111827 50%, #1a1535 100%)', +} as const; + +export const SHADOWS = { + light: { + sm: 'inset 0 0 0 0.0625rem #e3e8ee', + md: 'inset 0 0 0 0.0625rem #e3e8ee, 0 0.0625rem 0.125rem rgba(15,23,42,0.04)', + lg: 'inset 0 0 0 0.0625rem #e3e8ee, 0 0.25rem 0.75rem rgba(15,23,42,0.06)', + blue: '0 0.0625rem 0.125rem rgba(59,130,246,0.25), inset 0 0.0625rem 0 rgba(255,255,255,0.2)', + blueHover: '0 0.125rem 0.375rem rgba(59,130,246,0.3), inset 0 0.0625rem 0 rgba(255,255,255,0.25)', + }, + dark: { + sm: 'inset 0 0 0 0.0625rem #283248', + md: 'inset 0 0 0 0.0625rem #283248, 0 0.0625rem 0.1875rem rgba(0,0,0,0.3)', + lg: 'inset 0 0 0 0.0625rem #283248, 0 0.5rem 1.5rem rgba(0,0,0,0.4)', + blue: '0 0.0625rem 0.1875rem rgba(59,130,246,0.35), inset 0 0.0625rem 0 rgba(255,255,255,0.1)', + blueHover: '0 0.125rem 0.5rem rgba(59,130,246,0.45), inset 0 0.0625rem 0 rgba(255,255,255,0.15)', + }, +} as const; + +/** Radii (px). */ +export const RADII = { + xs: 3, + sm: 4, + md: 6, + lg: 8, + xl: 12, + full: '50%' as const, + pill: 10, +} as const; + +/** Typography. */ +export const TYPE = { + fontSans: '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif', + fontMono: '"SF Mono", "Fira Code", Menlo, Consolas, "DejaVu Sans Mono", monospace', + fontBrand: '"Alumni Sans", "Inter", sans-serif', +} as const; + +/** Motion. */ +export const MOTION = { + fast: '0.15s ease', + base: '0.2s cubic-bezier(0.4, 0, 0.2, 1)', + slow: '0.3s ease', + // Component-level easings + enter: '0.22s cubic-bezier(0.4, 0, 0.2, 1)', + exit: '0.18s cubic-bezier(0.4, 0, 0.2, 1)', +} as const; + +/** Returns the resolved palette for a given mode. */ +export function palette(mode: ColorMode): ColorPalette { + return mode === 'dark' ? COLORS_DARK : COLORS_LIGHT; +} + +/** Tier visual mapping — used by the tier switcher and sidebar plan indicator. */ +export const TIER_INFO: Record = { + free: { label: 'Free Plan', dotColor: COLORS_LIGHT.green, chipColor: COLORS_LIGHT.greenLight }, + pro: { label: 'Pay-as-you-go', dotColor: COLORS_LIGHT.blue, chipColor: COLORS_LIGHT.blueLight }, + enterprise: { label: 'Enterprise Plan', dotColor: COLORS_LIGHT.purple, chipColor: COLORS_LIGHT.purpleLight }, +}; diff --git a/stirling-frontend/tsconfig.json b/stirling-frontend/tsconfig.json new file mode 100644 index 0000000000..6396cbef74 --- /dev/null +++ b/stirling-frontend/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "useDefineForClassFields": true, + "esModuleInterop": true, + "isolatedModules": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "allowImportingTsExtensions": false, + "verbatimModuleSyntax": false, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src", ".storybook"], + "exclude": ["node_modules", "dist", "storybook-static"] +} diff --git a/stirling-frontend/vite.config.ts b/stirling-frontend/vite.config.ts new file mode 100644 index 0000000000..0a1d9b2cc5 --- /dev/null +++ b/stirling-frontend/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'node:path'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +});