diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..8d35b39 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,64 @@ + + +## Summary + + + +## Motivation + + + +## Changes + + + +- + +## Screenshots + + + +## Test plan + + + +- [ ] + +## Versioning + + + +- [ ] No version bump (chore / docs / refactor / test only) +- [ ] Frontend bump (`frontend/package.json`) — new version: `_._._` +- [ ] Backend bump (`backend/API/api.py` `version` literal) — new version: `_._._` +- [ ] `CHANGELOG.md` updated in this PR + +## Breaking changes + + + +None. + +## Checklist + +- [ ] PR title follows Conventional Commits +- [ ] Branch name follows `/` +- [ ] Local lint + tests pass for the side(s) I changed +- [ ] No `.env` / API keys / secrets committed +- [ ] Screenshots included for frontend visual changes diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4a6eb52..9f43bb2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,3 +31,28 @@ jobs: - name: Run pytest working-directory: backend run: pytest tests/ -v + + frontend: + name: Frontend lint + vitest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + working-directory: frontend + run: npm ci + + - name: Lint + working-directory: frontend + run: npm run lint + + - name: Run vitest + working-directory: frontend + run: npm run test diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..203a7ca --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,77 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +The repository ships as one product. `frontend` (`frontend/package.json`) and +`backend` (`backend/API/api.py`) share a single version and bump together +(lockstep), even when only one side changes in a release. Tags use the form +`v`. + +## [Unreleased] + +## [1.1.0] - 2026-05-06 + +### Changed + +- Visual redesign of the frontend to a Stripe Dashboard-inspired aesthetic: + full-bleed hero header with brand mark + live backend status chip, layered + duotone shadows, Stripe purple primary (`#635bff`) on a faint cool-purple + page wash, KPI stat-card row above the results table, numbered step badges + (1–4) on every workflow section, Bot icon brand mark. +- Introduced a CSS-variable design-token system in + `frontend/app/globals.css` (`--background`, `--surface`, `--foreground`, + `--primary`, status colors, radii, layered shadows, `.step-badge`, + `.hero-backdrop`, `.eyebrow`, status-dot utilities). Every component now + consumes tokens, so a future palette swap is a one-place edit. +- Typography ladder: 15px html base, h1 ~30px bold tracking-tight, h3 + base-weight semibold, tabular-nums on every number column / counter / row + index / progress percentage. + +### Added + +- Vitest + React Testing Library frontend test harness — first 5 suites + (40 tests) covering `profileUtils` (including the load-bearing legacy + profile migration), `csvParser`, `ColumnTags`, `OutputSchemaBuilder`, and + the `ResultsTable` schema-derived columns regression guard. +- `frontend` job added to `.github/workflows/test.yml` (lint + vitest, + parallel to the existing backend pytest job). +- `BackendStatusChip` in the hero — pulls `model` and `llm_profile` from + `/api/agent-status` and renders a live "Connected · local · gemma-4-31B-it" + pill. Falls back to "Offline" if the status fetch fails. +- README tutorial video embed at the top + ([youtu.be/wnuQufwRAXE](https://youtu.be/wnuQufwRAXE)). +- Repo conventions (first time, since the repo just went public): + `CHANGELOG.md`, `CONTRIBUTING.md`, `SECURITY.md`, + `.github/PULL_REQUEST_TEMPLATE.md`. + +### Fixed + +- `FileUpload`: the entire dashed drop zone is now clickable. Previously + only the inline "Click to upload" link triggered the file picker; the + rest of the box did nothing despite visually inviting clicks. Outer div + is now `role="button"` with full-area click + Enter/Space keyboard + activation. + +### Unchanged (intentionally) + +- All HTTP API contracts (request / response shapes, defaults, error shape, + `submit_result` tool-call protocol) are identical to v1.0.0. +- All component props, JSX layout / order, state shape, and runtime + behavior are unchanged. The redesign is a className / CSS-variable / + additive-element pass — no behavioral surface moved. +- Worker-pool concurrency, profile JSON shape, and CSV parsing all behave + identically. + +## [1.0.0] - 2026-04-XX — Initial public release + +### Added + +- Initial public Apache-2.0 release of Knowledge Robot — Next.js frontend + + Python Flask backend agentic AI for repetitive knowledge-work tasks. + +[Unreleased]: https://github.com/dimknaf/knowledge-robot/compare/v1.1.0...HEAD +[1.1.0]: https://github.com/dimknaf/knowledge-robot/compare/v1.0.0...v1.1.0 +[1.0.0]: https://github.com/dimknaf/knowledge-robot/releases/tag/v1.0.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f5bf5a7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,74 @@ +# Contributing to Knowledge Robot + +Thanks for taking the time to contribute. This document captures the small set of +conventions we follow so that the public history stays readable. + +## Branching + +- The default branch is `master`. +- Branch off `master` for every change. Branch names follow the pattern + `/`, e.g. `feat/csv-multi-row-paste`, + `fix/firecrawl-truncation`, `docs/security-policy`, + `redesign/professional-ui-v1.1`. + +## Commit messages — Conventional Commits + +We use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/). +Each commit subject is one of: + +- `feat(scope): …` — user-visible feature change +- `fix(scope): …` — bug fix +- `docs(scope): …` — documentation only +- `refactor(scope): …` — code change, no behavior change +- `test(scope): …` — tests only +- `chore(scope): …` — tooling, deps, repo hygiene +- `ci(scope): …` — CI/CD configuration +- `perf(scope): …` — performance improvement + +`scope` is optional but encouraged. Use `frontend`, `backend`, `repo`, or a +component name (e.g. `feat(frontend/results-table): …`). + +## Pull requests + +- One logical change per PR. Use squash-merge. +- The PR title MUST be a Conventional Commits subject — that line becomes the + squash-merged commit on `master`. +- Use `.github/PULL_REQUEST_TEMPLATE.md`. Frontend visual changes require + before/after screenshots. +- Both CI jobs (`backend` pytest, `frontend` lint+test) must be green before + merge. + +## Versioning — single shared version (lockstep) + +The repository ships as one product. `frontend` and `backend` carry the +**same version number** and bump together (lockstep), even when only one side +changes in a given release. + +- Frontend version lives in `frontend/package.json`. +- Backend version is the literal in `backend/API/api.py` (the `version` field + of the `/health` response). + +Both must be updated to the same value in the same PR that introduces the +release. Tags use the form `v` (e.g. `v1.1.0`). Update +`CHANGELOG.md` in the same PR. + +## Local checks before opening a PR + +```powershell +# Frontend +cd frontend +npm run lint +npm run test +npm run build + +# Backend +cd backend +pytest tests/ -v +``` + +If you cannot run one half (e.g. you only touched the frontend), say so in the +PR's Test Plan and lean on CI to cover the other side. + +## Reporting a vulnerability + +See `SECURITY.md`. diff --git a/README.md b/README.md index ec4f791..f1d5f8b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # Knowledge Robot +[![Knowledge Robot — tutorial video](https://img.youtube.com/vi/wnuQufwRAXE/maxresdefault.jpg)](https://youtu.be/wnuQufwRAXE "Watch the Knowledge Robot tutorial on YouTube") + +> **▶ Watch the tutorial:** [youtu.be/wnuQufwRAXE](https://youtu.be/wnuQufwRAXE) — 2-minute walkthrough of upload → prompt → schema → run → export. + An agentic AI that automates the repetitive work knowledge workers do every day — web research, browsing, data extraction, and structured note-taking. You describe the task once, define the shape of the output you want, and the agent runs it for you over many inputs: searching the web, visiting pages, and returning typed results that match your schema. - **Agent runtime** — Flask + the OpenAI Agents SDK, LiteLLM-driven and profile-pluggable so you can switch providers with one env var diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..64e7aac --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,29 @@ +# Security policy + +## Reporting a vulnerability + +If you believe you have found a security vulnerability in Knowledge Robot, +please report it privately rather than opening a public issue. + +Email the maintainer with the details: please include reproduction steps, the +affected component (frontend / backend / docker image), and any logs or +proof-of-concept inputs you have. + +You should expect an acknowledgement within a few business days and a fix or +mitigation timeline shortly after triage. We will credit reporters in the +release notes unless they request otherwise. + +## Scope + +In scope: + +- Code in this repository (frontend, backend, docker compose files). +- Default configuration shipped in `.env.local.example`. + +Out of scope: + +- Third-party LLM provider behavior (DeepInfra, Gemini, Firecrawl, etc.) — please + report those upstream. +- User-supplied API keys leaking via misconfigured deployments — that is a + deployment concern, not a code defect, but we are happy to help harden the + default configuration if you find a weak point. diff --git a/backend/API/api.py b/backend/API/api.py index 3867a90..4550bda 100644 --- a/backend/API/api.py +++ b/backend/API/api.py @@ -83,7 +83,7 @@ def health_check(): return jsonify({ 'status': 'healthy', 'service': 'knowledge-robot', - 'version': '1.0.0', + 'version': '1.1.0', 'agent_ready': status['litellm_model_initialized'], 'model': status['model'], }) diff --git a/frontend/__tests__/ColumnTags.test.tsx b/frontend/__tests__/ColumnTags.test.tsx new file mode 100644 index 0000000..74f0183 --- /dev/null +++ b/frontend/__tests__/ColumnTags.test.tsx @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import ColumnTags from '@/components/ColumnTags'; + +afterEach(() => { + cleanup(); +}); + +describe('ColumnTags', () => { + it('renders one button per column', () => { + render( + + ); + + expect(screen.getByRole('button', { name: /company/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /country/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /revenue/ })).toBeInTheDocument(); + }); + + it('shows the singular "column" label when count is 1', () => { + render(); + expect(screen.getByText(/^1 column$/)).toBeInTheDocument(); + }); + + it('shows the plural "columns" label when count > 1', () => { + render(); + expect(screen.getByText(/^3 columns$/)).toBeInTheDocument(); + }); + + it('calls onTagClick with the column name when a chip is clicked', async () => { + const user = userEvent.setup(); + const handleClick = vi.fn(); + + render( + + ); + + await user.click(screen.getByRole('button', { name: /company/ })); + expect(handleClick).toHaveBeenCalledTimes(1); + expect(handleClick).toHaveBeenCalledWith('company'); + + await user.click(screen.getByRole('button', { name: /country/ })); + expect(handleClick).toHaveBeenCalledTimes(2); + expect(handleClick).toHaveBeenLastCalledWith('country'); + }); + + it('renders the step badge "1" indicating the Data step', () => { + const { container } = render( + + ); + const badge = container.querySelector('.step-badge'); + expect(badge).not.toBeNull(); + expect(badge?.textContent).toBe('1'); + }); +}); diff --git a/frontend/__tests__/OutputSchemaBuilder.test.tsx b/frontend/__tests__/OutputSchemaBuilder.test.tsx new file mode 100644 index 0000000..994cf0d --- /dev/null +++ b/frontend/__tests__/OutputSchemaBuilder.test.tsx @@ -0,0 +1,116 @@ +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import OutputSchemaBuilder from '@/components/OutputSchemaBuilder'; +import type { OutputField } from '@/types'; + +beforeEach(() => { + // OutputSchemaBuilder uses window.alert when name is empty + vi.stubGlobal('alert', vi.fn()); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('OutputSchemaBuilder', () => { + it('renders empty state when no fields exist', () => { + render(); + expect(screen.getByPlaceholderText(/e\.g\., sentiment/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /add field/i })).toBeInTheDocument(); + // No "X fields" counter when empty + expect(screen.queryByText(/^\d+ fields?$/)).not.toBeInTheDocument(); + }); + + it('renders existing fields with their names', () => { + const fields: OutputField[] = [ + { id: 'a', name: 'sentiment', type: 'text', description: '' }, + { id: 'b', name: 'score', type: 'number', description: 'confidence 0-100' }, + ]; + render(); + + expect(screen.getByText('sentiment')).toBeInTheDocument(); + expect(screen.getByText('score')).toBeInTheDocument(); + expect(screen.getByText('confidence 0-100')).toBeInTheDocument(); + expect(screen.getByText(/^2 fields$/)).toBeInTheDocument(); + }); + + it('adds a new field when name is filled and Add Field is clicked', async () => { + const user = userEvent.setup(); + const handleChange = vi.fn(); + + render(); + + await user.type(screen.getByPlaceholderText(/e\.g\., sentiment/i), 'industry'); + await user.click(screen.getByRole('button', { name: /add field/i })); + + expect(handleChange).toHaveBeenCalledTimes(1); + const newFields = handleChange.mock.calls[0][0] as OutputField[]; + expect(newFields).toHaveLength(1); + expect(newFields[0].name).toBe('industry'); + expect(newFields[0].type).toBe('text'); // default + expect(newFields[0].description).toBe(''); + expect(newFields[0].id).toMatch(/^field-\d+$/); + }); + + it('respects the chosen field type when adding', async () => { + const user = userEvent.setup(); + const handleChange = vi.fn(); + + render(); + + await user.type(screen.getByPlaceholderText(/e\.g\., sentiment/i), 'count'); + await user.selectOptions(screen.getByRole('combobox'), 'number'); + await user.click(screen.getByRole('button', { name: /add field/i })); + + const newFields = handleChange.mock.calls[0][0] as OutputField[]; + expect(newFields[0].type).toBe('number'); + }); + + it('refuses to add a field with empty name (alerts, does not call onChange)', async () => { + const user = userEvent.setup(); + const handleChange = vi.fn(); + + render(); + + await user.click(screen.getByRole('button', { name: /add field/i })); + + expect(handleChange).not.toHaveBeenCalled(); + expect((globalThis as unknown as { alert: ReturnType }).alert).toHaveBeenCalled(); + }); + + it('removes a field when its trash icon is clicked', async () => { + const user = userEvent.setup(); + const handleChange = vi.fn(); + const fields: OutputField[] = [ + { id: 'a', name: 'first', type: 'text', description: '' }, + { id: 'b', name: 'second', type: 'text', description: '' }, + ]; + + render(); + + const removeButtons = screen.getAllByTitle(/remove field/i); + expect(removeButtons).toHaveLength(2); + + await user.click(removeButtons[0]); + + expect(handleChange).toHaveBeenCalledTimes(1); + const remaining = handleChange.mock.calls[0][0] as OutputField[]; + expect(remaining).toHaveLength(1); + expect(remaining[0].id).toBe('b'); + }); + + it('clears the new-field form after a successful add', async () => { + const user = userEvent.setup(); + + render(); + + const nameInput = screen.getByPlaceholderText(/e\.g\., sentiment/i) as HTMLInputElement; + await user.type(nameInput, 'industry'); + expect(nameInput.value).toBe('industry'); + + await user.click(screen.getByRole('button', { name: /add field/i })); + expect(nameInput.value).toBe(''); + }); +}); diff --git a/frontend/__tests__/ResultsTable.test.tsx b/frontend/__tests__/ResultsTable.test.tsx new file mode 100644 index 0000000..8f46969 --- /dev/null +++ b/frontend/__tests__/ResultsTable.test.tsx @@ -0,0 +1,143 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup, within } from '@testing-library/react'; +import ResultsTable from '@/components/ResultsTable'; +import type { OutputField, ProcessedRow } from '@/types'; + +afterEach(() => { + cleanup(); +}); + +const SCHEMA: OutputField[] = [ + { id: 'a', name: 'analysis', type: 'text', description: '' }, + { id: 'b', name: 'risk_score', type: 'number', description: '' }, +]; + +describe('ResultsTable', () => { + it('returns null when results is empty (renders nothing)', () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + + it('renders output column headers from the SCHEMA, not from results[0].outputData (regression test)', () => { + // The bug this guards: if a worker pool is processing rows N>0 first while row 0 is still pending, + // results[0].outputData is undefined, but the table must still display the schema's output columns. + const results: ProcessedRow[] = [ + { rowIndex: 0, status: 'pending', inputData: { company: 'Acme' } /* outputData intentionally omitted */ }, + { rowIndex: 1, status: 'completed', inputData: { company: 'Beta' }, outputData: { analysis: 'positive', risk_score: 12 } }, + ]; + + render( + + ); + + // Both schema-defined output columns must be visible in the header + const tableHeaders = screen.getAllByRole('columnheader'); + const headerTexts = tableHeaders.map((th) => th.textContent ?? ''); + expect(headerTexts.some((t) => t.includes('analysis'))).toBe(true); + expect(headerTexts.some((t) => t.includes('risk_score'))).toBe(true); + + // The completed row's output renders + expect(screen.getByText('positive')).toBeInTheDocument(); + expect(screen.getByText('12')).toBeInTheDocument(); + }); + + it('renders the input column header when includeInput=true', () => { + const results: ProcessedRow[] = [ + { rowIndex: 0, status: 'completed', inputData: { company: 'Acme' }, outputData: { analysis: 'x', risk_score: 1 } }, + ]; + + render( + + ); + + const headerTexts = screen.getAllByRole('columnheader').map((th) => th.textContent ?? ''); + expect(headerTexts.some((t) => t.includes('company'))).toBe(true); + }); + + it('hides input columns when includeInput=false', () => { + const results: ProcessedRow[] = [ + { rowIndex: 0, status: 'completed', inputData: { company: 'Acme' }, outputData: { analysis: 'x', risk_score: 1 } }, + ]; + + render( + + ); + + const headerTexts = screen.getAllByRole('columnheader').map((th) => th.textContent ?? ''); + expect(headerTexts.some((t) => t.includes('company'))).toBe(false); + expect(headerTexts.some((t) => t.includes('analysis'))).toBe(true); + }); + + it('renders the four KPI stat cards (Total / Completed / In progress / Failed)', () => { + const results: ProcessedRow[] = [ + { rowIndex: 0, status: 'completed', inputData: {}, outputData: { analysis: 'a', risk_score: 1 } }, + { rowIndex: 1, status: 'completed', inputData: {}, outputData: { analysis: 'b', risk_score: 2 } }, + { rowIndex: 2, status: 'processing', inputData: {} }, + { rowIndex: 3, status: 'error', inputData: {}, error: 'boom' }, + ]; + + render( + + ); + + // Stat-card labels + expect(screen.getByText(/^Total$/i)).toBeInTheDocument(); + expect(screen.getByText(/^Completed$/i)).toBeInTheDocument(); + expect(screen.getByText(/^In progress$/i)).toBeInTheDocument(); + // "Failed" appears in the stat card AND in error-row cells, so use getAllByText + expect(screen.getAllByText(/^Failed$/i).length).toBeGreaterThanOrEqual(1); + }); + + it('shows the failed cell as italic "Failed" placeholder for errored rows', () => { + const results: ProcessedRow[] = [ + { rowIndex: 0, status: 'error', inputData: { company: 'Acme' }, error: 'agent crashed' }, + ]; + + const { container } = render( + + ); + + // The "output" cells of error rows should show italic "Failed" + const failedCells = within(container).getAllByText(/^Failed$/); + // At least one in the table body (excluding the stat-card label which is "Failed" too) + expect(failedCells.length).toBeGreaterThan(0); + }); +}); diff --git a/frontend/__tests__/csvParser.test.ts b/frontend/__tests__/csvParser.test.ts new file mode 100644 index 0000000..ae5c88d --- /dev/null +++ b/frontend/__tests__/csvParser.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { parseCSV } from '@/lib/csvParser'; + +function fileFromString(content: string, name = 'test.csv'): File { + return new File([content], name, { type: 'text/csv' }); +} + +describe('parseCSV', () => { + it('parses a basic CSV with headers and rows', async () => { + const csv = 'company,country\nAcme,UK\nBeta,US\n'; + const result = await parseCSV(fileFromString(csv)); + + expect(result.headers).toEqual(['company', 'country']); + expect(result.rows).toHaveLength(2); + expect(result.rows[0]).toEqual({ company: 'Acme', country: 'UK' }); + expect(result.rows[1]).toEqual({ company: 'Beta', country: 'US' }); + }); + + it('dynamic-types numeric columns', async () => { + const csv = 'name,age\nAlice,30\nBob,42\n'; + const result = await parseCSV(fileFromString(csv)); + + expect(result.rows[0]).toEqual({ name: 'Alice', age: 30 }); + expect(result.rows[1]).toEqual({ name: 'Bob', age: 42 }); + expect(typeof result.rows[0].age).toBe('number'); + }); + + it('handles embedded commas inside quoted cells', async () => { + const csv = 'company,address\n"Acme, Inc.","221B, Baker St."\n'; + const result = await parseCSV(fileFromString(csv)); + + expect(result.rows[0]).toEqual({ + company: 'Acme, Inc.', + address: '221B, Baker St.', + }); + }); + + it('skips empty lines', async () => { + const csv = 'a,b\n1,2\n\n3,4\n\n\n'; + const result = await parseCSV(fileFromString(csv)); + + expect(result.rows).toHaveLength(2); + expect(result.rows[0]).toEqual({ a: 1, b: 2 }); + expect(result.rows[1]).toEqual({ a: 3, b: 4 }); + }); + + it('handles a single-row CSV', async () => { + const csv = 'a,b,c\n1,2,3\n'; + const result = await parseCSV(fileFromString(csv)); + + expect(result.rows).toHaveLength(1); + expect(result.rows[0]).toEqual({ a: 1, b: 2, c: 3 }); + }); + + it('handles a header-only CSV (no data rows)', async () => { + const csv = 'a,b,c\n'; + const result = await parseCSV(fileFromString(csv)); + + expect(result.headers).toEqual(['a', 'b', 'c']); + expect(result.rows).toEqual([]); + }); +}); diff --git a/frontend/__tests__/profileUtils.test.ts b/frontend/__tests__/profileUtils.test.ts new file mode 100644 index 0000000..d0fd198 --- /dev/null +++ b/frontend/__tests__/profileUtils.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect } from 'vitest'; +import { + validateProfile, + ProfileValidationError, + createProfile, + sanitizeProfileName, +} from '@/lib/profileUtils'; + +const VALID_FIELD = { + id: 'field-1', + name: 'sentiment', + type: 'text' as const, + description: 'overall sentiment of the row', +}; + +const VALID_NEW_PROFILE = { + name: 'My Profile', + prompt: 'Analyze {company}', + outputFields: [VALID_FIELD], + scrapeBackend: 'local' as const, + enableSearch: true, + browserVisible: false, + version: '1.0', + createdAt: '2026-05-05T10:00:00.000Z', +}; + +describe('validateProfile — valid input', () => { + it('accepts a fully-formed new-shape profile', () => { + const result = validateProfile(VALID_NEW_PROFILE); + expect(result.name).toBe('My Profile'); + expect(result.prompt).toBe('Analyze {company}'); + expect(result.outputFields).toHaveLength(1); + expect(result.outputFields[0]).toEqual(VALID_FIELD); + expect(result.scrapeBackend).toBe('local'); + expect(result.enableSearch).toBe(true); + expect(result.browserVisible).toBe(false); + expect(result.version).toBe('1.0'); + }); + + it('round-trips through createProfile -> validateProfile', () => { + const created = createProfile( + 'Round Trip', + 'Hello {x}', + [VALID_FIELD], + 'firecrawl', + false, + true + ); + expect(() => validateProfile(created)).not.toThrow(); + const validated = validateProfile(created); + expect(validated.scrapeBackend).toBe('firecrawl'); + expect(validated.browserVisible).toBe(true); + }); +}); + +describe('validateProfile — legacy migration (load-bearing)', () => { + it('migrates a pre-v1.1 profile that has only enableSearch (no scrapeBackend) to local', () => { + const legacy = { + name: 'Legacy Profile', + prompt: 'Old prompt', + outputFields: [VALID_FIELD], + enableSearch: true, + // scrapeBackend INTENTIONALLY missing — this is the legacy shape + version: '1.0', + createdAt: '2025-09-01T08:00:00.000Z', + }; + const result = validateProfile(legacy); + expect(result.scrapeBackend).toBe('local'); // defaulted + expect(result.enableSearch).toBe(true); // preserved verbatim + expect(result.browserVisible).toBe(false); // defaulted to false + }); + + it('preserves enableSearch=false on a legacy profile', () => { + const legacy = { + name: 'No-search Legacy', + prompt: 'X', + outputFields: [], + enableSearch: false, + version: '1.0', + createdAt: '2025-09-01T08:00:00.000Z', + }; + const result = validateProfile(legacy); + expect(result.enableSearch).toBe(false); + expect(result.scrapeBackend).toBe('local'); + }); + + it('handles a legacy profile with NO enableSearch field at all (defaults to false)', () => { + const ancient = { + name: 'Ancient', + prompt: 'Y', + outputFields: [], + version: '0.9', + createdAt: '2025-01-01T00:00:00.000Z', + }; + const result = validateProfile(ancient); + expect(result.enableSearch).toBe(false); + expect(result.scrapeBackend).toBe('local'); + expect(result.browserVisible).toBe(false); + }); + + it('rejects an unknown scrapeBackend value (falls back to local default, does NOT throw)', () => { + const corrupted = { + ...VALID_NEW_PROFILE, + scrapeBackend: 'made-up-backend', + }; + const result = validateProfile(corrupted); + expect(result.scrapeBackend).toBe('local'); + }); +}); + +describe('validateProfile — rejection of garbage', () => { + it('rejects null', () => { + expect(() => validateProfile(null)).toThrow(ProfileValidationError); + }); + + it('rejects a number', () => { + expect(() => validateProfile(42)).toThrow(ProfileValidationError); + }); + + it('rejects a missing name', () => { + const bad = { ...VALID_NEW_PROFILE, name: '' }; + expect(() => validateProfile(bad)).toThrow(/name/i); + }); + + it('rejects a missing prompt', () => { + const bad = { ...VALID_NEW_PROFILE }; + delete (bad as Partial).prompt; + expect(() => validateProfile(bad)).toThrow(/prompt/i); + }); + + it('rejects outputFields that is not an array', () => { + const bad = { ...VALID_NEW_PROFILE, outputFields: 'not-an-array' }; + expect(() => validateProfile(bad)).toThrow(/outputFields/); + }); + + it('rejects an output field with invalid type', () => { + const bad = { + ...VALID_NEW_PROFILE, + outputFields: [{ ...VALID_FIELD, type: 'invalid-type' }], + }; + expect(() => validateProfile(bad)).toThrow(/type/i); + }); + + it('rejects an output field missing name', () => { + const bad = { + ...VALID_NEW_PROFILE, + outputFields: [{ ...VALID_FIELD, name: '' }], + }; + expect(() => validateProfile(bad)).toThrow(/name/i); + }); + + it('rejects a malformed createdAt', () => { + const bad = { ...VALID_NEW_PROFILE, createdAt: 'not-a-date' }; + expect(() => validateProfile(bad)).toThrow(/createdAt/i); + }); +}); + +describe('sanitizeProfileName', () => { + it('returns the trimmed name when non-empty', () => { + expect(sanitizeProfileName(' Hello World ')).toBe('Hello World'); + }); + + it('returns "Unnamed Profile" for empty / whitespace-only input', () => { + expect(sanitizeProfileName('')).toBe('Unnamed Profile'); + expect(sanitizeProfileName(' ')).toBe('Unnamed Profile'); + }); +}); diff --git a/frontend/app/globals.css b/frontend/app/globals.css index af45c1d..db8c8cb 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -1,32 +1,145 @@ @import "tailwindcss"; +/* ============================================ + DESIGN TOKENS — Stripe Dashboard + ============================================ + Faint cool-purple page wash, deep navy ink, Stripe purple primary, + layered duotone shadows, dimensional richness — NOT austere. +*/ + :root { - --background: #f8fafc; - --foreground: #0f172a; + /* Surfaces */ + --background: #f6f9fc; /* Stripe's signature faint cool-purple page wash */ + --surface: #ffffff; + --surface-muted: #ebeff5; + --surface-sunken: #e6ebf1; + + /* Ink — Stripe's deep navy */ + --foreground: #0a2540; + --foreground-muted: #425466; + --foreground-subtle: #8898aa; + + /* Borders */ + --border: #e3e8ee; + --border-strong: #c1c9d6; + + /* Brand — actual Stripe purple-blue */ + --primary: #635bff; + --primary-hover: #4f46e5; + --primary-darker: #5048e5; /* gradient stop on CTAs */ + --primary-foreground:#ffffff; + --accent: #00d4ff; /* electric cyan — micro highlights only */ + + /* Status */ + --success: #0e7c66; + --success-bg: #e6fcf5; + --warning: #b45309; + --warning-bg: #fef3c7; + --danger: #cd3500; + --danger-bg: #fff1f0; + --info: #635bff; + --info-bg: #efeefe; + + /* Focus ring */ + --ring: #635bff; + + /* Radii */ + --radius-sm: 4px; + --radius: 8px; + --radius-md: 10px; + --radius-lg: 12px; /* cards */ + --radius-xl: 14px; /* modals, hero */ + + /* Shadows — Stripe layered duotone (bluish + neutral) */ + --shadow-xs: 0 1px 0 rgba(50, 50, 93, 0.05); + --shadow-sm: 0 1px 2px rgba(50, 50, 93, 0.08), 0 1px 1px rgba(0, 0, 0, 0.04); + --shadow-card: 0 1px 3px rgba(50, 50, 93, 0.10), 0 1px 2px rgba(0, 0, 0, 0.07); + --shadow-card-hover:0 6px 20px rgba(50, 50, 93, 0.10), 0 2px 6px rgba(0, 0, 0, 0.06); + --shadow-pop: 0 24px 48px rgba(50, 50, 93, 0.18), 0 2px 12px rgba(0, 0, 0, 0.08); + --shadow-focus: 0 0 0 4px rgba(99, 91, 255, 0.18); + + /* CTA gradient (used on .btn-primary) */ + --gradient-primary: linear-gradient(180deg, var(--primary) 0%, var(--primary-darker) 100%); } @theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-foreground-muted: var(--foreground-muted); + --color-foreground-subtle: var(--foreground-subtle); + --color-surface: var(--surface); + --color-surface-muted: var(--surface-muted); + --color-surface-sunken: var(--surface-sunken); + --color-border: var(--border); + --color-border-strong: var(--border-strong); + --color-primary: var(--primary); + --color-primary-hover: var(--primary-hover); + --color-primary-foreground: var(--primary-foreground); + --color-accent: var(--accent); + --color-success: var(--success); + --color-success-bg: var(--success-bg); + --color-warning: var(--warning); + --color-warning-bg: var(--warning-bg); + --color-danger: var(--danger); + --color-danger-bg: var(--danger-bg); + --color-info: var(--info); + --color-info-bg: var(--info-bg); + --color-ring: var(--ring); + --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); - /* Custom Shadows */ - --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06); - --shadow-card-hover: 0 4px 12px rgba(0, 0, 0, 0.15); - --shadow-glow-indigo: 0 0 20px rgba(99, 102, 241, 0.3); - --shadow-glow-emerald: 0 0 20px rgba(16, 185, 129, 0.3); + --radius-sm: var(--radius-sm); + --radius: var(--radius); + --radius-md: var(--radius-md); + --radius-lg: var(--radius-lg); + --radius-xl: var(--radius-xl); - /* Border Radius */ - --radius-lg: 0.75rem; - --radius-xl: 1rem; - --radius-2xl: 1.5rem; + --shadow-card: var(--shadow-card); + --shadow-card-hover: var(--shadow-card-hover); + --shadow-pop: var(--shadow-pop); } +/* ============================================ + TYPOGRAPHY — confident hierarchy + ============================================ + 14px base (fintech-correct), but headlines are RESTORED to confident sizes. + v2 went too austere with text-2xl h1; Stripe uses 28-32px headlines. +*/ + +html { font-size: 15px; } + body { - background: var(--background); + background: + radial-gradient(ellipse 80rem 40rem at 50% -10rem, + rgba(99, 91, 255, 0.08) 0%, + transparent 70%), + var(--background); + background-attachment: fixed; color: var(--foreground); - font-family: var(--font-sans), Arial, Helvetica, sans-serif; + font-family: var(--font-sans), Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-feature-settings: "cv11", "ss01", "ss03"; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + line-height: 1.5; +} + +/* Headline ladder */ +h1 { font-size: 2rem; line-height: 1.15; letter-spacing: -0.020em; font-weight: 700; } /* ~28px @ 14px base */ +h2 { font-size: 1.5rem; line-height: 1.20; letter-spacing: -0.018em; font-weight: 700; } /* ~21px */ +h3 { font-size: 1rem; line-height: 1.30; letter-spacing: -0.012em; font-weight: 600; } /* ~14px — but visible weight */ +h4 { font-size: 0.875rem;line-height: 1.30; letter-spacing: -0.008em; font-weight: 600; } +h1, h2, h3, h4, h5, h6 { + font-feature-settings: "cv11", "ss01", "ss03"; +} + +/* Tabular nums — every numeric column / counter / row index */ +.tabular-nums, +table, +input[type="number"], +[class*="tabular"] { + font-variant-numeric: tabular-nums; + font-feature-settings: "tnum"; } /* ============================================ @@ -34,248 +147,249 @@ body { ============================================ */ @keyframes fadeIn { - from { - opacity: 0; - transform: translateY(-10px); - } - to { - opacity: 1; - transform: translateY(0); - } + from { opacity: 0; transform: translateY(-6px); } + to { opacity: 1; transform: translateY(0); } } @keyframes slideUp { - from { - opacity: 0; - transform: translateY(20px); - } - to { - opacity: 1; - transform: translateY(0); - } + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } } @keyframes shimmer { - 0% { - background-position: -200% 0; - } - 100% { - background-position: 200% 0; - } + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } } @keyframes pulse-glow { - 0%, 100% { - box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.4); - } - 50% { - box-shadow: 0 0 20px 5px rgba(99, 102, 241, 0.1); - } + 0%, 100% { box-shadow: 0 0 0 0 rgba(205, 53, 0, 0.30); } + 50% { box-shadow: 0 0 14px 3px rgba(205, 53, 0, 0.06); } } @keyframes progress-stripes { - 0% { - background-position: 1rem 0; - } - 100% { - background-position: 0 0; - } + 0% { background-position: 1rem 0; } + 100% { background-position: 0 0; } } @keyframes bounce-subtle { - 0%, 100% { - transform: translateY(0); - } - 50% { - transform: translateY(-3px); - } + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-2px); } } @keyframes spin-slow { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -/* Animation utilities */ -.animate-fadeIn { - animation: fadeIn 0.3s ease-out forwards; + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } } -.animate-slideUp { - animation: slideUp 0.4s ease-out forwards; +@keyframes status-dot-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.55; } } -.animate-shimmer { - background: linear-gradient(90deg, #e2e8f0 25%, #f1f5f9 50%, #e2e8f0 75%); +.animate-fadeIn { animation: fadeIn 0.2s ease-out forwards; } +.animate-slideUp { animation: slideUp 0.25s ease-out forwards; } +.animate-shimmer { + background: linear-gradient(90deg, var(--surface-muted) 25%, var(--surface) 50%, var(--surface-muted) 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite; } - -.animate-pulse-glow { - animation: pulse-glow 2s infinite; -} - +.animate-pulse-glow { animation: pulse-glow 2s infinite; } .animate-progress-stripes { background-image: linear-gradient( 45deg, - rgba(255, 255, 255, 0.15) 25%, + rgba(255, 255, 255, 0.20) 25%, transparent 25%, transparent 50%, - rgba(255, 255, 255, 0.15) 50%, - rgba(255, 255, 255, 0.15) 75%, + rgba(255, 255, 255, 0.20) 50%, + rgba(255, 255, 255, 0.20) 75%, transparent 75%, transparent ); background-size: 1rem 1rem; animation: progress-stripes 1s linear infinite; } - -.animate-bounce-subtle { - animation: bounce-subtle 0.5s ease-in-out; -} +.animate-bounce-subtle { animation: bounce-subtle 0.5s ease-in-out; } +.animate-status-pulse { animation: status-dot-pulse 2s ease-in-out infinite; } /* ============================================ - UTILITY CLASSES + UTILITY CLASSES — Stripe-grade ============================================ */ -/* Card Base Styles */ +/* Cards — layered duotone shadow, subtle hover lift */ .card-base { - @apply bg-white rounded-xl border border-slate-200/60 p-5 - shadow-[0_1px_3px_rgba(0,0,0,0.1),0_1px_2px_rgba(0,0,0,0.06)] - transition-all duration-200 - hover:shadow-[0_4px_12px_rgba(0,0,0,0.1)]; + @apply bg-[var(--surface)] rounded-[var(--radius-lg)] border border-[var(--border)] p-5 + shadow-[var(--shadow-card)] + transition-shadow duration-200 ease-out + hover:shadow-[var(--shadow-card-hover)]; } .card-base-static { - @apply bg-white rounded-xl border border-slate-200/60 p-5 - shadow-[0_1px_3px_rgba(0,0,0,0.1),0_1px_2px_rgba(0,0,0,0.06)]; + @apply bg-[var(--surface)] rounded-[var(--radius-lg)] border border-[var(--border)] p-5 + shadow-[var(--shadow-card)]; } -/* Button Variants */ +/* Primary button — Stripe-style 2-stop gradient + signature focus glow */ .btn-primary { - @apply bg-gradient-to-r from-indigo-500 to-indigo-600 text-white - rounded-lg px-4 py-2 font-medium - shadow-md shadow-indigo-500/20 - hover:from-indigo-600 hover:to-indigo-700 hover:shadow-lg hover:shadow-indigo-500/30 - active:scale-[0.98] transition-all duration-150 - focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:ring-offset-2 - disabled:from-slate-400 disabled:to-slate-500 disabled:shadow-none disabled:cursor-not-allowed; + @apply inline-flex items-center justify-center h-9 px-4 gap-2 + text-[var(--primary-foreground)] + rounded-[var(--radius)] font-medium text-sm + shadow-[var(--shadow-sm)] + transition-[background,transform,box-shadow] duration-150 + active:scale-[0.97] + disabled:opacity-50 disabled:cursor-not-allowed; + background: var(--gradient-primary); +} +.btn-primary:hover:not(:disabled) { + background: linear-gradient(180deg, var(--primary-hover) 0%, #4338ca 100%); +} +.btn-primary:focus-visible { + outline: none; + box-shadow: var(--shadow-sm), var(--shadow-focus); +} +.btn-primary:disabled { + background: var(--foreground-subtle); } .btn-secondary { - @apply bg-white text-slate-700 rounded-lg px-4 py-2 font-medium - border border-slate-300 - hover:bg-slate-50 hover:border-slate-400 - active:scale-[0.98] transition-all duration-150 - focus:outline-none focus:ring-2 focus:ring-slate-500/30 focus:ring-offset-2 - disabled:bg-slate-100 disabled:text-slate-400 disabled:cursor-not-allowed; + @apply inline-flex items-center justify-center h-9 px-4 gap-2 + bg-[var(--surface)] text-[var(--foreground)] + rounded-[var(--radius)] font-medium text-sm + border border-[var(--border-strong)] + shadow-[var(--shadow-xs)] + hover:bg-[var(--surface-muted)] hover:border-[var(--foreground-muted)] + active:scale-[0.97] + transition-[background-color,border-color,transform,box-shadow] duration-150 + focus:outline-none focus-visible:ring-0 + disabled:bg-[var(--surface-muted)] disabled:text-[var(--foreground-subtle)] disabled:cursor-not-allowed; +} +.btn-secondary:focus-visible { + outline: none; + box-shadow: var(--shadow-xs), var(--shadow-focus); } .btn-success { - @apply bg-gradient-to-r from-emerald-500 to-emerald-600 text-white - rounded-lg px-4 py-2 font-medium - shadow-md shadow-emerald-500/20 - hover:from-emerald-600 hover:to-emerald-700 hover:shadow-lg hover:shadow-emerald-500/30 - active:scale-[0.98] transition-all duration-150 - focus:outline-none focus:ring-2 focus:ring-emerald-500/50 focus:ring-offset-2 - disabled:from-slate-400 disabled:to-slate-500 disabled:shadow-none disabled:cursor-not-allowed; + @apply inline-flex items-center justify-center h-9 px-4 gap-2 + bg-[var(--success)] text-[var(--primary-foreground)] + rounded-[var(--radius)] font-medium text-sm + shadow-[var(--shadow-sm)] + hover:brightness-95 + active:scale-[0.97] transition-[filter,transform] duration-150 + disabled:opacity-50 disabled:cursor-not-allowed; } .btn-danger { - @apply bg-gradient-to-r from-red-500 to-red-600 text-white - rounded-lg px-4 py-2 font-medium - shadow-md shadow-red-500/20 - hover:from-red-600 hover:to-red-700 hover:shadow-lg hover:shadow-red-500/30 - active:scale-[0.98] transition-all duration-150 - focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:ring-offset-2; + @apply inline-flex items-center justify-center h-9 px-4 gap-2 + bg-[var(--danger)] text-[var(--primary-foreground)] + rounded-[var(--radius)] font-medium text-sm + shadow-[var(--shadow-sm)] + hover:brightness-95 + active:scale-[0.97] transition-[filter,transform] duration-150; } -/* Input Styles */ +/* Inputs — focus glow ring (Stripe pattern) */ .input-base { - @apply w-full px-4 py-2.5 bg-white border border-slate-300 rounded-lg - text-slate-800 placeholder:text-slate-400 - focus:outline-none focus:ring-2 focus:ring-indigo-500/30 - focus:border-indigo-500 transition-all duration-150 - disabled:bg-slate-100 disabled:text-slate-500 disabled:cursor-not-allowed; + @apply w-full h-9 px-3 bg-[var(--surface)] border border-[var(--border-strong)] rounded-[var(--radius)] + text-sm text-[var(--foreground)] placeholder:text-[var(--foreground-subtle)] + transition-[border-color,box-shadow] duration-150 + disabled:bg-[var(--surface-muted)] disabled:text-[var(--foreground-subtle)] disabled:cursor-not-allowed; } - -.textarea-base { - @apply w-full px-4 py-3 bg-slate-50 border border-slate-300 rounded-xl - text-slate-800 placeholder:text-slate-400 - focus:outline-none focus:bg-white focus:ring-2 focus:ring-indigo-500/20 - focus:border-indigo-400 transition-all duration-200 resize-none; -} - -/* Badge Styles */ -.badge { - @apply inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium - border transition-all duration-150; -} - -.badge-indigo { - @apply bg-gradient-to-r from-indigo-100 to-indigo-50 text-indigo-700 - border-indigo-200/50; -} - -.badge-emerald { - @apply bg-gradient-to-r from-emerald-100 to-emerald-50 text-emerald-700 - border-emerald-200/50; +.input-base:focus { + outline: none; + border-color: var(--primary); + box-shadow: var(--shadow-focus); } -.badge-red { - @apply bg-gradient-to-r from-red-100 to-red-50 text-red-700 - border-red-200/50; +.textarea-base { + @apply w-full px-3 py-2.5 bg-[var(--surface-muted)] border border-[var(--border-strong)] rounded-[var(--radius-md)] + text-sm text-[var(--foreground)] placeholder:text-[var(--foreground-subtle)] + transition-[background-color,border-color,box-shadow] duration-150 resize-none; } - -.badge-amber { - @apply bg-gradient-to-r from-amber-100 to-amber-50 text-amber-700 - border-amber-200/50; +.textarea-base:focus { + outline: none; + background: var(--surface); + border-color: var(--primary); + box-shadow: var(--shadow-focus); } -.badge-slate { - @apply bg-gradient-to-r from-slate-100 to-slate-50 text-slate-600 - border-slate-200/50; +/* Badges */ +.badge { + @apply inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium + border transition-colors duration-120; +} +.badge-indigo { @apply bg-[var(--info-bg)] text-[var(--info)] border-[var(--info)]/15; } +.badge-emerald { @apply bg-[var(--success-bg)] text-[var(--success)] border-[var(--success)]/15; } +.badge-red { @apply bg-[var(--danger-bg)] text-[var(--danger)] border-[var(--danger)]/15; } +.badge-amber { @apply bg-[var(--warning-bg)] text-[var(--warning)] border-[var(--warning)]/15; } +.badge-slate { @apply bg-[var(--surface-muted)] text-[var(--foreground-muted)] border-[var(--border)]; } + +/* Status rows */ +.status-row-completed { @apply bg-[var(--success-bg)]/60 border-l-2 border-[var(--success)]; } +.status-row-processing { @apply bg-[var(--info-bg)]/60 border-l-2 border-[var(--primary)]; } +.status-row-error { @apply bg-[var(--danger-bg)]/60 border-l-2 border-[var(--danger)]; } +.status-row-pending { @apply bg-[var(--surface-muted)]/40 border-l-2 border-[var(--border-strong)]; } + +/* Skeleton */ +.skeleton { + background: linear-gradient(90deg, var(--surface-muted) 25%, var(--surface) 50%, var(--surface-muted) 75%); + background-size: 200% 100%; + border-radius: var(--radius-sm); + animation: shimmer 1.5s infinite; } -/* Status Row Styles */ -.status-row-completed { - @apply bg-gradient-to-r from-emerald-50/50 to-transparent border-l-4 border-emerald-400; -} +/* Glass */ +.glass { @apply bg-[var(--surface)]/85 backdrop-blur-md; } +.glass-dark { @apply bg-[var(--foreground)]/40 backdrop-blur-sm; } -.status-row-processing { - @apply bg-gradient-to-r from-indigo-50/50 to-transparent border-l-4 border-indigo-400; -} +/* ============================================ + NEW v3 UTILITIES — hero, step, stat, eyebrow + ============================================ */ -.status-row-error { - @apply bg-gradient-to-r from-red-50/50 to-transparent border-l-4 border-red-400; +/* Hero backdrop — radial Stripe-style purple wash */ +.hero-backdrop { + background: + radial-gradient(circle at 20% 0%, + rgba(99, 91, 255, 0.10) 0%, + transparent 55%), + radial-gradient(circle at 80% 0%, + rgba(0, 212, 255, 0.06) 0%, + transparent 55%), + linear-gradient(180deg, + rgba(255, 255, 255, 0.55) 0%, + transparent 100%); } -.status-row-pending { - @apply bg-slate-50/30 border-l-4 border-slate-300; +/* Step badge — small numbered circle */ +.step-badge { + @apply inline-flex items-center justify-center w-5 h-5 rounded-full + text-[var(--primary-foreground)] + text-[10px] font-bold tabular-nums + shadow-[var(--shadow-sm)] + flex-shrink-0; + background: var(--gradient-primary); } -/* Skeleton Loading */ -.skeleton { - @apply bg-gradient-to-r from-slate-200 via-slate-100 to-slate-200 - bg-[length:200%_100%] rounded; - animation: shimmer 1.5s infinite; +.step-badge-muted { + @apply inline-flex items-center justify-center w-5 h-5 rounded-full + bg-[var(--foreground-subtle)] text-[var(--primary-foreground)] + text-[10px] font-bold tabular-nums + flex-shrink-0; } -/* Glassmorphism */ -.glass { - @apply bg-white/80 backdrop-blur-md; +/* Eyebrow label */ +.eyebrow { + @apply text-[10px] font-semibold uppercase tracking-[0.08em] text-[var(--foreground-subtle)]; } -.glass-dark { - @apply bg-slate-900/60 backdrop-blur-sm; +/* Status dot */ +.status-dot { + @apply inline-block w-1.5 h-1.5 rounded-full; } +.status-dot-live { @apply bg-[var(--success)]; } +.status-dot-warning { @apply bg-[var(--warning)]; } +.status-dot-offline { @apply bg-[var(--foreground-subtle)]; } /* ============================================ - RANGE SLIDER STYLING + RANGE SLIDER — Stripe purple thumb ============================================ */ input[type="range"] { @@ -285,81 +399,52 @@ input[type="range"] { cursor: pointer; } -/* Track */ -input[type="range"]::-webkit-slider-track { - background: linear-gradient(to right, #e2e8f0, #f1f5f9); - height: 0.5rem; - border-radius: 0.5rem; -} - +input[type="range"]::-webkit-slider-track, input[type="range"]::-moz-range-track { - background: linear-gradient(to right, #e2e8f0, #f1f5f9); - height: 0.5rem; - border-radius: 0.5rem; + background: var(--border); + height: 0.375rem; + border-radius: var(--radius-sm); } -/* Thumb - Updated to Indigo */ input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; - height: 1.25rem; - width: 1.25rem; + height: 1.125rem; + width: 1.125rem; border-radius: 50%; - background: linear-gradient(135deg, #6366f1, #4f46e5); - border: 2px solid white; - box-shadow: 0 2px 6px rgba(99, 102, 241, 0.4); + background: var(--gradient-primary); + border: 2px solid var(--surface); + box-shadow: var(--shadow-sm); margin-top: -0.375rem; - transition: all 0.15s ease; + transition: box-shadow 0.15s ease, transform 0.15s ease; } input[type="range"]::-moz-range-thumb { - height: 1.25rem; - width: 1.25rem; + height: 1.125rem; + width: 1.125rem; border-radius: 50%; - background: linear-gradient(135deg, #6366f1, #4f46e5); - border: 2px solid white; - box-shadow: 0 2px 6px rgba(99, 102, 241, 0.4); - transition: all 0.15s ease; -} - -/* Hover states */ -input[type="range"]:hover::-webkit-slider-thumb { - background: linear-gradient(135deg, #4f46e5, #4338ca); - box-shadow: 0 4px 12px rgba(99, 102, 241, 0.5); - transform: scale(1.1); + background: var(--gradient-primary); + border: 2px solid var(--surface); + box-shadow: var(--shadow-sm); + transition: box-shadow 0.15s ease, transform 0.15s ease; } +input[type="range"]:hover::-webkit-slider-thumb, input[type="range"]:hover::-moz-range-thumb { - background: linear-gradient(135deg, #4f46e5, #4338ca); - box-shadow: 0 4px 12px rgba(99, 102, 241, 0.5); - transform: scale(1.1); -} - -/* Focus states */ -input[type="range"]:focus::-webkit-slider-thumb { - outline: none; - box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.3), 0 2px 6px rgba(99, 102, 241, 0.4); + transform: scale(1.06); + box-shadow: var(--shadow-card); } +input[type="range"]:focus::-webkit-slider-thumb, input[type="range"]:focus::-moz-range-thumb { outline: none; - box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.3), 0 2px 6px rgba(99, 102, 241, 0.4); -} - -/* Disabled state */ -input[type="range"]:disabled { - cursor: not-allowed; - opacity: 0.5; -} - -input[type="range"]:disabled::-webkit-slider-thumb { - background: #94a3b8; - box-shadow: none; - cursor: not-allowed; + box-shadow: var(--shadow-focus); } +input[type="range"]:disabled { cursor: not-allowed; opacity: 0.5; } +input[type="range"]:disabled::-webkit-slider-thumb, input[type="range"]:disabled::-moz-range-thumb { - background: #94a3b8; + background: var(--foreground-subtle); box-shadow: none; cursor: not-allowed; } @@ -369,39 +454,28 @@ input[type="range"]:disabled::-moz-range-thumb { ============================================ */ @media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { + *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } } -/* Focus visible for keyboard navigation */ :focus-visible { - @apply outline-none ring-2 ring-indigo-500/50 ring-offset-2; + outline: none; } /* ============================================ - SCROLLBAR STYLING + SCROLLBAR ============================================ */ -.custom-scrollbar::-webkit-scrollbar { - width: 8px; - height: 8px; -} - +.custom-scrollbar::-webkit-scrollbar { width: 8px; height: 8px; } .custom-scrollbar::-webkit-scrollbar-track { - background: #f1f5f9; - border-radius: 4px; + background: var(--surface-muted); + border-radius: var(--radius-sm); } - .custom-scrollbar::-webkit-scrollbar-thumb { - background: #cbd5e1; - border-radius: 4px; -} - -.custom-scrollbar::-webkit-scrollbar-thumb:hover { - background: #94a3b8; + background: var(--border-strong); + border-radius: var(--radius-sm); } +.custom-scrollbar::-webkit-scrollbar-thumb:hover { background: var(--foreground-subtle); } diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 945c320..04f752b 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,7 +1,6 @@ 'use client'; import { useState, useEffect } from "react"; -import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { HelpCircle } from "lucide-react"; import HelpModal from "@/components/HelpModal"; @@ -59,17 +58,18 @@ export default function RootLayout({ {/* Main Content */} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 60fdf78..47f85f3 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState, useRef, useCallback, useEffect } from 'react'; +import { Bot } from 'lucide-react'; import FileUpload from '@/components/FileUpload'; import ColumnTags from '@/components/ColumnTags'; import DataPreview from '@/components/DataPreview'; @@ -25,6 +26,9 @@ export default function Home() { const [browserVisible, setBrowserVisible] = useState(false); const [availableBackends, setAvailableBackends] = useState(['local', 'firecrawl']); const [browserVisibleSupported, setBrowserVisibleSupported] = useState(false); + const [model, setModel] = useState(undefined); + const [llmProfile, setLlmProfile] = useState(undefined); + const [backendOnline, setBackendOnline] = useState(false); const [currentProfileName, setCurrentProfileName] = useState(null); const [isProcessing, setIsProcessing] = useState(false); const [results, setResults] = useState([]); @@ -36,6 +40,9 @@ export default function Home() { .then((caps) => { setAvailableBackends(caps.availableScrapeBackends); setBrowserVisibleSupported(caps.browserVisibleSupported); + setModel(caps.model); + setLlmProfile(caps.llmProfile); + setBackendOnline(true); // If current default isn't actually available, fall back to first available. if (!caps.availableScrapeBackends.includes(scrapeBackend) && caps.availableScrapeBackends.length > 0) { setScrapeBackend(caps.availableScrapeBackends[0]); @@ -43,6 +50,7 @@ export default function Home() { }) .catch((err) => { console.warn('Failed to fetch agent capabilities:', err); + setBackendOnline(false); }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -210,21 +218,45 @@ export default function Home() { }, []); return ( -
-
- {/* Header */} -
-

