diff --git a/.gitignore b/.gitignore index 4a1b295..fb69d1e 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,4 @@ logs/ tmp/ temp/ .cache/ +.vercel diff --git a/frontend/e2e/a11y.spec.ts b/frontend/e2e/a11y.spec.ts index 5aa63e8..54c3622 100644 --- a/frontend/e2e/a11y.spec.ts +++ b/frontend/e2e/a11y.spec.ts @@ -11,10 +11,10 @@ test.describe("Accessibility audit", () => { test("home view has no serious axe violations", async ({ page }) => { await page.goto("/"); await expect(page.locator("main#main")).toBeVisible(); + // Let entrance animations finish so axe does not sample faded text. + await page.waitForTimeout(700); - const results = await new AxeBuilder({ page }) - .disableRules(["color-contrast"]) - .analyze(); + const results = await new AxeBuilder({ page }).analyze(); const serious = results.violations.filter( (v) => v.impact === "serious" || v.impact === "critical", diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts index a77533e..bb8aad2 100644 --- a/frontend/e2e/fixtures.ts +++ b/frontend/e2e/fixtures.ts @@ -83,5 +83,9 @@ export async function installApiMocks(page: Page): Promise { export async function dismissOnboarding(page: Page): Promise { await page.addInitScript(() => { localStorage.setItem("cortex_onboarding_v1", "done"); + localStorage.setItem( + "cortex_settings_v2", + JSON.stringify({ onboardingComplete: true, workspaceId: "local-dev" }), + ); }); } diff --git a/frontend/e2e/journeys.spec.ts b/frontend/e2e/journeys.spec.ts index 9c14649..60d8f59 100644 --- a/frontend/e2e/journeys.spec.ts +++ b/frontend/e2e/journeys.spec.ts @@ -58,4 +58,20 @@ test.describe("Critical user journeys", () => { await skip.focus(); await expect(skip).toBeFocused(); }); + + test("demo journey: ask search, explore, connection panel", async ({ page }) => { + await page.goto("/#ask"); + await expect(page.getByRole("heading", { name: /ask your organization/i })).toBeVisible(); + + await page.getByLabel(/your question/i).fill("Why CockroachDB for payments?"); + await page.getByRole("button", { name: /search memory/i }).click(); + await expect(page.getByText(/1 shown/i)).toBeVisible({ timeout: 10_000 }); + + await page.getByRole("button", { name: /open memory map/i }).click(); + await expect(page.getByRole("heading", { name: /memory map/i })).toBeVisible(); + + await page.getByRole("button", { name: "Connection", exact: true }).click(); + await expect(page.getByLabel(/^api key$/i)).toBeVisible(); + await expect(page.getByRole("button", { name: /test connection/i })).toBeVisible(); + }); }); diff --git a/frontend/src/components/layout/WorkspaceBar.test.tsx b/frontend/src/components/layout/WorkspaceBar.test.tsx new file mode 100644 index 0000000..cd000da --- /dev/null +++ b/frontend/src/components/layout/WorkspaceBar.test.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from "react"; +import { fireEvent, render, within } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { WorkspaceBar } from "./WorkspaceBar"; +import { AppProvider } from "../../context/AppContext"; +import { ToastProvider } from "../ui/Toast"; + +function wrap(ui: ReactNode) { + return ( + + {ui} + + ); +} + +describe("WorkspaceBar", () => { + it("renders workspace controls and expandable connection settings", () => { + const view = render(wrap()); + const bar = view.getByLabelText(/workspace and api connection/i); + expect(within(bar).getByLabelText(/organization workspace/i)).toBeInTheDocument(); + fireEvent.click(within(bar).getByRole("button", { name: "Connection" })); + expect(within(bar).getByLabelText(/^api key$/i)).toBeInTheDocument(); + expect(within(bar).getByRole("button", { name: /test connection/i })).toBeInTheDocument(); + expect(within(bar).getByRole("button", { name: /replay tour/i })).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/memory/DecisionCard.test.tsx b/frontend/src/components/memory/DecisionCard.test.tsx new file mode 100644 index 0000000..780addc --- /dev/null +++ b/frontend/src/components/memory/DecisionCard.test.tsx @@ -0,0 +1,42 @@ +import { fireEvent, render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { DecisionCard } from "./DecisionCard"; +import type { DecisionResult } from "../../types"; +import { ToastProvider } from "../ui/Toast"; + +const decision: DecisionResult = { + event_id: "card-1", + event_type: "decision", + content: "Use Redis for session cache at checkout.", + made_by: ["alex@acme.example"], + affects: ["checkout-service"], + rationale: ["Sub-millisecond reads matter for cart state."], + importance_score: 0.75, + trust_score: 0.8, + extraction_confidence: 0.9, + source: "slack", + channel: "eng", + extracted_at: "2026-05-14T12:00:00Z", + status: "active", +}; + +describe("DecisionCard", () => { + it("expands to show rationale on header click", () => { + const view = render( + + + , + ); + fireEvent.click(view.getByRole("button", { name: /use redis/i })); + expect(view.getByText(/sub-millisecond reads/i)).toBeInTheDocument(); + }); + + it("shows copy link action when expanded", () => { + const view = render( + + + , + ); + expect(view.getAllByRole("button", { name: /copy link/i }).length).toBeGreaterThan(0); + }); +}); diff --git a/frontend/src/components/ui/StateView.test.tsx b/frontend/src/components/ui/StateView.test.tsx new file mode 100644 index 0000000..edd7bf5 --- /dev/null +++ b/frontend/src/components/ui/StateView.test.tsx @@ -0,0 +1,21 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { StateView } from "./StateView"; + +describe("StateView", () => { + it("renders title and body", () => { + render( + + Connect your API to populate memory. + , + ); + expect(screen.getByRole("status")).toBeInTheDocument(); + expect(screen.getByText("No data yet")).toBeInTheDocument(); + expect(screen.getByText(/connect your api/i)).toBeInTheDocument(); + }); + + it("uses alert role for error tone", () => { + render(); + expect(screen.getByRole("alert")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/index.css b/frontend/src/index.css index 8fd06b5..885f249 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -10,8 +10,8 @@ --border-strong: rgba(148, 163, 184, 0.22); --text: #f1f5f9; /* Bumped from #94a3b8 → #a8b3c2 to clear WCAG AA on dark surfaces */ - --text-muted: #a8b3c2; - --text-soft: #7a8597; + --text-muted: #b0bac7; + --text-soft: #8b96a8; --accent: #5eead4; --accent-dim: #2dd4bf; --accent-violet: #a78bfa; @@ -532,12 +532,13 @@ a { .chip { padding: 0.35rem 0.75rem; border-radius: 999px; - border: 1px solid var(--border); - background: var(--bg-raised); - color: var(--text-muted); + border: 1px solid var(--border-strong); + background: var(--surface); + color: #d1d9e4; font-size: 0.8rem; + font-weight: 500; cursor: pointer; - transition: all 0.2s; + transition: border-color 0.2s, background 0.2s, color 0.2s; } .chip:hover, @@ -1511,6 +1512,285 @@ code { } } +/* Product-grade UI */ +.api-health-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.65rem 1.25rem; + font-size: var(--text-sm); + border-bottom: 1px solid var(--border); +} +.api-health-banner--degraded { + background: rgba(251, 191, 36, 0.1); + color: var(--warn); +} +.api-health-banner--unreachable { + background: var(--error-surface); + color: var(--danger); +} +.topbar__health { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--text-soft); + flex-shrink: 0; + position: relative; +} +.topbar__health-label { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} +.topbar__health--ok { + background: var(--ok); + box-shadow: 0 0 8px rgba(110, 231, 183, 0.5); +} +.topbar__health--degraded { + background: var(--warn); +} +.topbar__health--unreachable { + background: var(--danger); +} +.topbar__health--checking { + background: var(--text-muted); +} +.error-boundary { + padding: 2rem 1rem; +} +.state-view__action { + margin-top: 0.75rem; +} +.live-demo-banner { + margin-bottom: var(--space-4); + font-size: var(--text-sm); +} +.connection-bar__status { + display: inline-flex; + align-items: center; + margin-right: 0.35rem; +} +.connection-bar__status--secured { + color: var(--ok); +} +.detail-panel { + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: var(--space-4); + background: var(--surface-glass); + min-width: 280px; + max-width: 360px; +} +.detail-panel__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: var(--space-3); +} +.detail-panel__title { + font-size: var(--text-lg); + margin: 0; +} +.detail-panel__content { + font-size: var(--text-base); + line-height: 1.5; + margin: 0 0 var(--space-3); +} +.detail-panel__actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: var(--space-4); +} +.ask-results-layout, +.explore-layout { + display: grid; + gap: var(--space-4); +} +@media (min-width: 960px) { + .ask-results-layout, + .explore-layout { + grid-template-columns: 1fr minmax(280px, 340px); + align-items: start; + } +} +.ask-filters { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + margin-bottom: var(--space-4); +} +.ask-filters__trust { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: var(--text-sm); + color: var(--text-muted); + min-width: 160px; +} +.memory-graph__scroll { + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} +.memory-graph__tooltip { + font-size: var(--text-sm); + padding: 0.35rem 0.65rem; + margin-bottom: 0.5rem; + border-radius: var(--radius); + background: var(--surface-elevated); + border: 1px solid var(--border); +} +.memory-graph__node--hover .memory-graph__circle { + stroke: var(--accent); + stroke-width: 2px; +} +.subtab__badge { + margin-left: 0.35rem; + padding: 0.1rem 0.45rem; + border-radius: 999px; + font-size: 0.7rem; + background: var(--accent-tint-strong); + color: var(--accent); +} +.subtab__badge--warn { + background: rgba(251, 191, 36, 0.15); + color: var(--warn); +} +.onboarding-workspaces { + display: grid; + gap: 0.5rem; + margin: 1rem 0; + text-align: left; +} +.onboarding-workspace { + display: flex; + flex-direction: column; + gap: 0.15rem; + padding: 0.75rem 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface-inset); + cursor: pointer; + text-align: left; + color: inherit; + min-height: 44px; +} +.onboarding-workspace--active { + border-color: var(--accent-border-hover); + background: var(--accent-tint); +} +.onboarding-workspace__hint { + font-size: var(--text-sm); + color: var(--text-muted); +} +.onboarding-query { + margin: 1rem 0; + padding: 0.75rem 1rem; + border-left: 3px solid var(--accent); + color: var(--text); +} +.inject-preview { + margin: var(--space-4) 0; + padding: var(--space-4); + border-radius: var(--radius); + background: var(--code-bg); + border: 1px solid var(--border); + overflow-x: auto; + font-size: var(--text-sm); + line-height: 1.55; + white-space: pre-wrap; +} +.inject-token-bar { + position: relative; + height: 28px; + border-radius: var(--radius); + background: var(--surface-inset); + border: 1px solid var(--border); + overflow: hidden; + margin-bottom: var(--space-3); +} +.inject-token-bar__fill { + position: absolute; + inset: 0 auto 0 0; + background: linear-gradient(90deg, var(--accent-tint-strong), rgba(94, 234, 212, 0.35)); +} +.inject-token-bar__label { + position: relative; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + height: 100%; + font-size: var(--text-sm); + color: var(--text-muted); +} +.contradiction-diff { + display: grid; + gap: 0.5rem; + margin: 0.75rem 0; +} +@media (min-width: 640px) { + .contradiction-diff { + grid-template-columns: 1fr 1fr; + } +} +.contradiction-diff__col { + padding: 0.65rem 0.75rem; + border-radius: var(--radius); + font-size: var(--text-sm); + border: 1px solid var(--border); +} +.contradiction-diff__col--prior { + background: rgba(252, 165, 165, 0.06); + border-color: var(--error-border); +} +.contradiction-diff__col--new { + background: rgba(110, 231, 183, 0.06); + border-color: var(--secured-border); +} +.contradiction-diff__label { + display: block; + font-size: var(--text-xs); + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted); + margin-bottom: 0.35rem; +} +.story-strip__head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + margin-bottom: var(--space-3); +} +.story-strip__title { + margin: 0; + font-size: var(--text-lg); +} +.story-pill__value { + margin: 0; + line-height: 1.45; +} +.dashboard-panel__foot { + margin-top: var(--space-4); +} +.dashboard-panel__subhead { + margin-top: var(--space-4); +} +.mobile-nav__item { + min-height: 44px; + min-width: 44px; +} + @media (prefers-reduced-motion: reduce) { *, *::before, diff --git a/frontend/src/views/AgentsView.tsx b/frontend/src/views/AgentsView.tsx index 5bdb872..b13b70e 100644 --- a/frontend/src/views/AgentsView.tsx +++ b/frontend/src/views/AgentsView.tsx @@ -204,16 +204,21 @@ export function AgentsView() {

What the agent would see

{result.injected_decisions.length === 0 ? ( - + Cortex didn't find decisions that match this context yet — try a more specific - prompt or seed more memories. + prompt or capture more organizational memory. ) : ( <> -
{result.context_summary}
-

- ~{result.token_estimate} tokens · {result.latency_ms}ms -

+
+
+ + ~{result.token_estimate} / {maxTokens} tokens · {result.latency_ms}ms + +
+
+                {result.context_summary}
+              
{result.injected_decisions.map((d, i) => ( diff --git a/frontend/src/views/ReviewView.tsx b/frontend/src/views/ReviewView.tsx index 765dd0d..2cb6588 100644 --- a/frontend/src/views/ReviewView.tsx +++ b/frontend/src/views/ReviewView.tsx @@ -136,6 +136,24 @@ export function ReviewView() { Score {c.score.toFixed(2)} {c.status} +
+
+ Prior memory +

+ {c.prior_decision_id + ? `Decision ${shortId(c.prior_decision_id)} may be superseded or contradicted.` + : "Prior decision not linked."} +

+
+
+ New capture +

+ {c.new_decision_id + ? `Decision ${shortId(c.new_decision_id)} conflicts with existing memory.` + : "New decision not linked."} +

+
+

{c.explanation}