diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml
index 2de96b7..1953f84 100644
--- a/.github/workflows/backend-ci.yml
+++ b/.github/workflows/backend-ci.yml
@@ -21,14 +21,28 @@ jobs:
matrix:
include:
- service: ner
+ path: backend/ner
needs_shared: false
- service: nel
+ path: backend/nel
needs_shared: true
- service: classify
+ path: backend/classify
needs_shared: false
- service: nel_v2
+ path: backend/nel_v2
needs_shared: true
- service: classify_v2
+ path: backend/classify_v2
+ needs_shared: false
+ - service: tabiya_plugin_contracts
+ path: backend/tabiya_plugin_contracts
+ needs_shared: false
+ - service: tabiya_core
+ path: backend/plugin_bundles/tabiya_core
+ needs_shared: false
+ - service: tabiya_io
+ path: backend/plugin_bundles/tabiya_io
needs_shared: false
steps:
- uses: actions/checkout@v4
@@ -48,11 +62,11 @@ jobs:
- name: Install dependencies
run: poetry install --no-interaction ${{ matrix.service == 'nel_v2' && '--extras inference' || '' }}
- working-directory: backend/${{ matrix.service }}
+ working-directory: ${{ matrix.path }}
- name: Run tests
run: poetry run pytest
- working-directory: backend/${{ matrix.service }}
+ working-directory: ${{ matrix.path }}
build-and-push:
name: Build & push ${{ matrix.service }}
@@ -62,7 +76,21 @@ jobs:
environment: ${{ inputs.stack }}
strategy:
matrix:
- service: [ner, nel, classify, nel_v2, classify_v2]
+ include:
+ - service: ner
+ dockerfile: backend/ner/Dockerfile
+ - service: nel
+ dockerfile: backend/nel/Dockerfile
+ - service: classify
+ dockerfile: backend/classify/Dockerfile
+ - service: nel_v2
+ dockerfile: backend/nel_v2/Dockerfile
+ - service: classify_v2
+ dockerfile: backend/classify_v2/Dockerfile
+ - service: tabiya_core
+ dockerfile: backend/plugin_bundles/tabiya_core/Dockerfile
+ - service: tabiya_io
+ dockerfile: backend/plugin_bundles/tabiya_io/Dockerfile
permissions:
contents: read
id-token: write
@@ -85,7 +113,7 @@ jobs:
REGISTRY="${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/tabiya-classifier"
echo "${{ secrets.HF_TOKEN }}" > /tmp/hf_token
docker build \
- -f backend/${{ matrix.service }}/Dockerfile \
+ -f ${{ matrix.dockerfile }} \
--secret id=HF_TOKEN,src=/tmp/hf_token \
-t ${REGISTRY}/${{ matrix.service }}:${{ github.sha }} \
-t ${REGISTRY}/${{ matrix.service }}:latest \
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 45057b0..289b0d9 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -86,7 +86,9 @@ jobs:
--nel-image "${REGISTRY}/nel:${{ github.sha }}" \
--classify-image "${REGISTRY}/classify:${{ github.sha }}" \
--nel-v2-image "${REGISTRY}/nel_v2:${{ github.sha }}" \
- --classify-v2-image "${REGISTRY}/classify_v2:${{ github.sha }}"
+ --classify-v2-image "${REGISTRY}/classify_v2:${{ github.sha }}" \
+ --tabiya-core-image "${REGISTRY}/tabiya_core:${{ github.sha }}" \
+ --tabiya-io-image "${REGISTRY}/tabiya_io:${{ github.sha }}"
# ── Phase 1: deploy everything except frontend ─────────────────────────
- name: Deploy infrastructure (pre-frontend)
diff --git a/app/.eslintrc.cjs b/app/.eslintrc.cjs
new file mode 100644
index 0000000..9a23d6b
--- /dev/null
+++ b/app/.eslintrc.cjs
@@ -0,0 +1,36 @@
+module.exports = {
+ root: true,
+ env: { browser: true, es2020: true, node: true },
+ extends: [
+ "eslint:recommended",
+ "plugin:@typescript-eslint/recommended",
+ "plugin:react/recommended",
+ "plugin:react/jsx-runtime",
+ "plugin:react-hooks/recommended",
+ "plugin:storybook/recommended",
+ ],
+ ignorePatterns: ["dist", ".eslintrc.cjs", "storybook-static", "coverage"],
+ parser: "@typescript-eslint/parser",
+ parserOptions: { ecmaVersion: "latest", sourceType: "module" },
+ settings: { react: { version: "18.3" } },
+ rules: {
+ // Disable the base rule — @typescript-eslint version is TS-aware.
+ "no-unused-vars": "off",
+ "@typescript-eslint/no-unused-vars": [
+ "error",
+ {
+ args: "after-used",
+ argsIgnorePattern: "^_",
+ varsIgnorePattern: "^_",
+ caughtErrors: "all",
+ caughtErrorsIgnorePattern: "^_",
+ ignoreRestSiblings: true,
+ },
+ ],
+ "@typescript-eslint/consistent-type-imports": [
+ "error",
+ { prefer: "type-imports", fixStyle: "inline-type-imports" },
+ ],
+ "react/prop-types": "off",
+ },
+};
diff --git a/app/.gitignore b/app/.gitignore
index dafa699..9e91122 100644
--- a/app/.gitignore
+++ b/app/.gitignore
@@ -1,4 +1,16 @@
node_modules/
dist/
+storybook-static/
+coverage/
+.vite/
+*.log
+
+# Yarn Berry / PnP artifacts — this app uses classic Yarn 1.x (matches CI).
+# Ignore these so a stray `yarn` (Berry) run can't re-commit a v2 lockfile or
+# PnP files and break `yarn install --frozen-lockfile` on the classic CI runner.
+.yarn/
+.pnp.*
+
.env
.env.local
+.DS_Store
diff --git a/app/.storybook/main.ts b/app/.storybook/main.ts
new file mode 100644
index 0000000..76ee3b8
--- /dev/null
+++ b/app/.storybook/main.ts
@@ -0,0 +1,21 @@
+import type { StorybookConfig } from "@storybook/react-vite";
+
+const config: StorybookConfig = {
+ stories: ["../src/**/*.mdx", "../src/**/*.stories.@(ts|tsx)"],
+ addons: [
+ "@storybook/addon-links",
+ "@storybook/addon-essentials",
+ "@storybook/addon-interactions",
+ "msw-storybook-addon",
+ ],
+ framework: {
+ name: "@storybook/react-vite",
+ options: {},
+ },
+ staticDirs: ["../public"],
+ docs: {
+ autodocs: "tag",
+ },
+};
+
+export default config;
diff --git a/app/.storybook/preview.tsx b/app/.storybook/preview.tsx
new file mode 100644
index 0000000..4a9c79b
--- /dev/null
+++ b/app/.storybook/preview.tsx
@@ -0,0 +1,63 @@
+import { useEffect } from "react";
+import type { Preview, StoryFn } from "@storybook/react";
+import { I18nextProvider } from "react-i18next";
+import { initialize, mswLoader } from "msw-storybook-addon";
+import i18n from "../src/i18n/i18n";
+import { Locale, LocalesLabels } from "../src/i18n/constants";
+import { handlers } from "../src/mocks/handlers";
+import "../src/index.css";
+
+initialize({ onUnhandledRequest: "bypass" });
+
+const localeToolbarItems = Object.entries(LocalesLabels).map(
+ ([localeValue, label]) => ({ value: localeValue, title: label }),
+);
+
+const preview: Preview = {
+ loaders: [mswLoader],
+ parameters: {
+ // Register the full MSW handler set for every story. Without this, MSW is
+ // initialized but has no handlers, so any story that hits the API (e.g. the
+ // pipelines pages) gets an unhandled request and fails to load. Individual
+ // stories still tune the backing store via seed/reset helpers.
+ msw: { handlers },
+ controls: {
+ matchers: {
+ color: /(background|color)$/i,
+ date: /Date$/i,
+ },
+ },
+ },
+ globalTypes: {
+ locale: {
+ name: "Locale",
+ description: "Internationalization locale",
+ toolbar: {
+ icon: "globe",
+ items: localeToolbarItems,
+ defaultValue: Locale.EN_US,
+ showName: true,
+ },
+ },
+ },
+ decorators: [
+ function StoryWithLocale(
+ Story: StoryFn,
+ context: { globals: { locale?: string } },
+ ) {
+ const selectedLocale = context.globals.locale ?? Locale.EN_US;
+
+ useEffect(() => {
+ i18n.changeLanguage(selectedLocale);
+ }, [selectedLocale]);
+
+ return (
+
+
+
+ );
+ },
+ ],
+};
+
+export default preview;
diff --git a/app/index.html b/app/index.html
index a3dd856..6d34683 100644
--- a/app/index.html
+++ b/app/index.html
@@ -5,7 +5,12 @@
Tabiya Classifier
-
+
+
+
diff --git a/app/knip.json b/app/knip.json
new file mode 100644
index 0000000..3143af4
--- /dev/null
+++ b/app/knip.json
@@ -0,0 +1,16 @@
+{
+ "$schema": "https://unpkg.com/knip@5/schema.json",
+ "entry": [
+ "src/mocks/browser.ts",
+ "src/mocks/server.ts",
+ "src/**/*.stories.tsx",
+ "src/**/*.test.{ts,tsx}"
+ ],
+ "project": ["src/**/*.{ts,tsx}"],
+ "ignoreDependencies": ["@storybook/blocks"],
+ "ignoreExportsUsedInFile": true,
+ "rules": {
+ "exports": "warn",
+ "types": "warn"
+ }
+}
diff --git a/app/package.json b/app/package.json
index 67b65ee..a118147 100644
--- a/app/package.json
+++ b/app/package.json
@@ -2,23 +2,74 @@
"name": "tabiya-classifier-app",
"version": "0.1.0",
"private": true,
+ "type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
- "lint": "eslint src --ext ts,tsx"
+ "lint": "eslint src --ext ts,tsx --max-warnings 0",
+ "lint:dead-code": "knip",
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "test:coverage": "vitest run --coverage",
+ "storybook": "storybook dev -p 6006",
+ "build-storybook": "storybook build"
},
"dependencies": {
+ "@emotion/is-prop-valid": "^1.4.0",
+ "class-variance-authority": "^0.7.0",
+ "clsx": "^2.1.1",
"firebase": "^10.12.0",
+ "framer-motion": "^12.40.0",
+ "i18next": "^26.3.1",
+ "i18next-browser-languagedetector": "^8.2.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
- "react-router-dom": "^6.23.1"
+ "react-i18next": "^17.0.8",
+ "react-router-dom": "^6.23.1",
+ "reactflow": "^11",
+ "recharts": "^3.9.2",
+ "tailwind-merge": "^2.5.2"
},
"devDependencies": {
+ "@storybook/addon-essentials": "^8.3.5",
+ "@storybook/addon-interactions": "^8.3.5",
+ "@storybook/addon-links": "^8.3.5",
+ "@storybook/blocks": "^8.3.5",
+ "@storybook/react": "^8.3.5",
+ "@storybook/react-vite": "^8.3.5",
+ "@storybook/test": "^8.3.5",
+ "@testing-library/dom": "^10.4.1",
+ "@testing-library/jest-dom": "^6.5.0",
+ "@testing-library/react": "^16.0.1",
+ "@testing-library/user-event": "^14.5.2",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
+ "@typescript-eslint/eslint-plugin": "^7.18.0",
+ "@typescript-eslint/parser": "^7.18.0",
"@vitejs/plugin-react": "^4.3.0",
+ "@vitest/coverage-v8": "^2.1.2",
+ "autoprefixer": "^10.4.20",
+ "eslint": "^8.57.0",
+ "eslint-plugin-react": "^7.37.1",
+ "eslint-plugin-react-hooks": "^4.6.2",
+ "eslint-plugin-storybook": "^0.9.0",
+ "jsdom": "^25.0.1",
+ "json-schema-to-typescript": "^15.0.4",
+ "knip": "^6.16.1",
+ "msw": "^2.4.9",
+ "msw-storybook-addon": "^2.0.4",
+ "postcss": "^8.4.47",
+ "storybook": "^8.3.5",
+ "tailwindcss": "^3.4.13",
"typescript": "^5.4.5",
- "vite": "^5.2.12"
+ "vite": "^5.2.12",
+ "vitest": "^2.1.2"
+ },
+ "msw": {
+ "workerDirectory": [
+ "public"
+ ]
}
}
diff --git a/app/postcss.config.js b/app/postcss.config.js
new file mode 100644
index 0000000..2aa7205
--- /dev/null
+++ b/app/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git a/app/public/mockServiceWorker.js b/app/public/mockServiceWorker.js
new file mode 100644
index 0000000..0c970ef
--- /dev/null
+++ b/app/public/mockServiceWorker.js
@@ -0,0 +1,361 @@
+/* eslint-disable */
+/* tslint:disable */
+
+/**
+ * Mock Service Worker.
+ * @see https://github.com/mswjs/msw
+ * - Please do NOT modify this file.
+ */
+
+const PACKAGE_VERSION = '2.15.0'
+const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e'
+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)
+
+ // Omit the body of server-sent event stream responses.
+ // Cloning such responses would prevent client-side stream cancelations
+ // from reaching the original stream (a teed stream only cancels its
+ // source once both of its branches cancel) and would buffer the
+ // entire stream into the unconsumed clone indefinitely.
+ const isEventStreamResponse = response.headers
+ .get('content-type')
+ ?.toLowerCase()
+ .startsWith('text/event-stream')
+
+ // Clone the response so both the client and the library could consume it.
+ const responseClone = isEventStreamResponse ? null : response.clone()
+
+ sendToClient(
+ client,
+ {
+ type: 'RESPONSE',
+ payload: {
+ isMockedResponse: IS_MOCKED_RESPONSE in response,
+ request: {
+ id: requestId,
+ ...serializedRequest,
+ },
+ response: {
+ type: response.type,
+ status: response.status,
+ statusText: response.statusText,
+ headers: Object.fromEntries(response.headers.entries()),
+ body: responseClone ? responseClone.body : null,
+ },
+ },
+ },
+ responseClone && 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/app/src/App.test.tsx b/app/src/App.test.tsx
new file mode 100644
index 0000000..15b7c2b
--- /dev/null
+++ b/app/src/App.test.tsx
@@ -0,0 +1,27 @@
+import { describe, expect, it, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+
+const GIVEN_APP_ROUTER_MARKER_TEST_ID = "given-app-router-marker";
+
+// Stub the router's children so this test stays focused on App composition
+// rather than the inner feature pages (those have their own tests).
+vi.mock("./routes/AppRouter", () => ({
+ AppRouter: () => (
+ router
+ ),
+}));
+
+import { App } from "./App";
+
+describe("App", () => {
+ it("renders the AppRouter", () => {
+ // GIVEN the App component
+ // WHEN it is rendered
+ render();
+
+ // THEN the AppRouter is mounted
+ expect(
+ screen.getByTestId(GIVEN_APP_ROUTER_MARKER_TEST_ID),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/app/src/App.tsx b/app/src/App.tsx
index 37d239e..9d70fce 100644
--- a/app/src/App.tsx
+++ b/app/src/App.tsx
@@ -1,43 +1,13 @@
-import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
-import ProtectedRoute from "./auth/ProtectedRoute";
-import Login from "./pages/Login";
-import Dashboard from "./pages/Dashboard";
-import Configuration from "./pages/Configuration";
-import ApiKeys from "./pages/ApiKeys";
+import { ToastProvider } from "@/components";
+import { NavigationGuardProvider } from "@/lib/navigationGuard";
+import { AppRouter } from "./routes/AppRouter";
-export default function App() {
+export function App() {
return (
-
-
- } />
-
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
- } />
-
-
+
+
+
+
+
);
}
diff --git a/app/src/_test_utilities/VisualMock.tsx b/app/src/_test_utilities/VisualMock.tsx
new file mode 100644
index 0000000..55a5058
--- /dev/null
+++ b/app/src/_test_utilities/VisualMock.tsx
@@ -0,0 +1,75 @@
+/**
+ * Placeholder component for stories that need to occupy a slot without
+ * dragging in a real feature page. Renders a dashed box with diagonal
+ * cross-lines and a centered label, so the surrounding layout is obviously
+ * the focus of the story.
+ *
+ * Lives in _test_utilities/ because it is only ever rendered by stories and
+ * tests — production code never imports it.
+ */
+
+import { mergeClassNames } from "@/lib/mergeClassNames";
+
+const uniqueId = "f7c8d9e0-1a2b-3c4d-5e6f-7a8b9c0d1e2f";
+
+export const DATA_TEST_ID = {
+ CONTAINER: `visual-mock-container-${uniqueId}`,
+ LABEL: `visual-mock-label-${uniqueId}`,
+};
+
+export interface VisualMockProps {
+ /** Text shown centered inside the placeholder. */
+ text: string;
+ /** Optional max-width cap; defaults to filling the parent. */
+ maxWidth?: string | number;
+ className?: string;
+}
+
+export function VisualMock({ text, maxWidth, className }: VisualMockProps) {
+ return (
+
+
+ {text}
+
+
+
+ );
+}
diff --git a/app/src/_test_utilities/index.ts b/app/src/_test_utilities/index.ts
new file mode 100644
index 0000000..9254eaf
--- /dev/null
+++ b/app/src/_test_utilities/index.ts
@@ -0,0 +1,17 @@
+export { VisualMock } from "./VisualMock";
+export type { VisualMockProps } from "./VisualMock";
+
+export { renderWithRouter } from "./renderWithRouter";
+export type {
+ RenderWithRouterOptions,
+ AdditionalRouteEntry,
+} from "./renderWithRouter";
+
+export { renderWithRouterOutlet } from "./renderWithRouterOutlet";
+export type { RenderWithRouterOutletOptions } from "./renderWithRouterOutlet";
+
+export { withFirebaseAuth, withRouter } from "./storybookDecorators";
+export type {
+ FirebaseAuthStoryOptions,
+ RouterStoryOptions,
+} from "./storybookDecorators";
diff --git a/app/src/_test_utilities/renderWithRouter.tsx b/app/src/_test_utilities/renderWithRouter.tsx
new file mode 100644
index 0000000..35fac7c
--- /dev/null
+++ b/app/src/_test_utilities/renderWithRouter.tsx
@@ -0,0 +1,66 @@
+/**
+ * Renders a React element inside a MemoryRouter so router-aware components
+ * (NavLink, useNavigate, useLocation, etc.) can be exercised in unit tests
+ * without a full app shell.
+ *
+ * - `currentPath` is the initial pathname.
+ * - `routes` lets you register sibling routes (e.g. a fake `/login` page) so
+ * you can assert post-navigation state.
+ * - `routeState` is React Router's `location.state` for the initial entry —
+ * useful for testing redirect logic that reads `state.from`.
+ */
+
+import type { ReactNode } from "react";
+import { render } from "@testing-library/react";
+import { MemoryRouter, Route, Routes } from "react-router-dom";
+
+export interface AdditionalRouteEntry {
+ /** Path pattern for the sibling route (e.g. "/login"). */
+ path: string;
+ /** Element to render when the path matches. */
+ element: ReactNode;
+}
+
+export interface RenderWithRouterOptions {
+ /** Initial pathname rendered by the MemoryRouter. */
+ currentPath: string;
+ /** Path pattern the element under test is mounted at. */
+ elementPath: string;
+ /** Optional sibling routes for navigation assertions. */
+ additionalRoutes?: AdditionalRouteEntry[];
+ /** Optional `location.state` for the initial entry. */
+ routeState?: Record;
+}
+
+/**
+ * Render the given element behind a MemoryRouter at the configured path,
+ * alongside any extra routes the test needs (e.g. redirect targets).
+ */
+export function renderWithRouter(
+ element: ReactNode,
+ {
+ currentPath,
+ elementPath,
+ additionalRoutes = [],
+ routeState,
+ }: RenderWithRouterOptions,
+) {
+ const initialEntry = routeState
+ ? { pathname: currentPath, state: routeState }
+ : currentPath;
+
+ return render(
+
+
+
+ {additionalRoutes.map((extraRoute) => (
+
+ ))}
+
+ ,
+ );
+}
diff --git a/app/src/_test_utilities/renderWithRouterOutlet.tsx b/app/src/_test_utilities/renderWithRouterOutlet.tsx
new file mode 100644
index 0000000..28682d1
--- /dev/null
+++ b/app/src/_test_utilities/renderWithRouterOutlet.tsx
@@ -0,0 +1,53 @@
+/**
+ * Variant of renderWithRouter for components that render an and
+ * therefore need a *nested* child route to fill it.
+ *
+ * Use this when the element under test (e.g. AppShell) is a layout route —
+ * its child renders via , not as a direct child of .
+ */
+
+import type { ReactNode } from "react";
+import { render } from "@testing-library/react";
+import { MemoryRouter, Route, Routes } from "react-router-dom";
+import type { AdditionalRouteEntry } from "./renderWithRouter";
+
+export interface RenderWithRouterOutletOptions {
+ /** Initial pathname rendered by the MemoryRouter. */
+ currentPath: string;
+ /** Path of the nested route (filled by the layout's ). */
+ outletPath: string;
+ /** Element rendered inside the layout's outlet. */
+ outletElement: ReactNode;
+ /** Optional sibling top-level routes (e.g. a fake /login marker). */
+ additionalRoutes?: AdditionalRouteEntry[];
+}
+
+/**
+ * Render a layout component that uses with a nested child route.
+ */
+export function renderWithRouterOutlet(
+ layoutElement: ReactNode,
+ {
+ currentPath,
+ outletPath,
+ outletElement,
+ additionalRoutes = [],
+ }: RenderWithRouterOutletOptions,
+) {
+ return render(
+
+
+ {additionalRoutes.map((extraRoute) => (
+
+ ))}
+
+
+
+
+ ,
+ );
+}
diff --git a/app/src/_test_utilities/storybookDecorators.tsx b/app/src/_test_utilities/storybookDecorators.tsx
new file mode 100644
index 0000000..779e516
--- /dev/null
+++ b/app/src/_test_utilities/storybookDecorators.tsx
@@ -0,0 +1,69 @@
+/**
+ * Storybook decorators that wrap a story in the providers / router context
+ * production components depend on, with deterministic mock values.
+ *
+ * Why this lives in _test_utilities (not in a story file): multiple page
+ * stories need the same setup, and Storybook decorators are framework code,
+ * not test code — but the data they inject is the same flavor of mocking we
+ * use elsewhere, so co-locating with the test helpers keeps the
+ * "fake-data plumbing" in one place.
+ */
+
+import type { ReactElement } from "react";
+import { MemoryRouter } from "react-router-dom";
+import {
+ AuthOverrideProvider,
+ type UseFirebaseAuthValue,
+ type AuthenticatedUser,
+} from "@/lib/auth/useFirebaseAuth";
+
+type StoryRenderer = () => ReactElement;
+
+export interface RouterStoryOptions {
+ /** Initial pathname for MemoryRouter. Defaults to "/". */
+ initialPath?: string;
+}
+
+/** Wrap a story in a MemoryRouter at the given path. */
+export function withRouter({ initialPath = "/" }: RouterStoryOptions = {}) {
+ function StoryWithRouter(Story: StoryRenderer) {
+ return (
+
+
+
+ );
+ }
+ return StoryWithRouter;
+}
+
+export interface FirebaseAuthStoryOptions {
+ /** The signed-in user, or null for signed-out. Defaults to null. */
+ user?: AuthenticatedUser | null;
+ /** Whether the hook should report initial auth-resolution loading. Defaults to false. */
+ loading?: boolean;
+}
+
+/**
+ * Wrap a story in an AuthOverrideProvider so useFirebaseAuth returns a
+ * deterministic value. Sign-in/up/out callbacks are no-ops by default.
+ */
+export function withFirebaseAuth({
+ user = null,
+ loading = false,
+}: FirebaseAuthStoryOptions = {}) {
+ const value: UseFirebaseAuthValue = {
+ user,
+ loading,
+ signInWithEmail: async () => undefined,
+ signUpWithEmail: async () => undefined,
+ signOut: async () => undefined,
+ };
+ function StoryWithFirebaseAuth(Story: StoryRenderer) {
+ return (
+
+
+
+ );
+ }
+ return StoryWithFirebaseAuth;
+}
diff --git a/app/src/auth/ProtectedRoute.tsx b/app/src/auth/ProtectedRoute.tsx
deleted file mode 100644
index 0279eba..0000000
--- a/app/src/auth/ProtectedRoute.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import React from "react";
-import { Navigate } from "react-router-dom";
-import { useAuth } from "../hooks/useAuth";
-
-export default function ProtectedRoute({ children }: { children: React.ReactNode }) {
- const { user, loading } = useAuth();
-
- if (loading) {
- return (
-
- Loading…
-
- );
- }
-
- if (!user) {
- return ;
- }
-
- return <>{children}>;
-}
diff --git a/app/src/components/AppLayout/AppLayout.stories.tsx b/app/src/components/AppLayout/AppLayout.stories.tsx
new file mode 100644
index 0000000..ee259db
--- /dev/null
+++ b/app/src/components/AppLayout/AppLayout.stories.tsx
@@ -0,0 +1,73 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { useState } from "react";
+import { AppLayout } from "./AppLayout";
+import { Sidebar } from "@/components";
+import { Topbar } from "@/components";
+import { StatusPill } from "@/components";
+import { Kbd } from "@/components";
+
+const meta: Meta = {
+ title: "Primitives/AppLayout",
+ component: AppLayout,
+ parameters: { layout: "fullscreen" },
+};
+export default meta;
+
+type Story = StoryObj;
+
+const groups = [
+ {
+ label: "Workspace",
+ items: [
+ { id: "classify", label: "Classifier", icon: "classify" as const },
+ { id: "dashboard", label: "Dashboard", icon: "dashboard" as const },
+ { id: "history", label: "History", icon: "history" as const },
+ ],
+ },
+ {
+ label: "Settings",
+ items: [
+ { id: "config", label: "Configuration", icon: "config" as const },
+ { id: "keys", label: "Keys", icon: "key" as const },
+ { id: "docs", label: "Documentation", icon: "docs" as const },
+ ],
+ },
+];
+
+export const Shell: Story = {
+ render: () => {
+ const [active, setActive] = useState("classify");
+ return (
+ {}}
+ />
+ }
+ topbar={
+ {} },
+ { label: active },
+ ]}
+ right={
+ <>
+ API healthy · v1.0.0
+ ⌘ K
+ >
+ }
+ />
+ }
+ >
+
+
Workspace · {active}
+
Page content goes here
+
+
+ );
+ },
+};
diff --git a/app/src/components/AppLayout/AppLayout.test.tsx b/app/src/components/AppLayout/AppLayout.test.tsx
new file mode 100644
index 0000000..73baca3
--- /dev/null
+++ b/app/src/components/AppLayout/AppLayout.test.tsx
@@ -0,0 +1,31 @@
+import { describe, expect, it } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { AppLayout, DATA_TEST_ID } from "./AppLayout";
+
+const GIVEN_SIDEBAR_TEST_ID = "given-sidebar-slot";
+const GIVEN_TOPBAR_TEST_ID = "given-topbar-slot";
+
+describe("AppLayout", () => {
+ it("renders sidebar, topbar, and children content", () => {
+ // GIVEN main content for the layout
+ const givenMainContent = "main content";
+
+ // WHEN we render the AppLayout composed with three slots
+ render(
+ sidebar}
+ topbar={topbar
}
+ >
+ {givenMainContent}
+ ,
+ );
+
+ // THEN every slot is in the document with the given content
+ expect(screen.getByTestId(DATA_TEST_ID.CONTAINER)).toBeInTheDocument();
+ expect(screen.getByTestId(GIVEN_SIDEBAR_TEST_ID)).toBeInTheDocument();
+ expect(screen.getByTestId(GIVEN_TOPBAR_TEST_ID)).toBeInTheDocument();
+ expect(screen.getByTestId(DATA_TEST_ID.MAIN)).toHaveTextContent(
+ givenMainContent,
+ );
+ });
+});
diff --git a/app/src/components/AppLayout/AppLayout.tsx b/app/src/components/AppLayout/AppLayout.tsx
new file mode 100644
index 0000000..db0db66
--- /dev/null
+++ b/app/src/components/AppLayout/AppLayout.tsx
@@ -0,0 +1,48 @@
+import type { ReactNode } from "react";
+import { mergeClassNames } from "@/lib/mergeClassNames.ts";
+
+const uniqueId = "b8cabb2d-d140-48e5-b5bd-62d5dfa46628";
+
+export const DATA_TEST_ID = {
+ CONTAINER: `app-layout-container-${uniqueId}`,
+ MAIN: `app-layout-main-${uniqueId}`,
+};
+
+export interface AppLayoutProps {
+ sidebar: ReactNode;
+ topbar?: ReactNode;
+ children: ReactNode;
+ className?: string;
+}
+
+/**
+ * The page chrome: persistent sidebar on the left, sticky topbar, scrolling
+ * content area. Page components decide their own inner padding.
+ */
+export function AppLayout({
+ sidebar,
+ topbar,
+ children,
+ className,
+}: AppLayoutProps) {
+ return (
+
+ {sidebar}
+
+ {topbar}
+
+ {children}
+
+
+
+ );
+}
diff --git a/app/src/components/BottomTabBar/BottomTabBar.tsx b/app/src/components/BottomTabBar/BottomTabBar.tsx
new file mode 100644
index 0000000..de2f9f6
--- /dev/null
+++ b/app/src/components/BottomTabBar/BottomTabBar.tsx
@@ -0,0 +1,62 @@
+import { Icon } from "@/components/Icon/Icon";
+import { mergeClassNames } from "@/lib/mergeClassNames";
+import type { IconName } from "@/components/Icon/Icon.types";
+
+const uniqueId = "f3e2d1c0-b9a8-4f7e-8d6c-5b4a3c2d1e0f";
+
+export const DATA_TEST_ID = {
+ CONTAINER: `bottom-tab-bar-container-${uniqueId}`,
+ TAB: `bottom-tab-bar-tab-${uniqueId}`,
+};
+
+export interface BottomTabItem {
+ id: string;
+ label: string;
+ /** Shorter label for narrow viewports. Falls back to `label` if omitted. */
+ shortLabel?: string;
+ icon: IconName;
+}
+
+export interface BottomTabBarProps {
+ items: BottomTabItem[];
+ activeId: string;
+ onNavigate: (id: string) => void;
+ className?: string;
+}
+
+export function BottomTabBar({
+ items,
+ activeId,
+ onNavigate,
+ className,
+}: BottomTabBarProps) {
+ return (
+
+ );
+}
diff --git a/app/src/components/Breadcrumbs/Breadcrumbs.stories.tsx b/app/src/components/Breadcrumbs/Breadcrumbs.stories.tsx
new file mode 100644
index 0000000..e3efa84
--- /dev/null
+++ b/app/src/components/Breadcrumbs/Breadcrumbs.stories.tsx
@@ -0,0 +1,31 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { fn } from "@storybook/test";
+import { Breadcrumbs } from "@/components";
+
+const meta: Meta = {
+ title: "Primitives/Breadcrumbs",
+ component: Breadcrumbs,
+ parameters: { layout: "padded" },
+};
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ items: [
+ { label: "Workspace", onClick: fn() },
+ { label: "Classifier" },
+ ],
+ },
+};
+
+export const ThreeLevel: Story = {
+ args: {
+ items: [
+ { label: "Documentation", onClick: fn() },
+ { label: "Endpoints", onClick: fn() },
+ { label: "POST /v1/classify" },
+ ],
+ },
+};
diff --git a/app/src/components/Breadcrumbs/Breadcrumbs.test.tsx b/app/src/components/Breadcrumbs/Breadcrumbs.test.tsx
new file mode 100644
index 0000000..dc16028
--- /dev/null
+++ b/app/src/components/Breadcrumbs/Breadcrumbs.test.tsx
@@ -0,0 +1,62 @@
+import { describe, expect, it, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { Breadcrumbs, DATA_TEST_ID } from "./Breadcrumbs";
+
+describe("Breadcrumbs", () => {
+ it("renders one list item per breadcrumb", () => {
+ // GIVEN three breadcrumb labels
+ const givenBreadcrumbLabels = ["Docs", "Endpoints", "POST /classify"];
+
+ // WHEN we render Breadcrumbs with those labels
+ render(
+ ({ label }))}
+ />,
+ );
+
+ // THEN exactly that many items render, with the right text in each position
+ const renderedItems = screen.getAllByTestId(DATA_TEST_ID.ITEM);
+ expect(renderedItems).toHaveLength(givenBreadcrumbLabels.length);
+ givenBreadcrumbLabels.forEach((label, index) => {
+ expect(renderedItems[index]).toHaveTextContent(label);
+ });
+ });
+
+ it("renders intermediate items as anchors when onClick is provided", () => {
+ // GIVEN a clickable intermediate label and a terminal label
+ const givenClickableLabel = "Docs";
+ const givenTerminalLabel = "Endpoints";
+
+ // WHEN we render Breadcrumbs with the clickable label first
+ render(
+ {} },
+ { label: givenTerminalLabel },
+ ]}
+ />,
+ );
+
+ // THEN the clickable item renders as a single link with that label
+ const renderedLinks = screen.getAllByTestId(DATA_TEST_ID.LINK);
+ expect(renderedLinks).toHaveLength(1);
+ expect(renderedLinks[0]).toHaveTextContent(givenClickableLabel);
+ });
+
+ it("calls onClick when an item is activated", async () => {
+ // GIVEN an onClick spy
+ const onClick = vi.fn();
+
+ // AND a rendered breadcrumb bound to that handler
+ render(
+ ,
+ );
+
+ // WHEN the user clicks the breadcrumb link
+ await userEvent.click(screen.getByTestId(DATA_TEST_ID.LINK));
+
+ // THEN onClick is invoked exactly once
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/app/src/components/Breadcrumbs/Breadcrumbs.tsx b/app/src/components/Breadcrumbs/Breadcrumbs.tsx
new file mode 100644
index 0000000..86d91f3
--- /dev/null
+++ b/app/src/components/Breadcrumbs/Breadcrumbs.tsx
@@ -0,0 +1,77 @@
+import type { ReactNode } from "react";
+import { mergeClassNames } from "@/lib/mergeClassNames.ts";
+
+const uniqueId = "92dfd837-3b3c-463b-9aa0-fed5dfefe5ff";
+
+export const DATA_TEST_ID = {
+ NAV: `breadcrumbs-nav-${uniqueId}`,
+ ITEM: `breadcrumbs-item-${uniqueId}`,
+ LINK: `breadcrumbs-link-${uniqueId}`,
+};
+
+export interface BreadcrumbItem {
+ label: ReactNode;
+ href?: string;
+ onClick?: () => void;
+}
+
+export interface BreadcrumbsProps {
+ items: BreadcrumbItem[];
+ className?: string;
+ /** Separator character between items. Defaults to /. */
+ separator?: string;
+}
+
+export function Breadcrumbs({
+ items,
+ className,
+ separator = "/",
+}: BreadcrumbsProps) {
+ return (
+
+ );
+}
diff --git a/app/src/components/Button/Button.stories.tsx b/app/src/components/Button/Button.stories.tsx
new file mode 100644
index 0000000..e64eef2
--- /dev/null
+++ b/app/src/components/Button/Button.stories.tsx
@@ -0,0 +1,60 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { fn } from "@storybook/test";
+import { Button } from "./Button";
+import { Icon } from "@/components";
+
+const meta: Meta = {
+ title: "Primitives/Button",
+ component: Button,
+ parameters: { layout: "centered" },
+ argTypes: {
+ variant: {
+ control: "select",
+ options: ["default", "primary", "lime", "ghost", "danger"],
+ },
+ size: { control: "select", options: ["sm", "md", "lg"] },
+ },
+ args: { children: "Run classify", onClick: fn() },
+};
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const Primary: Story = { args: { variant: "primary" } };
+export const Lime: Story = { args: { variant: "lime", children: "Open the Classifier" } };
+export const Ghost: Story = { args: { variant: "ghost" } };
+export const Danger: Story = { args: { variant: "danger", children: "Revoke" } };
+
+export const Small: Story = { args: { size: "sm" } };
+export const Large: Story = { args: { size: "lg", variant: "primary" } };
+
+export const WithIcons: Story = {
+ args: {
+ variant: "primary",
+ leading: ,
+ trailing: ,
+ },
+};
+
+export const Loading: Story = {
+ args: { variant: "primary", loading: true, children: "Running" },
+};
+
+export const Disabled: Story = {
+ args: { variant: "primary", disabled: true },
+};
+
+export const AllVariants: Story = {
+ args: { onClick: fn() },
+ render: (args) => (
+
+
+
+
+
+
+
+ ),
+};
diff --git a/app/src/components/Button/Button.test.tsx b/app/src/components/Button/Button.test.tsx
new file mode 100644
index 0000000..ed360ee
--- /dev/null
+++ b/app/src/components/Button/Button.test.tsx
@@ -0,0 +1,88 @@
+import { describe, expect, it, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { Button, DATA_TEST_ID } from "./Button";
+
+describe("Button", () => {
+ it("renders its children as the label", () => {
+ // GIVEN an expected label text
+ const givenLabelText = "Save";
+
+ // WHEN we render a Button with that label
+ render();
+
+ // THEN the label node carries the given text
+ expect(screen.getByTestId(DATA_TEST_ID.LABEL)).toHaveTextContent(givenLabelText);
+ });
+
+ it("fires onClick when activated", async () => {
+ // GIVEN an onClick spy
+ const onClick = vi.fn();
+
+ // AND a rendered Button bound to that spy
+ render();
+
+ // WHEN the user clicks the button
+ await userEvent.click(screen.getByTestId(DATA_TEST_ID.CONTAINER));
+
+ // THEN the spy is called exactly once
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ it("is disabled and skips clicks while loading", async () => {
+ // GIVEN an onClick spy
+ const onClick = vi.fn();
+
+ // AND a Button rendered with loading=true
+ render(
+ ,
+ );
+
+ // WHEN the user clicks the loading button
+ await userEvent.click(screen.getByTestId(DATA_TEST_ID.CONTAINER));
+
+ // THEN the button is marked disabled, the spinner is shown, and onClick is never called
+ expect(screen.getByTestId(DATA_TEST_ID.CONTAINER)).toBeDisabled();
+ expect(screen.getByTestId(DATA_TEST_ID.SPINNER)).toBeInTheDocument();
+ expect(onClick).not.toHaveBeenCalled();
+ });
+
+ it("does not render the trailing slot while loading", () => {
+ // GIVEN a Button with a trailing icon-like marker and loading=true
+ // WHEN it renders
+ render(
+ ,
+ );
+
+ // THEN the trailing slot is suppressed in favor of the spinner
+ expect(screen.queryByTestId(DATA_TEST_ID.TRAILING)).not.toBeInTheDocument();
+ expect(screen.getByTestId(DATA_TEST_ID.SPINNER)).toBeInTheDocument();
+ });
+
+ it("renders leading and trailing slots when provided and not loading", () => {
+ // GIVEN a Button with both leading and trailing slots
+ // WHEN it renders
+ render(
+ ,
+ );
+
+ // THEN both slot containers are present in the DOM
+ expect(screen.getByTestId(DATA_TEST_ID.LEADING)).toBeInTheDocument();
+ expect(screen.getByTestId(DATA_TEST_ID.TRAILING)).toBeInTheDocument();
+ });
+
+ it("applies the primary variant classes", () => {
+ // GIVEN a primary Button
+ // WHEN it renders
+ render();
+
+ // THEN the button element carries the primary background utility
+ expect(screen.getByTestId(DATA_TEST_ID.CONTAINER).className).toMatch(/bg-navy/);
+ });
+});
diff --git a/app/src/components/Button/Button.tsx b/app/src/components/Button/Button.tsx
new file mode 100644
index 0000000..f9f702a
--- /dev/null
+++ b/app/src/components/Button/Button.tsx
@@ -0,0 +1,54 @@
+import { forwardRef } from "react";
+import { mergeClassNames } from "@/lib/mergeClassNames.ts";
+import type { ButtonProps } from "@/components";
+import { buttonVariants } from "@/components";
+
+const uniqueId = "01f9e13d-269a-41e4-ab18-f6a108050c29";
+
+export const DATA_TEST_ID = {
+ CONTAINER: `button-container-${uniqueId}`,
+ SPINNER: `button-spinner-${uniqueId}`,
+ LEADING: `button-leading-${uniqueId}`,
+ TRAILING: `button-trailing-${uniqueId}`,
+ LABEL: `button-label-${uniqueId}`,
+};
+
+export const Button = forwardRef(function Button(
+ {
+ variant,
+ size,
+ className,
+ leading,
+ trailing,
+ loading,
+ disabled,
+ children,
+ type = "button",
+ ...rest
+ },
+ ref,
+) {
+ const isDisabled = disabled || loading;
+ return (
+
+ );
+});
diff --git a/app/src/components/Button/Button.types.ts b/app/src/components/Button/Button.types.ts
new file mode 100644
index 0000000..f066c8d
--- /dev/null
+++ b/app/src/components/Button/Button.types.ts
@@ -0,0 +1,14 @@
+import type { ButtonHTMLAttributes } from "react";
+import type { VariantProps } from "class-variance-authority";
+import type { buttonVariants } from "./Button.variants";
+
+export interface ButtonProps
+ extends ButtonHTMLAttributes,
+ VariantProps {
+ /** Renders a leading 14px slot — typically an Icon. */
+ leading?: React.ReactNode;
+ /** Renders a trailing 14px slot — typically an Icon. */
+ trailing?: React.ReactNode;
+ /** Shows a spinner and disables interaction while running. */
+ loading?: boolean;
+}
diff --git a/app/src/components/Button/Button.variants.ts b/app/src/components/Button/Button.variants.ts
new file mode 100644
index 0000000..5779904
--- /dev/null
+++ b/app/src/components/Button/Button.variants.ts
@@ -0,0 +1,34 @@
+import { cva } from "class-variance-authority";
+
+export const buttonVariants = cva(
+ // base: monospace label, rounded-md corners, smooth transitions, focus ring
+ "inline-flex items-center justify-center gap-2 font-mono font-medium tracking-tight " +
+ "border transition-colors duration-100 outline-none " +
+ "focus-visible:ring-2 focus-visible:ring-navy/30 focus-visible:ring-offset-1 focus-visible:ring-offset-cream " +
+ "disabled:opacity-45 disabled:cursor-not-allowed",
+ {
+ variants: {
+ variant: {
+ default:
+ "bg-paper text-navy border-line-strong hover:bg-cream-200 hover:border-navy",
+ primary:
+ "bg-navy text-lime border-navy hover:bg-navy-700",
+ lime:
+ "bg-lime text-navy border-lime font-semibold hover:bg-lime-600 hover:border-lime-600",
+ ghost:
+ "bg-transparent text-navy border-line hover:bg-paper",
+ danger:
+ "bg-paper text-error border-line hover:bg-[#fcecea] hover:border-error",
+ },
+ size: {
+ sm: "px-2.5 py-1 text-[11px] rounded",
+ md: "px-3.5 py-2 text-xs rounded",
+ lg: "px-4 py-2.5 text-[13px] rounded",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "md",
+ },
+ },
+);
diff --git a/app/src/components/Card/Card.stories.tsx b/app/src/components/Card/Card.stories.tsx
new file mode 100644
index 0000000..de4fae9
--- /dev/null
+++ b/app/src/components/Card/Card.stories.tsx
@@ -0,0 +1,56 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { fn } from "@storybook/test";
+import { Card, CardHead } from "./Card";
+import { Button } from "@/components";
+import { Eyebrow } from "@/components";
+
+const meta: Meta = {
+ title: "Primitives/Card",
+ component: Card,
+ parameters: { layout: "padded" },
+};
+export default meta;
+
+type Story = StoryObj;
+
+export const Plain: Story = {
+ render: () => (
+
+ Calls this month
+ 12,348
+
+ ),
+};
+
+export const WithHead: Story = {
+ render: () => (
+
+
+ View all
+
+ }
+ />
+ Table content goes here.
+
+ ),
+};
+
+export const Flush: Story = {
+ render: () => (
+
+ Flush card – host owns padding
+
+ ),
+};
+
+export const Elevated: Story = {
+ render: () => (
+
+ Elevated
+ Used for overlays or featured surfaces.
+
+ ),
+};
diff --git a/app/src/components/Card/Card.test.tsx b/app/src/components/Card/Card.test.tsx
new file mode 100644
index 0000000..0c3fe1d
--- /dev/null
+++ b/app/src/components/Card/Card.test.tsx
@@ -0,0 +1,62 @@
+import { describe, expect, it } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { Card, CardHead, DATA_TEST_ID } from "./Card";
+
+describe("Card", () => {
+ it("renders children inside a styled container", () => {
+ // GIVEN expected card contents
+ const givenCardContents = "contents";
+
+ // WHEN we render a Card with those contents
+ render({givenCardContents});
+
+ // THEN the container carries the given contents
+ expect(screen.getByTestId(DATA_TEST_ID.CONTAINER)).toHaveTextContent(
+ givenCardContents,
+ );
+ });
+
+ it("omits inner padding when flush", () => {
+ // GIVEN a Card with flush=true
+ // WHEN we render it
+ render(x);
+
+ // THEN the rendered element does not carry the p-5 utility
+ expect(screen.getByTestId(DATA_TEST_ID.CONTAINER).className).not.toMatch(/p-5/);
+ });
+
+ it("applies the elevated shadow class when elevated", () => {
+ // GIVEN a Card with elevated=true
+ // WHEN we render it
+ render(x);
+
+ // THEN the container carries the elevated shadow utility
+ expect(screen.getByTestId(DATA_TEST_ID.CONTAINER).className).toMatch(
+ /shadow-card-2/,
+ );
+ });
+});
+
+describe("CardHead", () => {
+ it("renders title and action slots", () => {
+ // GIVEN a title text and action label
+ const givenCardTitle = "Title";
+ const givenCardActionLabel = "act";
+
+ // WHEN we render a CardHead with that title and action
+ render(
+ {givenCardActionLabel}}
+ />,
+ );
+
+ // THEN both slots carry their given content
+ expect(screen.getByTestId(DATA_TEST_ID.HEAD_TITLE)).toHaveTextContent(
+ givenCardTitle,
+ );
+ expect(screen.getByTestId(DATA_TEST_ID.HEAD_ACTION)).toHaveTextContent(
+ givenCardActionLabel,
+ );
+ });
+});
diff --git a/app/src/components/Card/Card.tsx b/app/src/components/Card/Card.tsx
new file mode 100644
index 0000000..d6128cb
--- /dev/null
+++ b/app/src/components/Card/Card.tsx
@@ -0,0 +1,77 @@
+import { forwardRef, type HTMLAttributes } from "react";
+import { mergeClassNames } from "@/lib/mergeClassNames.ts";
+
+const uniqueId = "c2f2fc50-0164-42f5-a4d6-6c2d6f16450f";
+
+export const DATA_TEST_ID = {
+ CONTAINER: `card-container-${uniqueId}`,
+ HEAD_CONTAINER: `card-head-container-${uniqueId}`,
+ HEAD_TITLE: `card-head-title-${uniqueId}`,
+ HEAD_ACTION: `card-head-action-${uniqueId}`,
+};
+
+export interface CardProps extends HTMLAttributes {
+ /** Remove the default padding — use when the card hosts its own layout. */
+ flush?: boolean;
+ /** Use the elevated shadow style instead of flat borders. */
+ elevated?: boolean;
+}
+
+export const Card = forwardRef(function Card(
+ { flush, elevated, className, children, ...rest },
+ ref,
+) {
+ return (
+
+ {children}
+
+ );
+});
+
+export interface CardHeadProps
+ extends Omit, "title"> {
+ title?: React.ReactNode;
+ action?: React.ReactNode;
+}
+
+export function CardHead({
+ title,
+ action,
+ className,
+ children,
+ ...rest
+}: CardHeadProps) {
+ return (
+
+ {title && (
+
+ {title}
+
+ )}
+ {children}
+ {action && (
+ {action}
+ )}
+
+ );
+}
diff --git a/app/src/components/CodeBlock/CodeBlock.stories.tsx b/app/src/components/CodeBlock/CodeBlock.stories.tsx
new file mode 100644
index 0000000..accc442
--- /dev/null
+++ b/app/src/components/CodeBlock/CodeBlock.stories.tsx
@@ -0,0 +1,42 @@
+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;
+
+const curl = `curl -X POST https://api.classifier.tabiya.tech/v1/classify \\
+ -H "x-api-key: $TABIYA_KEY" \\
+ -H "Content-Type: application/json" \\
+ -d '{"text":"Senior Data Engineer with Python."}'`;
+
+const response = `{
+ "status": "healthy",
+ "service": "classify-api",
+ "dependencies": { "ner_api": "healthy", "nel_api": "healthy" }
+}`;
+
+export const Default: Story = {
+ args: { code: curl },
+};
+
+export const Muted: Story = {
+ args: { code: response, muted: true },
+};
+
+export const Copyable: Story = {
+ args: { code: curl, copyable: true },
+};
+
+export const Scrolling: Story = {
+ args: {
+ copyable: true,
+ maxHeight: 180,
+ code: Array.from({ length: 30 }, (_, i) => `line ${i + 1}: payload data ${i}`).join("\n"),
+ },
+};
diff --git a/app/src/components/CodeBlock/CodeBlock.test.tsx b/app/src/components/CodeBlock/CodeBlock.test.tsx
new file mode 100644
index 0000000..92f8b60
--- /dev/null
+++ b/app/src/components/CodeBlock/CodeBlock.test.tsx
@@ -0,0 +1,44 @@
+import { describe, expect, it, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { CodeBlock, DATA_TEST_ID } from "./CodeBlock";
+
+describe("CodeBlock", () => {
+ it("renders the code prop as the body", () => {
+ // GIVEN an expected code snippet
+ const givenCodeSnippet = "curl https://example.com";
+
+ // WHEN we render a CodeBlock with that snippet
+ render();
+
+ // THEN the rendered code carries the given snippet
+ expect(screen.getByTestId(DATA_TEST_ID.CONTAINER)).toHaveTextContent(
+ givenCodeSnippet,
+ );
+ });
+
+ it("applies the muted variant class when muted", () => {
+ // GIVEN a CodeBlock with muted=true
+ // WHEN we render it
+ render();
+
+ // THEN the pre element carries the muted utility class
+ expect(screen.getByTestId(DATA_TEST_ID.CONTAINER).className).toMatch(/muted/);
+ });
+
+ it("copies text to clipboard when the copy button is clicked", async () => {
+ // GIVEN a clipboard spy and the expected code snippet
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ Object.assign(navigator, { clipboard: { writeText } });
+ const givenCodeSnippet = "hello world";
+
+ // AND a copyable CodeBlock with that snippet
+ render();
+
+ // WHEN the user clicks the copy button
+ await userEvent.click(screen.getByTestId(DATA_TEST_ID.COPY_BUTTON));
+
+ // THEN the clipboard is called with the given snippet
+ expect(writeText).toHaveBeenCalledWith(givenCodeSnippet);
+ });
+});
diff --git a/app/src/components/CodeBlock/CodeBlock.tsx b/app/src/components/CodeBlock/CodeBlock.tsx
new file mode 100644
index 0000000..eba8b24
--- /dev/null
+++ b/app/src/components/CodeBlock/CodeBlock.tsx
@@ -0,0 +1,73 @@
+import type { HTMLAttributes, ReactNode } from "react";
+import { useState } from "react";
+import { mergeClassNames } from "@/lib/mergeClassNames.ts";
+
+const uniqueId = "69e59609-0d9a-4928-80d6-9c727815def9";
+
+export const DATA_TEST_ID = {
+ CONTAINER: `code-block-container-${uniqueId}`,
+ COPY_BUTTON: `code-block-copy-button-${uniqueId}`,
+};
+
+export interface CodeBlockProps extends HTMLAttributes {
+ /** Dim the background — used for response examples in Docs. */
+ muted?: boolean;
+ /** Show a copy button in the top-right that copies the rendered text. */
+ copyable?: boolean;
+ /** The text content. Children are rendered if provided; otherwise `code` is shown. */
+ code?: string;
+ children?: ReactNode;
+ /** Max height before the block scrolls vertically. */
+ maxHeight?: number | string;
+}
+
+export function CodeBlock({
+ muted,
+ copyable,
+ code,
+ children,
+ className,
+ maxHeight,
+ ...rest
+}: CodeBlockProps) {
+ const [copied, setCopied] = useState(false);
+ const text = code ?? (typeof children === "string" ? children : "");
+
+ async function onCopy() {
+ if (!text) return;
+ try {
+ await navigator.clipboard.writeText(text);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1500);
+ } catch {
+ // navigator.clipboard may be unavailable in some test envs; swallow.
+ }
+ }
+
+ return (
+
+ {copyable && (
+
+
+
+ )}
+ {children ?? code}
+
+ );
+}
diff --git a/app/src/components/Divider/Divider.stories.tsx b/app/src/components/Divider/Divider.stories.tsx
new file mode 100644
index 0000000..3a84af0
--- /dev/null
+++ b/app/src/components/Divider/Divider.stories.tsx
@@ -0,0 +1,31 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { Divider } from "./Divider";
+
+const meta: Meta = {
+ title: "Primitives/Divider",
+ component: Divider,
+ parameters: { layout: "padded" },
+};
+export default meta;
+
+type Story = StoryObj;
+
+export const Solid: Story = {
+ render: () => (
+
+ ),
+};
+
+export const Dashed: Story = {
+ render: () => (
+
+ ),
+};
diff --git a/app/src/components/Divider/Divider.test.tsx b/app/src/components/Divider/Divider.test.tsx
new file mode 100644
index 0000000..20857c9
--- /dev/null
+++ b/app/src/components/Divider/Divider.test.tsx
@@ -0,0 +1,33 @@
+import { describe, expect, it } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { Divider, DATA_TEST_ID } from "./Divider";
+
+describe("Divider", () => {
+ it("renders as a separator with horizontal orientation by default", () => {
+ // GIVEN the default expected orientation
+ const expectedDefaultOrientation = "horizontal";
+
+ // WHEN we render the default Divider
+ render();
+
+ // THEN it exposes the separator role with horizontal orientation
+ const dividerNode = screen.getByTestId(DATA_TEST_ID.CONTAINER);
+ expect(dividerNode.getAttribute("role")).toBe("separator");
+ expect(dividerNode.getAttribute("aria-orientation")).toBe(
+ expectedDefaultOrientation,
+ );
+ });
+
+ it("uses vertical orientation when requested", () => {
+ // GIVEN a vertical orientation
+ const givenOrientation = "vertical" as const;
+
+ // WHEN we render the Divider with that orientation
+ render();
+
+ // THEN the separator reports the given orientation
+ expect(
+ screen.getByTestId(DATA_TEST_ID.CONTAINER).getAttribute("aria-orientation"),
+ ).toBe(givenOrientation);
+ });
+});
diff --git a/app/src/components/Divider/Divider.tsx b/app/src/components/Divider/Divider.tsx
new file mode 100644
index 0000000..f241ab6
--- /dev/null
+++ b/app/src/components/Divider/Divider.tsx
@@ -0,0 +1,41 @@
+import type { HTMLAttributes } from "react";
+import { mergeClassNames } from "@/lib/mergeClassNames.ts";
+
+const uniqueId = "a7e3b610-5d29-4f48-91c8-6b04e8d2a1f5";
+
+export const DATA_TEST_ID = {
+ CONTAINER: `divider-container-${uniqueId}`,
+};
+
+export interface DividerProps extends HTMLAttributes {
+ /** Use a dashed line instead of solid (often signals a soft section break). */
+ dashed?: boolean;
+ /** Vertical orientation for inline use. Defaults to horizontal. */
+ orientation?: "horizontal" | "vertical";
+}
+
+export function Divider({
+ dashed,
+ orientation = "horizontal",
+ className,
+ ...rest
+}: DividerProps) {
+ const vertical = orientation === "vertical";
+ return (
+
+ );
+}
diff --git a/app/src/components/Drawer/Drawer.stories.tsx b/app/src/components/Drawer/Drawer.stories.tsx
new file mode 100644
index 0000000..2d000f8
--- /dev/null
+++ b/app/src/components/Drawer/Drawer.stories.tsx
@@ -0,0 +1,50 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { useState } from "react";
+import { fn } from "@storybook/test";
+import { Drawer } from "./Drawer";
+import { Button } from "@/components";
+
+const meta: Meta = {
+ title: "Primitives/Drawer",
+ component: Drawer,
+ parameters: { layout: "centered" },
+};
+export default meta;
+
+type Story = StoryObj;
+
+export const EntityDetail: Story = {
+ render: () => {
+ const [isOpen, setIsOpen] = useState(false);
+ const onOpen = fn(() => setIsOpen(true));
+ const onClose = fn(() => setIsOpen(false));
+ const onViewInEsco = fn();
+ return (
+ <>
+
+
+
+
+
+ }
+ >
+
+
Top ESCO match: data engineer (94%)
+
Alt match: big data engineer (88%)
+
+
+ >
+ );
+ },
+};
diff --git a/app/src/components/Drawer/Drawer.test.tsx b/app/src/components/Drawer/Drawer.test.tsx
new file mode 100644
index 0000000..7677269
--- /dev/null
+++ b/app/src/components/Drawer/Drawer.test.tsx
@@ -0,0 +1,102 @@
+import { describe, expect, it, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { Drawer, DATA_TEST_ID } from "./Drawer";
+
+describe("Drawer", () => {
+ it("does not render when closed", () => {
+ // GIVEN a Drawer with open=false
+ // WHEN we render it
+ render(
+ {}} title="Hidden">
+ body
+ ,
+ );
+
+ // THEN no panel is in the DOM
+ expect(screen.queryByTestId(DATA_TEST_ID.PANEL)).not.toBeInTheDocument();
+ });
+
+ it("renders eyebrow, title, body and footer when open", () => {
+ // GIVEN content for each slot
+ const givenEyebrowText = "OCCUPATION";
+ const givenTitleText = "Senior Engineer";
+ const givenBodyText = "details body";
+ const givenFooterText = "footer-actions";
+
+ // WHEN we render the open Drawer with all four slots populated
+ render(
+ {}}
+ eyebrow={givenEyebrowText}
+ title={givenTitleText}
+ footer={{givenFooterText}}
+ >
+ {givenBodyText}
+ ,
+ );
+
+ // THEN each slot's node carries the given text
+ expect(screen.getByTestId(DATA_TEST_ID.EYEBROW)).toHaveTextContent(givenEyebrowText);
+ expect(screen.getByTestId(DATA_TEST_ID.TITLE)).toHaveTextContent(givenTitleText);
+ expect(screen.getByTestId(DATA_TEST_ID.BODY)).toHaveTextContent(givenBodyText);
+ expect(screen.getByTestId(DATA_TEST_ID.FOOTER)).toHaveTextContent(givenFooterText);
+ });
+
+ it("invokes onClose on Escape", async () => {
+ // GIVEN an onClose spy
+ const onClose = vi.fn();
+
+ // AND an open Drawer wired to that spy
+ render(
+
+ body
+ ,
+ );
+
+ // WHEN the user presses Escape
+ await userEvent.keyboard("{Escape}");
+
+ // THEN onClose is invoked exactly once
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("invokes onClose when the backdrop is clicked", async () => {
+ // GIVEN an onClose spy
+ const onClose = vi.fn();
+
+ // AND an open Drawer wired to that spy
+ render(
+
+ body
+ ,
+ );
+
+ // WHEN the user mousedowns on the backdrop directly
+ const backdrop = screen.getByTestId(DATA_TEST_ID.BACKDROP);
+ await userEvent.pointer({ keys: "[MouseLeft>]", target: backdrop });
+
+ // THEN onClose is invoked exactly once
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not invoke onClose when closeOnBackdropClick is false", async () => {
+ // GIVEN an onClose spy
+ const onClose = vi.fn();
+
+ // AND a Drawer that ignores backdrop clicks
+ render(
+
+ body
+ ,
+ );
+
+ // WHEN the user mousedowns on the backdrop
+ const backdrop = screen.getByTestId(DATA_TEST_ID.BACKDROP);
+ await userEvent.pointer({ keys: "[MouseLeft>]", target: backdrop });
+
+ // THEN onClose stays untouched
+ expect(onClose).not.toHaveBeenCalled();
+ });
+});
diff --git a/app/src/components/Drawer/Drawer.tsx b/app/src/components/Drawer/Drawer.tsx
new file mode 100644
index 0000000..3ceb214
--- /dev/null
+++ b/app/src/components/Drawer/Drawer.tsx
@@ -0,0 +1,156 @@
+import { useEffect, useRef, type ReactNode } from "react";
+import { createPortal } from "react-dom";
+import { AnimatePresence, motion } from "framer-motion";
+import { mergeClassNames } from "@/lib/mergeClassNames.ts";
+
+const uniqueId = "3b992191-34cd-406b-bf12-5ab1b1729d2b";
+
+export const DATA_TEST_ID = {
+ BACKDROP: `drawer-backdrop-${uniqueId}`,
+ PANEL: `drawer-panel-${uniqueId}`,
+ HEADER: `drawer-header-${uniqueId}`,
+ EYEBROW: `drawer-eyebrow-${uniqueId}`,
+ TITLE: `drawer-title-${uniqueId}`,
+ DESCRIPTION: `drawer-description-${uniqueId}`,
+ BODY: `drawer-body-${uniqueId}`,
+ FOOTER: `drawer-footer-${uniqueId}`,
+};
+
+export interface DrawerProps {
+ open: boolean;
+ onClose: () => void;
+ title?: ReactNode;
+ description?: ReactNode;
+ /** Visual eyebrow above the title (e.g. entity type name). */
+ eyebrow?: ReactNode;
+ /** Sticky footer slot. */
+ footer?: ReactNode;
+ /** Width of the slide-in panel. Defaults to 480px. */
+ width?: number | string;
+ /** Closes the drawer when the backdrop is clicked. Defaults to true. */
+ closeOnBackdropClick?: boolean;
+ className?: string;
+ children?: ReactNode;
+}
+
+const backdropVariants = {
+ hidden: { opacity: 0 },
+ visible: { opacity: 1 },
+};
+
+const panelVariants = {
+ hidden: { x: 24, opacity: 0 },
+ visible: { x: 0, opacity: 1 },
+};
+
+export function Drawer({
+ open,
+ onClose,
+ title,
+ description,
+ eyebrow,
+ footer,
+ width = 480,
+ closeOnBackdropClick = true,
+ className,
+ children,
+}: DrawerProps) {
+ const panelRef = useRef(null);
+
+ useEffect(() => {
+ if (!open) return;
+ function onKey(event: KeyboardEvent) {
+ if (event.key === "Escape") onClose();
+ }
+ document.addEventListener("keydown", onKey);
+ panelRef.current?.focus();
+ return () => document.removeEventListener("keydown", onKey);
+ }, [open, onClose]);
+
+ return createPortal(
+
+ {open && (
+ {
+ if (closeOnBackdropClick && event.target === event.currentTarget) {
+ onClose();
+ }
+ }}
+ className="fixed inset-0 z-[150] flex items-end justify-end bg-ink/35"
+ variants={backdropVariants}
+ initial="hidden"
+ animate="visible"
+ exit="hidden"
+ transition={{ duration: 0.16, ease: "easeOut" }}
+ >
+
+ {(title || eyebrow || description) && (
+
+ {eyebrow && (
+
+ {eyebrow}
+
+ )}
+ {title && (
+
+ {title}
+
+ )}
+ {description && (
+
+ {description}
+
+ )}
+
+ )}
+
+ {children}
+
+ {footer && (
+
+ )}
+
+
+ )}
+ ,
+ document.body,
+ );
+}
diff --git a/app/src/components/EmptyState/EmptyState.stories.tsx b/app/src/components/EmptyState/EmptyState.stories.tsx
new file mode 100644
index 0000000..48c1aa3
--- /dev/null
+++ b/app/src/components/EmptyState/EmptyState.stories.tsx
@@ -0,0 +1,30 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { fn } from "@storybook/test";
+import { EmptyState } from "./EmptyState";
+import { Button } from "@/components";
+
+const meta: Meta = {
+ title: "Primitives/EmptyState",
+ component: EmptyState,
+ parameters: { layout: "padded" },
+};
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ icon: "history",
+ title: "No classifications yet",
+ description: "Run your first classification to see it here.",
+ action: (
+
+ ),
+ },
+};
+
+export const Minimal: Story = {
+ args: { title: "Nothing matches your filters" },
+};
diff --git a/app/src/components/EmptyState/EmptyState.test.tsx b/app/src/components/EmptyState/EmptyState.test.tsx
new file mode 100644
index 0000000..dc356c3
--- /dev/null
+++ b/app/src/components/EmptyState/EmptyState.test.tsx
@@ -0,0 +1,47 @@
+import { describe, expect, it } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { EmptyState, DATA_TEST_ID } from "./EmptyState";
+
+describe("EmptyState", () => {
+ it("renders title and optional description and action", () => {
+ // GIVEN title, description, and action content
+ const givenEmptyStateTitle = "No keys yet";
+ const givenEmptyStateDescription = "Create one above.";
+ const givenEmptyStateActionLabel = "create-cta";
+
+ // WHEN we render an EmptyState with all three slots
+ render(
+ {givenEmptyStateActionLabel}}
+ />,
+ );
+
+ // THEN each slot carries the given content
+ expect(screen.getByTestId(DATA_TEST_ID.TITLE)).toHaveTextContent(
+ givenEmptyStateTitle,
+ );
+ expect(screen.getByTestId(DATA_TEST_ID.DESCRIPTION)).toHaveTextContent(
+ givenEmptyStateDescription,
+ );
+ expect(screen.getByTestId(DATA_TEST_ID.ACTION)).toHaveTextContent(
+ givenEmptyStateActionLabel,
+ );
+ });
+
+ it("renders without a description or action", () => {
+ // GIVEN an EmptyState with just a title
+ const givenEmptyStateTitle = "Nothing here";
+
+ // WHEN we render it
+ render();
+
+ // THEN only the title is visible and the optional slots are absent
+ expect(screen.getByTestId(DATA_TEST_ID.TITLE)).toHaveTextContent(
+ givenEmptyStateTitle,
+ );
+ expect(screen.queryByTestId(DATA_TEST_ID.DESCRIPTION)).not.toBeInTheDocument();
+ expect(screen.queryByTestId(DATA_TEST_ID.ACTION)).not.toBeInTheDocument();
+ });
+});
diff --git a/app/src/components/EmptyState/EmptyState.tsx b/app/src/components/EmptyState/EmptyState.tsx
new file mode 100644
index 0000000..4897e39
--- /dev/null
+++ b/app/src/components/EmptyState/EmptyState.tsx
@@ -0,0 +1,67 @@
+import type { ReactNode } from "react";
+import { mergeClassNames } from "@/lib/mergeClassNames.ts";
+import { Icon, type IconName } from "@/components";
+
+const uniqueId = "a9bb5fab-439b-43c0-be79-70a2e2ad06ce";
+
+export const DATA_TEST_ID = {
+ CONTAINER: `empty-state-container-${uniqueId}`,
+ ICON_WRAP: `empty-state-icon-wrap-${uniqueId}`,
+ TITLE: `empty-state-title-${uniqueId}`,
+ DESCRIPTION: `empty-state-description-${uniqueId}`,
+ ACTION: `empty-state-action-${uniqueId}`,
+};
+
+export interface EmptyStateProps {
+ icon?: IconName;
+ title: ReactNode;
+ description?: ReactNode;
+ action?: ReactNode;
+ className?: string;
+}
+
+export function EmptyState({
+ icon,
+ title,
+ description,
+ action,
+ className,
+}: EmptyStateProps) {
+ return (
+
+ {icon && (
+
+
+
+ )}
+
+ {title}
+
+ {description && (
+
+ {description}
+
+ )}
+ {action && (
+
+ {action}
+
+ )}
+
+ );
+}
diff --git a/app/src/components/Eyebrow/Eyebrow.stories.tsx b/app/src/components/Eyebrow/Eyebrow.stories.tsx
new file mode 100644
index 0000000..8682b47
--- /dev/null
+++ b/app/src/components/Eyebrow/Eyebrow.stories.tsx
@@ -0,0 +1,23 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { Eyebrow } from "./Eyebrow";
+
+const meta: Meta = {
+ title: "Primitives/Eyebrow",
+ component: Eyebrow,
+ parameters: { layout: "padded" },
+ args: { children: "Workspace · Classifier" },
+};
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const AboveHeading: Story = {
+ render: () => (
+
+ Pipeline · Stage 01
+
Named Entity Recognition
+
+ ),
+};
diff --git a/app/src/components/Eyebrow/Eyebrow.test.tsx b/app/src/components/Eyebrow/Eyebrow.test.tsx
new file mode 100644
index 0000000..fcd21be
--- /dev/null
+++ b/app/src/components/Eyebrow/Eyebrow.test.tsx
@@ -0,0 +1,19 @@
+import { describe, expect, it } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { Eyebrow, DATA_TEST_ID } from "./Eyebrow";
+
+describe("Eyebrow", () => {
+ it("renders children inside an .eyebrow container", () => {
+ // GIVEN an expected eyebrow text
+ const givenEyebrowText = "Settings · Pipeline";
+
+ // WHEN we render an Eyebrow with that text
+ render({givenEyebrowText});
+
+ // THEN the eyebrow container is in the DOM, carries the given text, and the editorial class
+ const eyebrowNode = screen.getByTestId(DATA_TEST_ID.CONTAINER);
+ expect(eyebrowNode).toBeInTheDocument();
+ expect(eyebrowNode).toHaveTextContent(givenEyebrowText);
+ expect(eyebrowNode.className).toMatch(/eyebrow/);
+ });
+});
diff --git a/app/src/components/Eyebrow/Eyebrow.tsx b/app/src/components/Eyebrow/Eyebrow.tsx
new file mode 100644
index 0000000..4590738
--- /dev/null
+++ b/app/src/components/Eyebrow/Eyebrow.tsx
@@ -0,0 +1,23 @@
+import type { HTMLAttributes } from "react";
+import { mergeClassNames } from "@/lib/mergeClassNames.ts";
+
+const uniqueId = "3c4d9a1b-7e62-4a85-9f0c-2d8b6e7a4310";
+
+export const DATA_TEST_ID = {
+ CONTAINER: `eyebrow-container-${uniqueId}`,
+};
+
+export type EyebrowProps = HTMLAttributes;
+
+/** Mono uppercase micro-label that sits above a heading. */
+export function Eyebrow({ className, children, ...rest }: EyebrowProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/app/src/components/FormField/FormField.stories.tsx b/app/src/components/FormField/FormField.stories.tsx
new file mode 100644
index 0000000..f007361
--- /dev/null
+++ b/app/src/components/FormField/FormField.stories.tsx
@@ -0,0 +1,50 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { fn } from "@storybook/test";
+import { FormField } from "./FormField";
+import { Input } from "@/components";
+import { Textarea } from "@/components";
+
+const meta: Meta = {
+ title: "Primitives/FormField",
+ component: FormField,
+ parameters: { layout: "padded" },
+};
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: () => (
+
+
+
+ ),
+};
+
+export const Required: Story = {
+ render: () => (
+
+
+
+ ),
+};
+
+export const WithError: Story = {
+ render: () => (
+
+
+
+ ),
+};
+
+export const Textareaish: Story = {
+ render: () => (
+
+
+
+ ),
+};
diff --git a/app/src/components/FormField/FormField.test.tsx b/app/src/components/FormField/FormField.test.tsx
new file mode 100644
index 0000000..1d0e61b
--- /dev/null
+++ b/app/src/components/FormField/FormField.test.tsx
@@ -0,0 +1,60 @@
+import { describe, expect, it } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { FormField, DATA_TEST_ID } from "./FormField";
+import { Input, INPUT_DATA_TEST_ID, LABEL_DATA_TEST_ID } from "@/components";
+
+describe("FormField", () => {
+ it("associates label, help text, and control via aria-describedby", () => {
+ // GIVEN a label and helper text for the field
+ const givenLabelText = "Key";
+ const givenHelpText = "Pick something memorable";
+
+ // WHEN we render a FormField wrapping an Input with that label and help
+ render(
+
+
+ ,
+ );
+
+ // THEN the help node carries the given text and the input is wired to it via aria-describedby
+ const inputElement = screen.getByTestId(INPUT_DATA_TEST_ID.CONTAINER);
+ const helpElement = screen.getByTestId(DATA_TEST_ID.HELP);
+ expect(helpElement).toHaveTextContent(givenHelpText);
+ expect(inputElement.getAttribute("aria-describedby")).toBe(helpElement.id);
+ });
+
+ it("renders the error message and sets aria-invalid on the child", () => {
+ // GIVEN an expected error message
+ const givenErrorMessage = "That email isn't valid";
+
+ // WHEN we render a FormField with that error
+ render(
+
+
+ ,
+ );
+
+ // THEN the error node carries the given message and the child input is aria-invalid
+ expect(screen.getByTestId(DATA_TEST_ID.ERROR)).toHaveTextContent(
+ givenErrorMessage,
+ );
+ expect(
+ screen.getByTestId(INPUT_DATA_TEST_ID.CONTAINER).getAttribute("aria-invalid"),
+ ).toBe("true");
+ });
+
+ it("shows the required asterisk when required", () => {
+ // GIVEN a FormField with required=true
+ // WHEN we render it
+ render(
+
+
+ ,
+ );
+
+ // THEN the required mark span is visible next to the label
+ expect(
+ screen.getByTestId(LABEL_DATA_TEST_ID.REQUIRED_MARK),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/app/src/components/FormField/FormField.tsx b/app/src/components/FormField/FormField.tsx
new file mode 100644
index 0000000..8a601bf
--- /dev/null
+++ b/app/src/components/FormField/FormField.tsx
@@ -0,0 +1,94 @@
+import {
+ Children,
+ cloneElement,
+ isValidElement,
+ useId,
+ type HTMLAttributes,
+ type ReactElement,
+ type ReactNode,
+} from "react";
+import { mergeClassNames } from "@/lib/mergeClassNames.ts";
+import { Label } from "@/components";
+
+const uniqueId = "3ff67c65-2da4-4ead-bbd5-61904d9b827f";
+
+export const DATA_TEST_ID = {
+ CONTAINER: `form-field-container-${uniqueId}`,
+ HELP: `form-field-help-${uniqueId}`,
+ ERROR: `form-field-error-${uniqueId}`,
+};
+
+export interface FormFieldProps extends HTMLAttributes {
+ /** Visible label. Omit only for unlabeled inline fields. */
+ label?: ReactNode;
+ /** Mark the field required. */
+ required?: boolean;
+ /** Helper text rendered below the control. */
+ help?: ReactNode;
+ /** Error message — when set, the field renders in error state. */
+ error?: ReactNode;
+ /** The form control. Must accept id, aria-describedby, and aria-invalid props. */
+ children: ReactNode;
+}
+
+export function FormField({
+ label,
+ required,
+ help,
+ error,
+ className,
+ children,
+ ...rest
+}: FormFieldProps) {
+ const fieldId = useId();
+ const helpId = `${fieldId}-help`;
+ const errorId = `${fieldId}-error`;
+ const describedBy =
+ [error ? errorId : null, help ? helpId : null].filter(Boolean).join(" ") ||
+ undefined;
+
+ // Inject id + describedBy + invalid into the single child control if it's an element.
+ const enhancedChild =
+ isValidElement(children) && Children.count(children) === 1
+ ? cloneElement(children as ReactElement, {
+ id: (children as ReactElement).props.id ?? fieldId,
+ "aria-describedby": describedBy,
+ invalid: error
+ ? true
+ : (children as ReactElement).props.invalid,
+ })
+ : children;
+
+ return (
+
+ {label && (
+
+ )}
+ {enhancedChild}
+ {error ? (
+
+ {error}
+
+ ) : help ? (
+
+ {help}
+
+ ) : null}
+
+ );
+}
diff --git a/app/src/components/Icon/Icon.stories.tsx b/app/src/components/Icon/Icon.stories.tsx
new file mode 100644
index 0000000..9293333
--- /dev/null
+++ b/app/src/components/Icon/Icon.stories.tsx
@@ -0,0 +1,55 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { Icon } from "./Icon";
+import type { IconName } from "./Icon.types";
+
+const meta: Meta = {
+ title: "Primitives/Icon",
+ component: Icon,
+ parameters: { layout: "padded" },
+ args: { size: 24 },
+};
+export default meta;
+
+type Story = StoryObj;
+
+const allNames: IconName[] = [
+ "classify",
+ "dashboard",
+ "config",
+ "key",
+ "docs",
+ "history",
+ "copy",
+ "arrowRight",
+ "external",
+ "plus",
+ "trash",
+ "check",
+ "close",
+ "filter",
+ "download",
+ "upload",
+ "search",
+ "spark",
+ "globe",
+];
+
+export const Gallery: Story = {
+ render: (args) => (
+
+ {allNames.map((name) => (
+
+
+ {name}
+
+ ))}
+
+ ),
+};
+
+export const Single: Story = {
+ args: { name: "arrowRight", size: 32 },
+};
diff --git a/app/src/components/Icon/Icon.test.tsx b/app/src/components/Icon/Icon.test.tsx
new file mode 100644
index 0000000..877c27e
--- /dev/null
+++ b/app/src/components/Icon/Icon.test.tsx
@@ -0,0 +1,43 @@
+import { describe, expect, it } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { Icon, DATA_TEST_ID } from "./Icon";
+
+describe("Icon", () => {
+ it("renders an svg with the given icon name as a data attribute", () => {
+ // GIVEN an icon name to render
+ const givenIconName = "check";
+
+ // WHEN we render the Icon with that name
+ render();
+
+ // THEN an svg is rendered and the icon name is recorded for selector targeting
+ const svg = screen.getByTestId(DATA_TEST_ID.SVG);
+ expect(svg.tagName).toBe("svg");
+ expect(svg.getAttribute("data-icon")).toBe(givenIconName);
+ });
+
+ it("applies a custom size to width and height", () => {
+ // GIVEN an expected pixel size
+ const givenIconSize = 32;
+ const expectedSizeAttribute = String(givenIconSize);
+
+ // WHEN we render the Icon with that size
+ render();
+
+ // THEN the svg has matching width and height attributes
+ const svg = screen.getByTestId(DATA_TEST_ID.SVG);
+ expect(svg.getAttribute("width")).toBe(expectedSizeAttribute);
+ expect(svg.getAttribute("height")).toBe(expectedSizeAttribute);
+ });
+
+ it("is marked aria-hidden so it is decorative by default", () => {
+ // GIVEN the default Icon
+ // WHEN we render it
+ render();
+
+ // THEN the svg is hidden from assistive tech
+ expect(screen.getByTestId(DATA_TEST_ID.SVG).getAttribute("aria-hidden")).toBe(
+ "true",
+ );
+ });
+});
diff --git a/app/src/components/Icon/Icon.tsx b/app/src/components/Icon/Icon.tsx
new file mode 100644
index 0000000..b0af84f
--- /dev/null
+++ b/app/src/components/Icon/Icon.tsx
@@ -0,0 +1,115 @@
+import type { IconName, IconProps } from "./Icon.types";
+
+const uniqueId = "5789bcfb-a547-4739-9446-d67a89d8c368";
+
+export const DATA_TEST_ID = {
+ SVG: `icon-svg-${uniqueId}`,
+};
+
+const paths: Record = {
+ classify: (
+ <>
+
+
+ >
+ ),
+ dashboard: (
+ <>
+
+
+
+
+ >
+ ),
+ config: (
+ <>
+
+
+ >
+ ),
+ key: (
+ <>
+
+
+ >
+ ),
+ docs: (
+ <>
+
+
+ >
+ ),
+ history: (
+ <>
+
+
+ >
+ ),
+ copy: (
+ <>
+
+
+ >
+ ),
+ arrowRight: ,
+ external: (
+
+ ),
+ plus: ,
+ trash: (
+
+ ),
+ check: ,
+ close: ,
+ filter: ,
+ download: ,
+ upload: ,
+ search: (
+ <>
+
+
+ >
+ ),
+ globe: (
+ <>
+
+
+ >
+ ),
+ spark: (
+
+ ),
+ pipelines: (
+ <>
+
+
+
+
+ >
+ ),
+};
+
+export function Icon({ name, size = 14, ...rest }: IconProps) {
+ return (
+
+ );
+}
diff --git a/app/src/components/Icon/Icon.types.ts b/app/src/components/Icon/Icon.types.ts
new file mode 100644
index 0000000..586ae88
--- /dev/null
+++ b/app/src/components/Icon/Icon.types.ts
@@ -0,0 +1,29 @@
+import type { SVGProps } from "react";
+
+export type IconName =
+ | "classify"
+ | "dashboard"
+ | "config"
+ | "key"
+ | "docs"
+ | "history"
+ | "copy"
+ | "arrowRight"
+ | "external"
+ | "plus"
+ | "trash"
+ | "check"
+ | "close"
+ | "filter"
+ | "download"
+ | "upload"
+ | "spark"
+ | "search"
+ | "globe"
+ | "pipelines";
+
+export interface IconProps extends SVGProps {
+ name: IconName;
+ /** Pixel size for both width and height. Defaults to 14. */
+ size?: number;
+}
diff --git a/app/src/components/IconButton/IconButton.stories.tsx b/app/src/components/IconButton/IconButton.stories.tsx
new file mode 100644
index 0000000..c2d919c
--- /dev/null
+++ b/app/src/components/IconButton/IconButton.stories.tsx
@@ -0,0 +1,35 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { fn } from "@storybook/test";
+import { IconButton } from "./IconButton";
+
+const meta: Meta = {
+ title: "Primitives/IconButton",
+ component: IconButton,
+ parameters: { layout: "centered" },
+ argTypes: {
+ variant: { control: "select", options: ["default", "ghost", "primary", "danger"] },
+ size: { control: "select", options: ["sm", "md"] },
+ },
+ args: { icon: "copy", "aria-label": "Copy", onClick: fn() },
+};
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+export const Primary: Story = { args: { variant: "primary", icon: "arrowRight", "aria-label": "Next" } };
+export const Danger: Story = { args: { variant: "danger", icon: "trash", "aria-label": "Delete" } };
+
+export const Cluster: Story = {
+ render: () => {
+ const onClick = fn();
+ return (
+
+
+
+
+
+
+ );
+ },
+};
diff --git a/app/src/components/IconButton/IconButton.test.tsx b/app/src/components/IconButton/IconButton.test.tsx
new file mode 100644
index 0000000..57053e4
--- /dev/null
+++ b/app/src/components/IconButton/IconButton.test.tsx
@@ -0,0 +1,47 @@
+import { describe, expect, it, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { IconButton, DATA_TEST_ID } from "./IconButton";
+import { ICON_DATA_TEST_ID } from "@/components";
+
+describe("IconButton", () => {
+ it("exposes its aria-label and renders an Icon inside", () => {
+ // GIVEN an accessible label and icon for the button
+ const givenAriaLabel = "Copy";
+ const givenIconName = "copy";
+
+ // WHEN we render an IconButton with that label and icon
+ render();
+
+ // THEN the button carries the given aria-label and an Icon svg is inside it
+ const iconButton = screen.getByTestId(DATA_TEST_ID.CONTAINER);
+ expect(iconButton.getAttribute("aria-label")).toBe(givenAriaLabel);
+ expect(screen.getByTestId(ICON_DATA_TEST_ID.SVG)).toBeInTheDocument();
+ });
+
+ it("fires onClick", async () => {
+ // GIVEN an onClick spy
+ const onClick = vi.fn();
+
+ // AND a rendered IconButton bound to that spy
+ render();
+
+ // WHEN the user clicks it
+ await userEvent.click(screen.getByTestId(DATA_TEST_ID.CONTAINER));
+
+ // THEN onClick is called exactly once
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not fire onClick when disabled", async () => {
+ // GIVEN a disabled IconButton with an onClick spy
+ const onClick = vi.fn();
+ render();
+
+ // WHEN the user clicks it
+ await userEvent.click(screen.getByTestId(DATA_TEST_ID.CONTAINER));
+
+ // THEN onClick is never called
+ expect(onClick).not.toHaveBeenCalled();
+ });
+});
diff --git a/app/src/components/IconButton/IconButton.tsx b/app/src/components/IconButton/IconButton.tsx
new file mode 100644
index 0000000..c74c6d6
--- /dev/null
+++ b/app/src/components/IconButton/IconButton.tsx
@@ -0,0 +1,57 @@
+import { forwardRef, type ButtonHTMLAttributes } from "react";
+import { mergeClassNames } from "@/lib/mergeClassNames.ts";
+import { Icon } from "@/components";
+import type { IconName } from "@/components";
+
+const uniqueId = "193eaebd-acff-4efe-9dd5-0bfd0eacd22f";
+
+export const DATA_TEST_ID = {
+ CONTAINER: `icon-button-container-${uniqueId}`,
+};
+
+export interface IconButtonProps extends ButtonHTMLAttributes {
+ icon: IconName;
+ /** Required accessible label — IconButton has no visible text. */
+ "aria-label": string;
+ variant?: "default" | "ghost" | "primary" | "danger";
+ size?: "sm" | "md";
+}
+
+const variantClasses: Record, string> = {
+ default:
+ "bg-paper text-navy border-line-strong hover:bg-cream-200 hover:border-navy",
+ ghost: "bg-transparent text-navy border-line hover:bg-paper",
+ primary: "bg-navy text-lime border-navy hover:bg-navy-700",
+ danger: "bg-paper text-error border-line hover:bg-[#fcecea] hover:border-error",
+};
+
+const sizeClasses: Record, string> = {
+ sm: "h-6 w-6 rounded-sm",
+ md: "h-8 w-8 rounded",
+};
+
+export const IconButton = forwardRef(
+ function IconButton(
+ { icon, variant = "ghost", size = "md", className, type = "button", ...rest },
+ ref,
+ ) {
+ return (
+
+ );
+ },
+);
diff --git a/app/src/components/Input/Input.stories.tsx b/app/src/components/Input/Input.stories.tsx
new file mode 100644
index 0000000..364ade3
--- /dev/null
+++ b/app/src/components/Input/Input.stories.tsx
@@ -0,0 +1,18 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { fn } from "@storybook/test";
+import { Input } from "./Input";
+
+const meta: Meta = {
+ title: "Primitives/Input",
+ component: Input,
+ parameters: { layout: "padded" },
+ args: { placeholder: "you@tabiya.org", onChange: fn(), onFocus: fn(), onBlur: fn() },
+};
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+export const Mono: Story = { args: { mono: true, placeholder: "tabiya_sk_…" } };
+export const Invalid: Story = { args: { invalid: true, defaultValue: "not-an-email" } };
+export const Password: Story = { args: { type: "password", defaultValue: "secret" } };
diff --git a/app/src/components/Input/Input.test.tsx b/app/src/components/Input/Input.test.tsx
new file mode 100644
index 0000000..5d78bd3
--- /dev/null
+++ b/app/src/components/Input/Input.test.tsx
@@ -0,0 +1,57 @@
+import { describe, expect, it, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { Input, DATA_TEST_ID } from "./Input";
+
+describe("Input", () => {
+ it("accepts user keystrokes", async () => {
+ // GIVEN text the user is expected to type
+ const givenTypedText = "Sara";
+
+ // AND a rendered Input
+ render();
+
+ // WHEN the user types that text into the input
+ await userEvent.type(screen.getByTestId(DATA_TEST_ID.CONTAINER), givenTypedText);
+
+ // THEN the input's value reflects the typed text
+ expect(
+ (screen.getByTestId(DATA_TEST_ID.CONTAINER) as HTMLInputElement).value,
+ ).toBe(givenTypedText);
+ });
+
+ it("fires onChange for each keystroke", async () => {
+ // GIVEN an onChange spy and a sequence of characters to type
+ const onChange = vi.fn();
+ const givenTypedText = "abc";
+
+ // AND a rendered Input bound to that spy
+ render();
+
+ // WHEN the user types each character
+ await userEvent.type(screen.getByTestId(DATA_TEST_ID.CONTAINER), givenTypedText);
+
+ // THEN onChange is invoked once per keystroke
+ expect(onChange).toHaveBeenCalledTimes(givenTypedText.length);
+ });
+
+ it("applies aria-invalid when invalid", () => {
+ // GIVEN an Input with invalid=true
+ // WHEN we render it
+ render();
+
+ // THEN aria-invalid='true' is on the element
+ expect(
+ screen.getByTestId(DATA_TEST_ID.CONTAINER).getAttribute("aria-invalid"),
+ ).toBe("true");
+ });
+
+ it("uses the mono face when requested", () => {
+ // GIVEN an Input with mono=true
+ // WHEN we render it
+ render();
+
+ // THEN the input carries the mono utility class
+ expect(screen.getByTestId(DATA_TEST_ID.CONTAINER).className).toMatch(/font-mono/);
+ });
+});
diff --git a/app/src/components/Input/Input.tsx b/app/src/components/Input/Input.tsx
new file mode 100644
index 0000000..75a7a2d
--- /dev/null
+++ b/app/src/components/Input/Input.tsx
@@ -0,0 +1,37 @@
+import { forwardRef, type InputHTMLAttributes } from "react";
+import { mergeClassNames } from "@/lib/mergeClassNames.ts";
+
+const uniqueId = "c55c4d0a-880d-41d9-854f-ed7c268a27fd";
+
+export const DATA_TEST_ID = {
+ CONTAINER: `input-container-${uniqueId}`,
+};
+
+export interface InputProps extends InputHTMLAttributes {
+ /** Use the monospace face — for API keys, model IDs, etc. */
+ mono?: boolean;
+ /** Visual error state — pairs with FormField's error message. */
+ invalid?: boolean;
+}
+
+export const Input = forwardRef(function Input(
+ { mono, invalid, className, ...rest },
+ ref,
+) {
+ return (
+
+ );
+});
diff --git a/app/src/components/Kbd/Kbd.stories.tsx b/app/src/components/Kbd/Kbd.stories.tsx
new file mode 100644
index 0000000..d2d41d1
--- /dev/null
+++ b/app/src/components/Kbd/Kbd.stories.tsx
@@ -0,0 +1,25 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { Kbd } from "./Kbd";
+
+const meta: Meta = {
+ title: "Primitives/Kbd",
+ component: Kbd,
+ parameters: { layout: "centered" },
+ args: { children: "⌘ K" },
+};
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const Combinations: Story = {
+ render: () => (
+
+ ⌘ K
+ ⌘ ⇧ P
+ Esc
+ ↵
+
+ ),
+};
diff --git a/app/src/components/Kbd/Kbd.test.tsx b/app/src/components/Kbd/Kbd.test.tsx
new file mode 100644
index 0000000..b049b96
--- /dev/null
+++ b/app/src/components/Kbd/Kbd.test.tsx
@@ -0,0 +1,18 @@
+import { describe, expect, it } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { Kbd, DATA_TEST_ID } from "./Kbd";
+
+describe("Kbd", () => {
+ it("renders inside a element", () => {
+ // GIVEN an expected key combo
+ const givenKeyCombo = "⌘ K";
+
+ // WHEN we render a Kbd with that combo
+ render({givenKeyCombo});
+
+ // THEN the rendered node is a tag carrying the given combo
+ const kbdNode = screen.getByTestId(DATA_TEST_ID.CONTAINER);
+ expect(kbdNode.tagName).toBe("KBD");
+ expect(kbdNode).toHaveTextContent(givenKeyCombo);
+ });
+});
diff --git a/app/src/components/Kbd/Kbd.tsx b/app/src/components/Kbd/Kbd.tsx
new file mode 100644
index 0000000..152f18e
--- /dev/null
+++ b/app/src/components/Kbd/Kbd.tsx
@@ -0,0 +1,27 @@
+import type { HTMLAttributes } from "react";
+import { mergeClassNames } from "@/lib/mergeClassNames.ts";
+
+const uniqueId = "f2a0c5d4-9b18-4d63-8a72-5e9c10b3a4d6";
+
+export const DATA_TEST_ID = {
+ CONTAINER: `kbd-container-${uniqueId}`,
+};
+
+export type KbdProps = HTMLAttributes;
+
+/** Inline keyboard hint chip, e.g. ⌘K. */
+export function Kbd({ className, children, ...rest }: KbdProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/app/src/components/Label/Label.stories.tsx b/app/src/components/Label/Label.stories.tsx
new file mode 100644
index 0000000..20b9c8b
--- /dev/null
+++ b/app/src/components/Label/Label.stories.tsx
@@ -0,0 +1,15 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { Label } from "./Label";
+
+const meta: Meta = {
+ title: "Primitives/Label",
+ component: Label,
+ parameters: { layout: "padded" },
+ args: { children: "Key label", htmlFor: "demo" },
+};
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+export const Required: Story = { args: { required: true } };
diff --git a/app/src/components/Label/Label.test.tsx b/app/src/components/Label/Label.test.tsx
new file mode 100644
index 0000000..42b63c9
--- /dev/null
+++ b/app/src/components/Label/Label.test.tsx
@@ -0,0 +1,38 @@
+import { describe, expect, it } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { Label, DATA_TEST_ID } from "./Label";
+
+describe("Label", () => {
+ it("renders children as label text", () => {
+ // GIVEN a label text and a target field id
+ const givenLabelText = "Email";
+ const givenForFieldId = "email-field";
+
+ // WHEN we render a Label associated with that field
+ render();
+
+ // THEN the label uses the