- Knowledge Robot -

-

- An agentic AI for repetitive knowledge work — web research, browsing, - structured extraction. Drop in a CSV, describe the task, define the - output, and let the agent run it row-by-row. -

+
+ {/* Hero Header — full viewport bleed */} +
+
+
+
+ {/* Brand mark — Bot icon tile */} + +
+

+ Knowledge Robot +

+

+ An agentic AI for repetitive knowledge work — web research, browsing, + structured extraction. Drop in a CSV, describe the task, define the + output, and let the agent run it row-by-row. +

+
+
+ + {/* Backend status chip */} + +
+
- {/* Main Content */} + {/* Main Content */} +
{/* File Upload - Always show when no CSV */} {!csvData && ( @@ -335,7 +367,56 @@ export default function Home() { />
-
+ +
+ ); +} + +interface BackendStatusChipProps { + online: boolean; + backends: ScrapeBackend[]; + model?: string; + llmProfile?: string; +} + +function BackendStatusChip({ online, backends, model, llmProfile }: BackendStatusChipProps) { + // Trim model string for display: "deepinfra/google/gemma-4-31B-it" -> "gemma-4-31B-it" + const trimmedModel = model + ? model.split('/').slice(-1)[0] + : null; + + return ( +
+ + + {online && backends.length > 0 && ( + <> + + + {backends.join(' + ')} + + + )} + {online && (llmProfile || trimmedModel) && ( + <> + + + {llmProfile && {llmProfile}/} + {trimmedModel} + + + )}
); } diff --git a/frontend/components/ColumnTags.tsx b/frontend/components/ColumnTags.tsx index 8055a25..4f22bbb 100644 --- a/frontend/components/ColumnTags.tsx +++ b/frontend/components/ColumnTags.tsx @@ -7,47 +7,33 @@ interface ColumnTagsProps { onTagClick: (column: string) => void; } -// Indigo-based gradient color palette -const colors = [ - 'from-indigo-100 to-indigo-50 text-indigo-700 border-indigo-200/50 hover:from-indigo-200 hover:to-indigo-100', - 'from-violet-100 to-violet-50 text-violet-700 border-violet-200/50 hover:from-violet-200 hover:to-violet-100', - 'from-blue-100 to-blue-50 text-blue-700 border-blue-200/50 hover:from-blue-200 hover:to-blue-100', - 'from-cyan-100 to-cyan-50 text-cyan-700 border-cyan-200/50 hover:from-cyan-200 hover:to-cyan-100', - 'from-teal-100 to-teal-50 text-teal-700 border-teal-200/50 hover:from-teal-200 hover:to-teal-100', - 'from-emerald-100 to-emerald-50 text-emerald-700 border-emerald-200/50 hover:from-emerald-200 hover:to-emerald-100', - 'from-purple-100 to-purple-50 text-purple-700 border-purple-200/50 hover:from-purple-200 hover:to-purple-100', - 'from-fuchsia-100 to-fuchsia-50 text-fuchsia-700 border-fuchsia-200/50 hover:from-fuchsia-200 hover:to-fuchsia-100', -]; - export default function ColumnTags({ columns, onTagClick }: ColumnTagsProps) { return (
-
-
- -
-
-

