diff --git a/ui/src/assets/icons/chat-bubbles.png b/ui/src/assets/icons/chat-bubbles.png new file mode 100644 index 0000000..118e4d9 Binary files /dev/null and b/ui/src/assets/icons/chat-bubbles.png differ diff --git a/ui/src/assets/icons/dashboard.png b/ui/src/assets/icons/dashboard.png new file mode 100644 index 0000000..ea2f7e0 Binary files /dev/null and b/ui/src/assets/icons/dashboard.png differ diff --git a/ui/src/assets/icons/logout.png b/ui/src/assets/icons/logout.png new file mode 100644 index 0000000..a0b94ff Binary files /dev/null and b/ui/src/assets/icons/logout.png differ diff --git a/ui/src/assets/icons/moon.png b/ui/src/assets/icons/moon.png new file mode 100644 index 0000000..ed711e9 Binary files /dev/null and b/ui/src/assets/icons/moon.png differ diff --git a/ui/src/assets/icons/save.png b/ui/src/assets/icons/save.png new file mode 100644 index 0000000..059ad8b Binary files /dev/null and b/ui/src/assets/icons/save.png differ diff --git a/ui/src/assets/icons/setting.png b/ui/src/assets/icons/setting.png new file mode 100644 index 0000000..7f21dcb Binary files /dev/null and b/ui/src/assets/icons/setting.png differ diff --git a/ui/src/assets/icons/sun.png b/ui/src/assets/icons/sun.png new file mode 100644 index 0000000..64e9653 Binary files /dev/null and b/ui/src/assets/icons/sun.png differ diff --git a/ui/src/assets/icons/upload.png b/ui/src/assets/icons/upload.png new file mode 100644 index 0000000..ba0d959 Binary files /dev/null and b/ui/src/assets/icons/upload.png differ diff --git a/ui/src/components/app/ChatInput.test.tsx b/ui/src/components/app/ChatInput.test.tsx new file mode 100644 index 0000000..af0d399 --- /dev/null +++ b/ui/src/components/app/ChatInput.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { ChatInput } from "@/components/app/ChatInput"; + +describe("ChatInput", () => { + it("does not render the removed sample question chips", () => { + render( + , + ); + + expect(screen.queryByText("Why is pipeline conversion dropping?")).not.toBeInTheDocument(); + expect(screen.queryByText("Generate SQL for this question")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/src/components/app/ChatInput.tsx b/ui/src/components/app/ChatInput.tsx index 86cb9d2..3c881c1 100644 --- a/ui/src/components/app/ChatInput.tsx +++ b/ui/src/components/app/ChatInput.tsx @@ -1,7 +1,6 @@ import { useEffect, useLayoutEffect, useRef } from "react"; import { Button } from "@/components/shared/Button"; import { Textarea } from "@/components/shared/Textarea"; -import { PromptChips } from "@/components/app/PromptChips"; import { Spinner } from "@/components/shared/Spinner"; import { UPLOAD_ACCEPT } from "@/lib/uploads"; import type { UploadedAsset } from "@/types/upload"; @@ -10,7 +9,6 @@ interface ChatInputProps { value: string; onChange: (value: string) => void; onSubmit: () => void; - onPickPrompt: (prompt: string) => void; onUpload: (file: File) => void; onRemoveAttachment: (assetId: string) => void; attachments: UploadedAsset[]; @@ -22,7 +20,6 @@ export function ChatInput({ value, onChange, onSubmit, - onPickPrompt, onUpload, onRemoveAttachment, attachments, @@ -77,7 +74,6 @@ export function ChatInput({ return (
- {attachments.length > 0 ? (
{attachments.map((asset) => ( @@ -127,7 +123,7 @@ export function ChatInput({ CSV or JSON only
diff --git a/ui/src/components/app/ChatMessage.tsx b/ui/src/components/app/ChatMessage.tsx index 424f264..893b23a 100644 --- a/ui/src/components/app/ChatMessage.tsx +++ b/ui/src/components/app/ChatMessage.tsx @@ -21,9 +21,9 @@ export function ChatMessage({ message, onInspect }: ChatMessageProps) { if (!isAssistant) { return (
-
+

{message.content}

-

{formatTimestamp(message.createdAt)}

+

{formatTimestamp(message.createdAt)}

); diff --git a/ui/src/components/app/InsightCard.tsx b/ui/src/components/app/InsightCard.tsx index a3c1383..cfdd9fa 100644 --- a/ui/src/components/app/InsightCard.tsx +++ b/ui/src/components/app/InsightCard.tsx @@ -9,8 +9,8 @@ interface InsightCardProps { const toneStyles = { neutral: "bg-panel", - positive: "bg-green-50/70", - caution: "bg-amber-50/80", + positive: "bg-success-soft/70", + caution: "bg-warning-soft/80", }; export function InsightCard({ title, body, tone = "neutral" }: InsightCardProps) { diff --git a/ui/src/components/app/InspectionPanel.test.tsx b/ui/src/components/app/InspectionPanel.test.tsx new file mode 100644 index 0000000..2afec47 --- /dev/null +++ b/ui/src/components/app/InspectionPanel.test.tsx @@ -0,0 +1,67 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { InspectionPanel } from "@/components/app/InspectionPanel"; +import type { InspectionData } from "@/types/inspection"; + +const inspection: InspectionData = { + id: "inspection_1", + title: "Enterprise conversion review", + status: "valid", + verified: true, + queryType: "SQL", + engine: "DuckDB", + dataSource: "Revenue warehouse", + rowsReturned: 3, + lastUpdated: "2026-04-14T12:00:00.000Z", + filters: ["segment = enterprise"], + query: "select * from revenue_pipeline", + runtimeMs: 814, + confidence: 0.92, + metadata: [ + { label: "Warehouse", value: "Revenue warehouse" }, + { label: "Model", value: "revenue_pipeline" }, + ], + results: { + columns: ["segment", "conversion_rate"], + rows: [{ segment: "Enterprise", conversion_rate: "34.1%" }], + }, + trace: [ + { + id: "trace_1", + label: "Compiled SQL", + description: "The query writer produced the final SQL for execution.", + detail: "1 statement compiled.", + durationLabel: "142 ms", + status: "complete", + }, + ], + validation: [ + { + id: "validation_1", + label: "Query valid", + detail: "SQL parsed and executed without syntax errors.", + status: "pass", + }, + ], +}; + +describe("InspectionPanel", () => { + it("renders without an expand control and uses the wide drawer layout", () => { + render( + , + ); + + expect(screen.queryByRole("button", { name: /expand|collapse/i })).not.toBeInTheDocument(); + + const dialog = screen.getByRole("dialog", { name: "Enterprise conversion review" }); + expect(dialog.className).toContain("lg:w-[min(88vw,980px)]"); + }); +}); diff --git a/ui/src/components/app/InspectionPanel.tsx b/ui/src/components/app/InspectionPanel.tsx index 393ff40..9cc96e7 100644 --- a/ui/src/components/app/InspectionPanel.tsx +++ b/ui/src/components/app/InspectionPanel.tsx @@ -1,7 +1,6 @@ import { Drawer } from "@/components/shared/Drawer"; import { ErrorState } from "@/components/shared/ErrorState"; import { Spinner } from "@/components/shared/Spinner"; -import { Button } from "@/components/shared/Button"; import { SqlPreview } from "@/components/app/SqlPreview"; import { ResultTable } from "@/components/app/ResultTable"; import { ValidationSummary } from "@/components/app/ValidationSummary"; @@ -17,9 +16,7 @@ interface InspectionPanelProps { error: string | null; inspection: InspectionData | null; activeTab: InspectionTabId; - maximized: boolean; onClose: () => void; - onToggleMaximized: () => void; onTabChange: (tab: InspectionTabId) => void; } @@ -58,9 +55,7 @@ export function InspectionPanel({ error, inspection, activeTab, - maximized, onClose, - onToggleMaximized, onTabChange, }: InspectionPanelProps) { return ( @@ -69,12 +64,7 @@ export function InspectionPanel({ onClose={onClose} title={inspection?.title ?? "Inspection"} subtitle="LeetCode-style execution feedback adapted for analytics workflows." - maximized={maximized} - actions={ - - } + size="wide" > {loading ? (
diff --git a/ui/src/components/app/PromptChips.tsx b/ui/src/components/app/PromptChips.tsx deleted file mode 100644 index c34ae37..0000000 --- a/ui/src/components/app/PromptChips.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { promptSuggestions } from "@/lib/constants"; - -interface PromptChipsProps { - onPick: (prompt: string) => void; -} - -export function PromptChips({ onPick }: PromptChipsProps) { - return ( -
- {promptSuggestions.map((prompt) => ( - - ))} -
- ); -} diff --git a/ui/src/components/app/ResultTable.tsx b/ui/src/components/app/ResultTable.tsx index f9d92ca..3f97877 100644 --- a/ui/src/components/app/ResultTable.tsx +++ b/ui/src/components/app/ResultTable.tsx @@ -21,7 +21,7 @@ export function ResultTable({ title, table }: ResultTableProps) { ))} - + {table.rows.map((row, rowIndex) => ( {table.columns.map((column) => ( diff --git a/ui/src/components/app/Sidebar.test.tsx b/ui/src/components/app/Sidebar.test.tsx index 19363e5..c0773fa 100644 --- a/ui/src/components/app/Sidebar.test.tsx +++ b/ui/src/components/app/Sidebar.test.tsx @@ -2,45 +2,97 @@ import { render, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { describe, expect, it, vi } from "vitest"; import { Sidebar } from "@/components/app/Sidebar"; +import { ThemeProvider } from "@/context/ThemeProvider"; import { AuthContext } from "@/context/auth-context"; describe("Sidebar", () => { - it("does not render a recent uploads section", () => { + const renderSidebar = (props?: Partial>) => render( - - - + + + + + , ); + it("does not render a recent uploads section", () => { + renderSidebar(); + expect(screen.queryByText("Recent uploads")).not.toBeInTheDocument(); }); + + it("renders the theme toggle in collapsed and mobile sidebar states", () => { + const { rerender } = renderSidebar({ collapsed: true }); + + expect(screen.getAllByRole("button", { name: "Switch to dark mode" }).length).toBeGreaterThan(0); + + rerender( + + + + + + + , + ); + + expect(screen.getAllByRole("button", { name: "Switch to dark mode" }).length).toBeGreaterThan(0); + }); }); diff --git a/ui/src/components/app/Sidebar.tsx b/ui/src/components/app/Sidebar.tsx index 943e6f5..586fbb1 100644 --- a/ui/src/components/app/Sidebar.tsx +++ b/ui/src/components/app/Sidebar.tsx @@ -1,6 +1,14 @@ +import chatBubblesIcon from "@/assets/icons/chat-bubbles.png"; +import dashboardIcon from "@/assets/icons/dashboard.png"; +import logoutIcon from "@/assets/icons/logout.png"; +import saveIcon from "@/assets/icons/save.png"; +import settingIcon from "@/assets/icons/setting.png"; +import uploadIcon from "@/assets/icons/upload.png"; import { Link } from "react-router-dom"; import { Button } from "@/components/shared/Button"; import { Drawer } from "@/components/shared/Drawer"; +import { MaskedIcon } from "@/components/shared/MaskedIcon"; +import { ThemeToggle } from "@/components/shared/ThemeToggle"; import { useAuth } from "@/hooks/useAuth"; import { sidebarNavItems } from "@/lib/constants"; import { classNames } from "@/lib/classNames"; @@ -24,33 +32,27 @@ interface SidebarProps { } const navIcons = { - chats: ( - - ), - uploads: ( - - ), - saved: ( - - ), - dashboards: , + chats: chatBubblesIcon, + uploads: uploadIcon, + saved: saveIcon, + dashboards: dashboardIcon, }; function BrandMark({ showExpandCue = false }: { showExpandCue?: boolean }) { return (
P {showExpandCue ? ( <> -
-
-        {code}
+      
+        {code}
       
); diff --git a/ui/src/components/app/StatusBadge.tsx b/ui/src/components/app/StatusBadge.tsx index 6bf0cf5..55dd2f1 100644 --- a/ui/src/components/app/StatusBadge.tsx +++ b/ui/src/components/app/StatusBadge.tsx @@ -8,9 +8,9 @@ interface StatusBadgeProps { const toneStyles = { neutral: "border-line bg-surface text-muted", accent: "border-accent/15 bg-accent-soft text-accent-strong", - success: "border-green-200 bg-green-50 text-success", - warning: "border-amber-200 bg-amber-50 text-warning", - danger: "border-red-200 bg-red-50 text-danger", + success: "border-success/20 bg-success-soft text-success", + warning: "border-warning/20 bg-warning-soft text-warning", + danger: "border-danger/20 bg-danger-soft text-danger", }; export function StatusBadge({ label, tone = "neutral" }: StatusBadgeProps) { diff --git a/ui/src/components/marketing/HeroSection.tsx b/ui/src/components/marketing/HeroSection.tsx index 97c7795..8da1b59 100644 --- a/ui/src/components/marketing/HeroSection.tsx +++ b/ui/src/components/marketing/HeroSection.tsx @@ -38,7 +38,7 @@ export function HeroSection() {
- +
@@ -50,7 +50,7 @@ export function HeroSection() {
-
+
Why is enterprise conversion softening this month?
@@ -90,7 +90,7 @@ export function HeroSection() {
-
+
SELECT segment, AVG(conversion_rate)
FROM revenue_pipeline diff --git a/ui/src/components/marketing/Navbar.test.tsx b/ui/src/components/marketing/Navbar.test.tsx new file mode 100644 index 0000000..d77e3f4 --- /dev/null +++ b/ui/src/components/marketing/Navbar.test.tsx @@ -0,0 +1,19 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it } from "vitest"; +import { Navbar } from "@/components/marketing/Navbar"; +import { ThemeProvider } from "@/context/ThemeProvider"; + +describe("Navbar", () => { + it("renders the theme toggle in the marketing navigation", () => { + render( + + + + + , + ); + + expect(screen.getAllByRole("button", { name: "Switch to dark mode" })).toHaveLength(2); + }); +}); diff --git a/ui/src/components/marketing/Navbar.tsx b/ui/src/components/marketing/Navbar.tsx index 8cc5027..60b426f 100644 --- a/ui/src/components/marketing/Navbar.tsx +++ b/ui/src/components/marketing/Navbar.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { Link, useLocation } from "react-router-dom"; import { Button } from "@/components/shared/Button"; import { PageContainer } from "@/components/shared/PageContainer"; +import { ThemeToggle } from "@/components/shared/ThemeToggle"; import { homeNavLinks } from "@/lib/constants"; import { classNames } from "@/lib/classNames"; @@ -26,7 +27,7 @@ export function Navbar() { )} > -
P
+
P
Planera
+ Sign In diff --git a/ui/src/components/marketing/PricingSection.tsx b/ui/src/components/marketing/PricingSection.tsx index 9333136..481ce56 100644 --- a/ui/src/components/marketing/PricingSection.tsx +++ b/ui/src/components/marketing/PricingSection.tsx @@ -69,7 +69,7 @@ export function PricingSection() { className="overflow-hidden rounded-[36px] border border-line bg-panel px-6 py-8 shadow-soft sm:px-8 sm:py-10 lg:px-10" style={{ backgroundImage: - "radial-gradient(circle at top right, rgba(35, 88, 82, 0.14), transparent 30%), linear-gradient(180deg, rgba(255, 253, 249, 0.96), rgba(247, 242, 234, 0.92))", + "radial-gradient(circle at top right, rgb(var(--color-accent) / 0.16), transparent 30%), linear-gradient(180deg, rgb(var(--color-panel) / 0.96), rgb(var(--color-elevated) / 0.92))", }} >
@@ -82,7 +82,7 @@ export function PricingSection() {

- +

All paid plans include

{[ @@ -106,7 +106,7 @@ export function PricingSection() { elevated className={classNames( "flex h-full flex-col rounded-[30px] p-7 sm:p-8", - plan.featured ? "border-ink bg-ink text-white" : "bg-panel/85 backdrop-blur", + plan.featured ? "border-contrast bg-contrast text-contrast-foreground" : "bg-panel/85 backdrop-blur", )} >
@@ -114,42 +114,44 @@ export function PricingSection() {

{plan.label}

-

{plan.name}

+

{plan.name}

{plan.featured ? ( - + Popular ) : null}
-

{plan.description}

+

{plan.description}

- {plan.price} - {plan.cadence} + {plan.price} + {plan.cadence}
-

{plan.note}

+

{plan.note}

-
-

+

+

What's included

    @@ -158,10 +160,10 @@ export function PricingSection() { - {feature} + {feature} ))}
diff --git a/ui/src/components/shared/Button.tsx b/ui/src/components/shared/Button.tsx index 91ceec1..cc34a83 100644 --- a/ui/src/components/shared/Button.tsx +++ b/ui/src/components/shared/Button.tsx @@ -12,11 +12,11 @@ interface ButtonProps extends ButtonHTMLAttributes, PropsWith const variantStyles: Record = { primary: - "bg-ink text-white shadow-card hover:bg-black/90 focus-visible:ring-ink/20", + "bg-contrast text-contrast-foreground shadow-card hover:bg-contrast/90 focus-visible:ring-accent/30", secondary: - "border border-line bg-panel text-ink hover:border-ink/20 hover:bg-white", + "border border-line bg-panel text-ink hover:border-line/90 hover:bg-surface", ghost: - "bg-transparent text-muted hover:bg-black/[0.03] hover:text-ink", + "bg-transparent text-muted hover:bg-surface hover:text-ink", subtle: "bg-accent-soft text-accent-strong hover:bg-accent-soft/80", }; diff --git a/ui/src/components/shared/Drawer.tsx b/ui/src/components/shared/Drawer.tsx index 84419a6..320057b 100644 --- a/ui/src/components/shared/Drawer.tsx +++ b/ui/src/components/shared/Drawer.tsx @@ -7,7 +7,7 @@ interface DrawerProps { title?: string; subtitle?: string; side?: "left" | "right"; - maximized?: boolean; + size?: "default" | "wide"; actions?: ReactNode; children: ReactNode; } @@ -18,7 +18,7 @@ export function Drawer({ title, subtitle, side = "right", - maximized = false, + size = "default", actions, children, }: DrawerProps) { @@ -61,7 +61,7 @@ export function Drawer({ side === "right" ? "right-0 border-l" : "left-0 border-r", - maximized + size === "wide" ? "w-full lg:w-[min(88vw,980px)]" : "w-full sm:w-[min(92vw,560px)] lg:w-[520px]", open @@ -70,6 +70,9 @@ export function Drawer({ ? "translate-x-full" : "-translate-x-full", )} + role="dialog" + aria-modal="true" + aria-label={title} >
diff --git a/ui/src/components/shared/ErrorState.tsx b/ui/src/components/shared/ErrorState.tsx index 07f99f8..57b280a 100644 --- a/ui/src/components/shared/ErrorState.tsx +++ b/ui/src/components/shared/ErrorState.tsx @@ -9,9 +9,9 @@ interface ErrorStateProps { export function ErrorState({ title, description, onRetry }: ErrorStateProps) { return ( - +
-
+
diff --git a/ui/src/components/shared/Input.tsx b/ui/src/components/shared/Input.tsx index 9eb3ada..46f78a6 100644 --- a/ui/src/components/shared/Input.tsx +++ b/ui/src/components/shared/Input.tsx @@ -5,7 +5,7 @@ export function Input({ className, ...props }: InputHTMLAttributes
{error ? ( -
+
{error}
) : null} diff --git a/ui/src/router/RootLayout.tsx b/ui/src/router/RootLayout.tsx index 32be3ef..6526c59 100644 --- a/ui/src/router/RootLayout.tsx +++ b/ui/src/router/RootLayout.tsx @@ -1,10 +1,13 @@ import { Outlet } from "react-router-dom"; import { AuthProvider } from "@/context/AuthProvider"; +import { ThemeProvider } from "@/context/ThemeProvider"; export function RootLayout() { return ( - - - + + + + + ); } diff --git a/ui/src/store/uiStore.ts b/ui/src/store/uiStore.ts index bcc4ce4..a158179 100644 --- a/ui/src/store/uiStore.ts +++ b/ui/src/store/uiStore.ts @@ -2,8 +2,11 @@ const keys = { sidebarCollapsed: "planera.sidebar.collapsed", activeSection: "planera.active.section", activeConversation: "planera.active.conversation", + theme: "planera.theme", }; +type ThemePreference = "light" | "dark"; + export const uiStore = { getSidebarCollapsed() { return window.localStorage.getItem(keys.sidebarCollapsed) === "true"; @@ -23,4 +26,11 @@ export const uiStore = { setActiveConversation(value: string) { window.localStorage.setItem(keys.activeConversation, value); }, + getTheme(): ThemePreference | null { + const value = window.localStorage.getItem(keys.theme); + return value === "light" || value === "dark" ? value : null; + }, + setTheme(value: ThemePreference) { + window.localStorage.setItem(keys.theme, value); + }, }; diff --git a/ui/src/styles/globals.css b/ui/src/styles/globals.css index a56ab53..067ba92 100644 --- a/ui/src/styles/globals.css +++ b/ui/src/styles/globals.css @@ -11,9 +11,13 @@ body { @apply bg-canvas font-sans text-ink antialiased; background-image: - radial-gradient(circle at top left, rgba(35, 88, 82, 0.06), transparent 30%), - radial-gradient(circle at top right, rgba(199, 179, 148, 0.1), transparent 26%); + radial-gradient(circle at top left, rgb(var(--color-accent) / 0.12), transparent 32%), + radial-gradient(circle at top right, rgb(var(--color-contrast) / 0.08), transparent 30%); min-height: 100vh; + transition: + background-color 220ms ease, + color 220ms ease, + background-image 220ms ease; } * { @@ -21,7 +25,7 @@ } ::selection { - background: rgba(35, 88, 82, 0.16); + background: rgb(var(--color-accent) / 0.16); } h1, @@ -49,12 +53,12 @@ } .surface-ring { - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65); + box-shadow: inset 0 1px 0 var(--surface-ring); } .scroll-fade { scrollbar-width: thin; - scrollbar-color: rgba(99, 108, 114, 0.5) transparent; + scrollbar-color: rgb(var(--color-muted) / 0.5) transparent; } .scroll-fade::-webkit-scrollbar { @@ -63,7 +67,7 @@ } .scroll-fade::-webkit-scrollbar-thumb { - background: rgba(99, 108, 114, 0.35); + background: rgb(var(--color-muted) / 0.35); border-radius: 999px; } } diff --git a/ui/src/styles/theme.css b/ui/src/styles/theme.css index 944bd04..70b9649 100644 --- a/ui/src/styles/theme.css +++ b/ui/src/styles/theme.css @@ -1,19 +1,58 @@ -:root { - --color-canvas: #f4efe8; - --color-surface: #faf7f2; - --color-panel: #fffdf9; - --color-elevated: #f7f2ea; - --color-line: #d9d0c4; - --color-ink: #161819; - --color-muted: #636c72; - --color-accent: #235852; - --color-accent-soft: #d9ebe7; - --color-accent-strong: #193f3b; - --color-success: #2f6d5a; - --color-warning: #8a6227; - --color-danger: #8c4141; +:root, +[data-theme="light"] { + color-scheme: light; + --color-canvas: 244 239 232; + --color-surface: 250 247 242; + --color-panel: 255 253 249; + --color-elevated: 247 242 234; + --color-line: 217 208 196; + --color-ink: 22 24 25; + --color-muted: 99 108 114; + --color-contrast: 22 24 25; + --color-contrast-foreground: 255 255 255; + --color-accent: 35 88 82; + --color-accent-soft: 217 235 231; + --color-accent-strong: 25 63 59; + --color-success: 47 109 90; + --color-success-soft: 227 241 234; + --color-warning: 138 98 39; + --color-warning-soft: 247 236 215; + --color-danger: 140 65 65; + --color-danger-soft: 250 232 232; + --color-code: 17 20 23; + --color-code-ink: 230 239 233; --shadow-soft: 0 16px 48px rgba(24, 32, 34, 0.07); --shadow-card: 0 12px 28px rgba(24, 32, 34, 0.06); + --shadow-field: inset 0 1px 0 rgba(255, 255, 255, 0.62); + --surface-ring: rgba(255, 255, 255, 0.65); --radius-panel: 28px; --radius-card: 22px; } + +[data-theme="dark"] { + color-scheme: dark; + --color-canvas: 10 14 18; + --color-surface: 17 23 29; + --color-panel: 24 30 37; + --color-elevated: 31 39 47; + --color-line: 66 78 89; + --color-ink: 231 237 240; + --color-muted: 159 170 178; + --color-contrast: 235 241 243; + --color-contrast-foreground: 12 16 20; + --color-accent: 55 131 120; + --color-accent-soft: 27 56 52; + --color-accent-strong: 195 231 224; + --color-success: 118 200 172; + --color-success-soft: 25 52 46; + --color-warning: 229 183 93; + --color-warning-soft: 71 50 21; + --color-danger: 243 156 156; + --color-danger-soft: 76 37 37; + --color-code: 12 17 22; + --color-code-ink: 226 236 232; + --shadow-soft: 0 22px 56px rgba(1, 5, 9, 0.42); + --shadow-card: 0 14px 34px rgba(1, 5, 9, 0.34); + --shadow-field: inset 0 1px 0 rgba(255, 255, 255, 0.05); + --surface-ring: rgba(255, 255, 255, 0.04); +} diff --git a/ui/src/test/setup.ts b/ui/src/test/setup.ts index f149f27..4bdc87b 100644 --- a/ui/src/test/setup.ts +++ b/ui/src/test/setup.ts @@ -1 +1,26 @@ import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterEach, beforeEach, vi } from "vitest"; + +beforeEach(() => { + Object.defineProperty(window, "matchMedia", { + writable: true, + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}); + +afterEach(() => { + cleanup(); + localStorage.clear(); + delete document.documentElement.dataset.theme; +}); diff --git a/ui/tailwind.config.ts b/ui/tailwind.config.ts index 9bf3272..b79a649 100644 --- a/ui/tailwind.config.ts +++ b/ui/tailwind.config.ts @@ -5,22 +5,38 @@ const config: Config = { theme: { extend: { colors: { - canvas: "#f4efe8", - surface: "#faf7f2", - panel: "#fffdf9", - elevated: "#f7f2ea", - line: "#d9d0c4", - ink: "#161819", - muted: "#636c72", + canvas: "rgb(var(--color-canvas) / )", + surface: "rgb(var(--color-surface) / )", + panel: "rgb(var(--color-panel) / )", + elevated: "rgb(var(--color-elevated) / )", + line: "rgb(var(--color-line) / )", + ink: "rgb(var(--color-ink) / )", + muted: "rgb(var(--color-muted) / )", + contrast: { + DEFAULT: "rgb(var(--color-contrast) / )", + foreground: "rgb(var(--color-contrast-foreground) / )", + }, accent: { - DEFAULT: "#235852", - soft: "#d9ebe7", - strong: "#193f3b", + DEFAULT: "rgb(var(--color-accent) / )", + soft: "rgb(var(--color-accent-soft) / )", + strong: "rgb(var(--color-accent-strong) / )", + }, + success: { + DEFAULT: "rgb(var(--color-success) / )", + soft: "rgb(var(--color-success-soft) / )", + }, + warning: { + DEFAULT: "rgb(var(--color-warning) / )", + soft: "rgb(var(--color-warning-soft) / )", + }, + danger: { + DEFAULT: "rgb(var(--color-danger) / )", + soft: "rgb(var(--color-danger-soft) / )", + }, + code: { + DEFAULT: "rgb(var(--color-code) / )", + ink: "rgb(var(--color-code-ink) / )", }, - sand: "#ece3d5", - success: "#2f6d5a", - warning: "#8a6227", - danger: "#8c4141", }, fontFamily: { sans: ['"Inter"', '"Avenir Next"', '"Segoe UI"', "system-ui", "sans-serif"], @@ -28,9 +44,10 @@ const config: Config = { mono: ['"SFMono-Regular"', '"SF Mono"', "ui-monospace", "monospace"], }, boxShadow: { - soft: "0 16px 48px rgba(24, 32, 34, 0.07)", - card: "0 12px 28px rgba(24, 32, 34, 0.06)", - focus: "0 0 0 3px rgba(35, 88, 82, 0.16)", + soft: "var(--shadow-soft)", + card: "var(--shadow-card)", + field: "var(--shadow-field)", + focus: "0 0 0 3px rgb(var(--color-accent) / 0.16)", }, borderRadius: { xl2: "1.4rem",