diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 000000000..4e7c8591b --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "studio", + "runtimeExecutable": "pnpm", + "runtimeArgs": ["run", "dev"], + "port": 5173 + } + ] +} diff --git a/index.html b/index.html index ee20041f0..802c11ff0 100644 --- a/index.html +++ b/index.html @@ -1,5 +1,5 @@ - + diff --git a/src/components/DataTable.tsx b/src/components/DataTable.tsx index 8d8467bd7..270410cca 100644 --- a/src/components/DataTable.tsx +++ b/src/components/DataTable.tsx @@ -17,7 +17,7 @@ export function DataTable({ columns, data }: DataTableProps +
{table.getHeaderGroups().map((headerGroup) => ( @@ -32,7 +32,7 @@ export function DataTable({ columns, data }: DataTableProps ))} - + {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( diff --git a/src/components/Loading.tsx b/src/components/Loading.tsx index 6efe346fd..07a118771 100644 --- a/src/components/Loading.tsx +++ b/src/components/Loading.tsx @@ -8,7 +8,7 @@ interface LoadingProps extends ComponentProps<'div'> { export function Loading({ className, text, centered, ...props }: LoadingProps) { const additionalClassName = centered ? 'flex flex-col items-center justify-center' : ''; return ( -
+
HDB Dog Logo Loading

{!text ? 'Loading...' : text}

diff --git a/src/components/MainLogo.tsx b/src/components/MainLogo.tsx index a2b08ddff..5d0e42d96 100644 --- a/src/components/MainLogo.tsx +++ b/src/components/MainLogo.tsx @@ -5,7 +5,16 @@ export function MainLogo() { <> {isLocalStudio ? Harper Studio - : Harper Fabric} + : ( + <> + Harper Fabric + Harper Fabric + + )} Harper ); diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 95f5aeea7..af45a2b9e 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -28,7 +28,7 @@ import { import { ReactNode, useCallback, useMemo, useState } from 'react'; import { toast } from 'sonner'; -const activeLinkProps = { className: 'text-white' }; +const activeLinkProps = { className: 'text-foreground dark:text-white font-semibold' }; export function Navbar() { const { mutate: signOut } = useLogoutMutation(); @@ -164,7 +164,7 @@ function AnonymousNav() {
- + }) { - + {menuItems.map(menuItem => isMenuGroup(menuItem) ? !!menuItem.items.length && ( -
+
{menuItem.items.map(innerMenuItem => ( ))} @@ -225,7 +225,7 @@ function DesktopNav({ menuItems }: { menuItems: Array }) { function DesktopNavItem({ menuItem }: { menuItem: MenuItem }) { return ( - + }) {
{menuItems.map(menuItem => isMenuGroup(menuItem) ? !!menuItem.items.length && ( -
+
{menuItem.items.map(innerMenuItem => ( ))} @@ -302,7 +304,7 @@ function MobileNavItem({ menuItem, onClick }: { menuItem: MenuItem; onClick: () to={menuItem.to} onClick={linkOnClick} target={menuItem.target} - className="flex flex-row px-3 py-2 text-base font-medium rounded-md text-gray-400 hover:text-white" + className="flex flex-row px-3 py-2 text-base font-medium rounded-md text-muted-foreground dark:text-gray-400 hover:text-foreground dark:hover:text-white" activeProps={menuItem.to ? activeLinkProps : undefined} > {menuItem.icon} diff --git a/src/components/RadioButtonGroup.tsx b/src/components/RadioButtonGroup.tsx index 418e3d5cd..d330d514c 100644 --- a/src/components/RadioButtonGroup.tsx +++ b/src/components/RadioButtonGroup.tsx @@ -57,7 +57,7 @@ export function RadioButtonGroup<
-
+
{table.getHeaderGroups().map((headerGroup) => ( @@ -69,7 +69,7 @@ export function SimpleBrowseDataTable({ ))} - + {table.getRowModel().rows?.length ? (table.getRowModel().rows.map((row) => ( +
+
{table.getHeaderGroups().map((headerGroup) => ( @@ -104,7 +104,7 @@ export function TableView({ headerGroups={table.getHeaderGroups()} /> )} - + {table.getRowModel().rows?.length ? (table.getRowModel().rows.map((row) => ( diff --git a/src/features/instance/databases/index.tsx b/src/features/instance/databases/index.tsx index 16c040a2b..d153fbbda 100644 --- a/src/features/instance/databases/index.tsx +++ b/src/features/instance/databases/index.tsx @@ -43,10 +43,10 @@ export function Databases() { return (
-
+
-
+
{params.databaseName && params.tableName && ( -
+
{table.getHeaderGroups().map((headerGroup) => ( @@ -39,7 +39,7 @@ export function LogsDataTable({ ))} - + {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( diff --git a/src/features/instance/log/index.tsx b/src/features/instance/log/index.tsx index 01f7d0cde..da8b714d2 100644 --- a/src/features/instance/log/index.tsx +++ b/src/features/instance/log/index.tsx @@ -78,7 +78,10 @@ const columns: ColumnDef[] = [ {node.split('.')[0]}... - + {node} @@ -177,7 +180,7 @@ export function Logs() { const onRefreshClick = useRefreshClick(refetchReadLogQueryOptions); return ( -
+
-
+

Level:

diff --git a/src/features/instance/modals/BrowseSettingsModal.tsx b/src/features/instance/modals/BrowseSettingsModal.tsx index e9ebee989..872412384 100644 --- a/src/features/instance/modals/BrowseSettingsModal.tsx +++ b/src/features/instance/modals/BrowseSettingsModal.tsx @@ -16,7 +16,7 @@ export function BrowseSettingsModal() { {/* NOTE - Is this okay to do for the aria describedby? */} - + Edit Row diff --git a/src/features/instance/modals/UploadCSVModal.tsx b/src/features/instance/modals/UploadCSVModal.tsx index 98f44b3fd..6ea266316 100644 --- a/src/features/instance/modals/UploadCSVModal.tsx +++ b/src/features/instance/modals/UploadCSVModal.tsx @@ -14,7 +14,7 @@ export function UploadCSVModal() { {/* NOTE - Is this okay to do for the aria describedby? */} - + Upload A CSV File diff --git a/src/features/instance/status/analytics/StatusTabs.tsx b/src/features/instance/status/analytics/StatusTabs.tsx index c1c637607..0764ed04a 100644 --- a/src/features/instance/status/analytics/StatusTabs.tsx +++ b/src/features/instance/status/analytics/StatusTabs.tsx @@ -1,8 +1,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import type { InstanceClientIdConfig, InstanceTypeConfig } from '@/config/instanceClientConfig.ts'; +import { useSystemTheme } from '@/hooks/useSystemTheme'; import { useNavigate, useSearch } from '@tanstack/react-router'; -import { useTheme } from 'next-themes'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { AnalyticsOnboardingHint } from './components/AnalyticsOnboardingHint.tsx'; import { TimeRangePicker } from './components/TimeRangePicker.tsx'; @@ -80,8 +80,7 @@ function StatusTabsInner({ instanceParams, isLocalStudio }: Props) { // clicks the refresh button. const [tick, setTick] = useState(0); - const { resolvedTheme } = useTheme(); - const theme = resolvedTheme === 'dark' ? 'dark' : 'light'; + const theme = useSystemTheme(); const updatePreset = useCallback((id: TimePresetId) => { void navigate({ to: '.', search: { tab, range: id, refresh: refreshMs } }); diff --git a/src/features/instance/status/analytics/__tests__/StatusTabs.url-sync.test.tsx b/src/features/instance/status/analytics/__tests__/StatusTabs.url-sync.test.tsx index 90cdda581..8df84585a 100644 --- a/src/features/instance/status/analytics/__tests__/StatusTabs.url-sync.test.tsx +++ b/src/features/instance/status/analytics/__tests__/StatusTabs.url-sync.test.tsx @@ -1,7 +1,6 @@ // @vitest-environment happy-dom import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { ThemeProvider } from 'next-themes'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // Mock useAnalyticsRecords so we don't need a real Harper. Each tab still @@ -40,25 +39,24 @@ vi.mock('@tanstack/react-router', () => ({ useNavigate: () => navigateMock, })); -// next-themes uses matchMedia which happy-dom doesn't fully implement. +// useSystemTheme uses matchMedia which happy-dom doesn't fully implement. beforeEach(() => { currentSearch = {}; navigateMock.mockClear(); - if (!window.matchMedia) { - Object.defineProperty(window, 'matchMedia', { - writable: true, - value: vi.fn().mockImplementation((query: string) => ({ - matches: false, - media: query, - onchange: null, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - addListener: vi.fn(), - removeListener: vi.fn(), - dispatchEvent: vi.fn(), - })), - }); - } + Object.defineProperty(window, 'matchMedia', { + writable: true, + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); }); import { StatusTabs } from '../StatusTabs.tsx'; @@ -75,9 +73,7 @@ function mount() { }); return render( - - - + , ); } diff --git a/src/features/instance/status/analytics/lib/theme.ts b/src/features/instance/status/analytics/lib/theme.ts index 802c63f01..524eebf66 100644 --- a/src/features/instance/status/analytics/lib/theme.ts +++ b/src/features/instance/status/analytics/lib/theme.ts @@ -1,31 +1,5 @@ export type Theme = 'light' | 'dark'; -const STORAGE_KEY = 'analytics-viz-theme'; - -export function getStoredTheme(): Theme { - // Safari private mode + sandboxed iframes throw on localStorage access. - // Fall back to the OS preference rather than blowing up the whole tab. - try { - const stored = localStorage.getItem(STORAGE_KEY); - if (stored === 'light' || stored === 'dark') { return stored; } - } catch { - // fall through to media-query fallback - } - return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; -} - -export function setStoredTheme(theme: Theme): void { - try { - localStorage.setItem(STORAGE_KEY, theme); - } catch { - // best-effort; silently drop in restricted-storage environments - } -} - -export function applyTheme(theme: Theme): void { - document.documentElement.classList.toggle('dark', theme === 'dark'); -} - /** Reads the studio chart-surface CSS tokens defined in src/index.css. * All charts render inside a `Card`, so axis/grid/tooltip colors resolve * against `--card`, not the brand-purple `--background`. The hex defaults diff --git a/src/features/layouts/Dashboard.tsx b/src/features/layouts/Dashboard.tsx index a8c05d8cc..a67e6a11b 100644 --- a/src/features/layouts/Dashboard.tsx +++ b/src/features/layouts/Dashboard.tsx @@ -22,7 +22,7 @@ export function Dashboard() { return ( <> -
+
diff --git a/src/features/organization/billing/index.tsx b/src/features/organization/billing/index.tsx index 22ae02ef8..f97c0b893 100644 --- a/src/features/organization/billing/index.tsx +++ b/src/features/organization/billing/index.tsx @@ -5,7 +5,7 @@ import { Link, Outlet, useParams } from '@tanstack/react-router'; import { CreditCardIcon, ReceiptIcon, ReceiptTextIcon } from 'lucide-react'; const sharedClasses = 'flex items-center p-2 rounded-lg group'; -const inactiveProps = { className: 'text-white hover:bg-gray-700' }; +const inactiveProps = { className: 'text-foreground hover:bg-accent dark:text-white dark:hover:bg-gray-700' }; const activeProps = { className: 'text-black bg-white pointer-events-none cursor-default' }; export function OrgBillingIndex() { @@ -25,11 +25,11 @@ export function OrgBillingIndex() {
-
+
-
+
@@ -44,7 +44,7 @@ function DesktopBillingNavBar() {
-

Billing

+

Billing

    diff --git a/src/features/organizations/components/NewOrgForm.tsx b/src/features/organizations/components/NewOrgForm.tsx index 417dfcf72..4f7102b07 100644 --- a/src/features/organizations/components/NewOrgForm.tsx +++ b/src/features/organizations/components/NewOrgForm.tsx @@ -76,7 +76,7 @@ export function NewOrgForm() { id="org-add-form" name="org-add-form" onSubmit={form.handleSubmit(submitForm)} - className="grid gap-6 text-white max-w-xl" + className="grid gap-6 text-foreground max-w-xl" > + {organizationId} {remove && ( - + diff --git a/src/features/profile/index.tsx b/src/features/profile/index.tsx index 08641997c..555647ceb 100644 --- a/src/features/profile/index.tsx +++ b/src/features/profile/index.tsx @@ -83,7 +83,7 @@ export function ProfileIndex() { @@ -136,7 +136,7 @@ export function ProfileIndex() { ( + () => window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light', + ); + useEffect(() => { + const mq = window.matchMedia('(prefers-color-scheme: dark)'); + const handler = (e: MediaQueryListEvent) => setTheme(e.matches ? 'dark' : 'light'); + mq.addEventListener('change', handler); + return () => mq.removeEventListener('change', handler); + }, []); + return theme; +} diff --git a/src/index.css b/src/index.css index d799ea744..70c2e6199 100644 --- a/src/index.css +++ b/src/index.css @@ -12,7 +12,7 @@ @plugin "tailwindcss-animate"; -@custom-variant dark (&:where(.dark, .dark *)); +@custom-variant dark (@media (prefers-color-scheme: dark)); :root { --white: hsl(0, 0%, 99.61%); /* #fefefe */ @@ -54,26 +54,24 @@ --purple-gradient: 90deg, var(--purple-500) 100%, var(--purple-500) 100%; --purple-dark-to-light-gradient: 45deg, var(--purple-200) 0%, var(--purple-100) 100%; - --background: var(--purple-500); /* Dark Purple: #312556 */ - --foreground: hsl(0 0% 3.9%); - --card: var(--grey-700); - --card-foreground: var(--white); - /* --popover: hsl(0 0% 100%); */ - --popover: var(--black); - /* --popover-foreground: hsl(0 0% 3.9%); */ - --popover-foreground: var(--white); - --primary: var(--purple-300); /* Purple #403b8a */ + --background: hsl(0 0% 97%); /* Light grey page background */ + --foreground: hsl(0 0% 9%); /* Near-black text */ + --card: hsl(0 0% 100%); /* White cards */ + --card-foreground: hsl(0 0% 9%); + --popover: hsl(0 0% 100%); /* White popovers */ + --popover-foreground: hsl(0 0% 9%); + --primary: var(--purple-300); /* Purple #403b8a */ --primary-foreground: hsl(0 0% 98%); - --secondary: hsl(0 0% 96.1%); + --secondary: hsl(0 0% 92%); --secondary-foreground: hsl(0 0% 9%); - --muted: hsl(0 0% 96.1%); - --muted-foreground: hsl(0 0% 63.9%); /* #D9D9D9 */ - --accent: hsl(0 0% 14.9%); + --muted: hsl(0 0% 93%); + --muted-foreground: hsl(0 0% 45%); + --accent: hsl(0 0% 93%); --accent-foreground: hsl(0 0% 9%); --destructive: hsl(0 84.2% 60.2%); --destructive-foreground: hsl(0 0% 98%); - --border: hsl(0 0% 89.8%); - --input: var(--color-grey-700); + --border: hsl(0 0% 85%); + --input: hsl(0 0% 85%); --ring: var(--purple); --chart-1: hsl(12 76% 61%); --chart-2: hsl(173 58% 39%); @@ -82,65 +80,69 @@ --chart-5: hsl(27 87% 67%); --radius: 0.6rem; --blue-pink-gradient: 44.5deg, var(--color-blue-400) 39.04%, var(--color-pink) 98.58%; - --sidebar: hsl(0 0% 98%); + --sidebar: hsl(0 0% 96%); --sidebar-foreground: hsl(240 5.3% 26.1%); - --sidebar-primary: hsl(240 5.9% 10%); + --sidebar-primary: var(--purple-300); --sidebar-primary-foreground: hsl(0 0% 98%); - --sidebar-accent: hsl(240 4.8% 95.9%); + --sidebar-accent: hsl(0 0% 91%); --sidebar-accent-foreground: hsl(240 5.9% 10%); - --sidebar-border: hsl(220 13% 91%); + --sidebar-border: hsl(0 0% 85%); --sidebar-ring: hsl(217.2 91.2% 59.8%); /* Chart surface tokens — analytics charts render on a neutral surface * (the Card), not the brand-purple background. Axis/grid/tooltip tokens * resolve against `--card` so WCAG AA contrast is preserved. */ --chart-bg: var(--card); - --chart-grid: hsl(0 0% 89.8%); + --chart-grid: hsl(0 0% 87%); --chart-axis: hsl(0 0% 40%); --chart-tooltip-bg: var(--popover); --chart-tooltip-fg: var(--popover-foreground); } -.dark { - --background: var(--color-black-dark); /* Dark Black: #111111 */ - --foreground: hsl(0 0% 98%); - --card: var(--color-grey-700); - --card-foreground: var(--color-white); - --popover: hsl(0 0% 3.9%); - --popover-foreground: hsl(0 0% 98%); - --primary: var(--purple-300); - --primary-foreground: var(--color-white); - --secondary: hsl(0 0% 14.9%); - --secondary-foreground: hsl(0 0% 98%); - --muted: hsl(0 0% 14.9%); - --muted-foreground: hsl(0 0% 63.9%); - --accent: hsl(0 0% 14.9%); - --accent-foreground: hsl(0 0% 98%); - --destructive: hsl(0 84.2% 60.2%); - --destructive-foreground: hsl(0 0% 98%); - --border: hsl(0 0% 14.9%); - --input: var(--color-grey-700); - --ring: var(--purple); - --chart-1: hsl(220 70% 50%); - --chart-2: hsl(160 60% 45%); - --chart-3: hsl(30 80% 55%); - --chart-4: hsl(280 65% 60%); - --chart-5: hsl(340 75% 55%); - --sidebar: hsl(240 5.9% 10%); - --sidebar-foreground: hsl(240 4.8% 95.9%); - --sidebar-primary: hsl(224.3 76.3% 48%); - --sidebar-primary-foreground: hsl(0 0% 100%); - --sidebar-accent: hsl(240 3.7% 15.9%); - --sidebar-accent-foreground: hsl(240 4.8% 95.9%); - --sidebar-border: hsl(240 3.7% 15.9%); - --sidebar-ring: hsl(217.2 91.2% 59.8%); - - --chart-bg: var(--card); - --chart-grid: hsl(0 0% 22%); - --chart-axis: hsl(0 0% 70%); - --chart-tooltip-bg: var(--popover); - --chart-tooltip-fg: var(--popover-foreground); +@media (prefers-color-scheme: dark) { + :root { + --background: var(--color-black-dark); /* Dark Black: #111111 */ + --foreground: hsl(0 0% 98%); + --card: var(--color-grey-700); + --card-foreground: var(--color-white); + --popover: hsl(0 0% 3.9%); + --popover-foreground: hsl(0 0% 98%); + --primary: var(--purple-300); + --primary-foreground: var(--color-white); + --secondary: hsl(0 0% 14.9%); + --secondary-foreground: hsl(0 0% 98%); + --muted: hsl(0 0% 14.9%); + --muted-foreground: hsl(0 0% 63.9%); + --accent: hsl(0 0% 14.9%); + --accent-foreground: hsl(0 0% 98%); + --destructive: hsl(0 84.2% 60.2%); + --destructive-foreground: hsl(0 0% 98%); + --border: hsl(0 0% 14.9%); + --input: var(--color-grey-700); + --ring: var(--purple); + --chart-1: hsl(220 70% 50%); + --chart-2: hsl(160 60% 45%); + --chart-3: hsl(30 80% 55%); + --chart-4: hsl(280 65% 60%); + --chart-5: hsl(340 75% 55%); + --sidebar: hsl(240 5.9% 10%); + --sidebar-foreground: hsl(240 4.8% 95.9%); + --sidebar-primary: hsl(224.3 76.3% 48%); + --sidebar-primary-foreground: hsl(0 0% 100%); + --sidebar-accent: hsl(240 3.7% 15.9%); + --sidebar-accent-foreground: hsl(240 4.8% 95.9%); + --sidebar-border: hsl(240 3.7% 15.9%); + --sidebar-ring: hsl(217.2 91.2% 59.8%); + + --chart-bg: var(--card); + --chart-grid: hsl(0 0% 22%); + --chart-axis: hsl(0 0% 70%); + --chart-tooltip-bg: var(--popover); + --chart-tooltip-fg: var(--popover-foreground); + + --shadow-deep: 0 2px 8px 0 rgba(0,0,0,0.4), 0 1px 2px 0 rgba(0,0,0,0.3); + } } @theme inline { @@ -225,7 +227,7 @@ --color-success: var(--green); --color-warning: var(--yellow); - --shadow-deep: 1px 2px 4px 0px var(--black-dark); + --shadow-deep: 0 2px 12px 0 rgba(0,0,0,0.06), 0 1px 3px 0 rgba(0,0,0,0.04); --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); @@ -255,8 +257,7 @@ } body { - /* @apply font-ubuntu bg-linear-(--purple-dark-to-light-gradient) dark:bg-linear-(--black-dark-gradient) text-foreground bg-no-repeat h-full; */ - @apply h-full bg-black font-ubuntu text-white; + @apply h-full bg-background font-ubuntu text-foreground; } } @@ -314,3 +315,9 @@ background: url('/fabric-signup-background.webp') center center; background-size: cover; } + +@media (prefers-color-scheme: light) { + .fabricSignupTextContainer { + filter: brightness(1.3) saturate(0.75); + } +} diff --git a/src/integrations/stripe/StripeContext.tsx b/src/integrations/stripe/StripeContext.tsx index f4c6140e3..4750eff56 100644 --- a/src/integrations/stripe/StripeContext.tsx +++ b/src/integrations/stripe/StripeContext.tsx @@ -1,5 +1,6 @@ import { Loading } from '@/components/Loading'; import { getOrganizationQueryOptions } from '@/features/organization/queries/getOrganizationQuery'; +import { useSystemTheme } from '@/hooks/useSystemTheme'; import { stripePromise } from '@/integrations/stripe/stripePromise'; import { useGetStripeClientSecret } from '@/integrations/stripe/useGetStripeClientSecret'; import { StripeContext } from '@/integrations/stripe/useStripeOptions'; @@ -16,14 +17,15 @@ export function StripeWrapper({ children }: { children: ReactNode }) { enabled: !!organization, existingStripeId: organization?.stripeId, }); + const systemTheme = useSystemTheme(); const options = useMemo(() => { return { clientSecret: clientSecret || '', appearance: { - theme: 'night' as const, + theme: systemTheme === 'dark' ? 'night' as const : 'stripe' as const, }, }; - }, [clientSecret]); + }, [clientSecret, systemTheme]); if (!import.meta.env.VITE_PUBLIC_STRIPE_KEY) { console.error('No VITE_PUBLIC_STRIPE_KEY is configured for this environment.');