Column Tags

-

Click to insert into prompt

-
- - {columns.length} columns +
+ 1 + +

Column Tags

+ — click to insert into prompt + + {columns.length} {columns.length === 1 ? 'column' : 'columns'}
-
- {columns.map((column, index) => ( +
+ {columns.map((column) => ( ))} diff --git a/frontend/components/DataPreview.tsx b/frontend/components/DataPreview.tsx index b13bed7..6ed03fb 100644 --- a/frontend/components/DataPreview.tsx +++ b/frontend/components/DataPreview.tsx @@ -1,7 +1,7 @@ 'use client'; import { CsvRow } from '@/types'; -import { Table, FileSpreadsheet } from 'lucide-react'; +import { FileSpreadsheet } from 'lucide-react'; interface DataPreviewProps { headers: string[]; @@ -11,52 +11,49 @@ interface DataPreviewProps { export default function DataPreview({ headers, rows }: DataPreviewProps) { return (
-
-
- -
-
-

Data Preview

-

- {rows.length} row{rows.length !== 1 ? 's' : ''} · {headers.length} column{headers.length !== 1 ? 's' : ''} -

-
+
+ 1 + +

Data Preview

+ + {rows.length} {rows.length === 1 ? 'row' : 'rows'} · {headers.length} {headers.length === 1 ? 'column' : 'columns'} +
-
- - +
+
+ - {headers.map((header) => ( ))} - + {rows.map((row, rowIndex) => ( - {headers.map((header) => ( ))} diff --git a/frontend/components/ExecutionControls.tsx b/frontend/components/ExecutionControls.tsx index d4b4797..4a02b3f 100644 --- a/frontend/components/ExecutionControls.tsx +++ b/frontend/components/ExecutionControls.tsx @@ -25,23 +25,22 @@ export default function ExecutionControls({ }: ExecutionControlsProps) { return (
-
-
- -
-

Execution Controls

+
+ 3 + +

Execution Controls

-
+
{/* Concurrent Runs Slider */}
-
-
+ # {header}
+ {rowIndex + 1} {row[header] !== null && row[header] !== undefined ? String(row[header]) - : } + : }