update dark and ligth theme

This commit is contained in:
2026-08-10 12:35:18 +05:30
parent f397e94418
commit 6cc3c9a0b7
28 changed files with 319 additions and 105 deletions

View File

@@ -80,7 +80,7 @@ export default function DashboardPage() {
return (
<VStack gap={5}>
{/* 1 + 2 — Sticky header: greeting, title, description, and filter controls. */}
<div className="sticky -top-5 z-40 -mx-5 px-5 pt-5 pb-4 bg-[#0F0F10] border-b border-[#2F2F2F] shadow-sm">
<div className="sticky -top-5 z-40 -mx-5 px-5 pt-5 pb-4 bg-surface border-b border-border shadow-sm">
<PageHeader
eyebrow={greetingFor(kpis.meta?.generatedAt)}
title="Dashboard"

View File

@@ -22,13 +22,31 @@
/*
* Paint the canvas before React mounts. AppShell sets these itself once it
* renders, but this guarantees the very first frame — and any non-shell route
* such as an error boundary — is already black rather than white.
* such as an error boundary — already has the right ground colour.
*
* These rules are UNLAYERED, so they beat everything in loyaly.css. That is
* why `color-scheme` has to be resolved from `data-theme` here rather than
* pinned: a flat `color-scheme: dark` silently won over the theme's own
* html[data-theme="light"] rule, and every `light-dark()` token in the
* stylesheet kept resolving to its dark branch no matter what mode said.
*
* data-theme is written by the root layout (from the cookie) and re-synced by
* Astryx's <Theme>. It is absent in system mode — that is what leaves
* `light dark` in force so the OS preference decides.
*/
html {
background-color: var(--color-background-body);
color-scheme: light dark;
}
html[data-theme='dark'] {
color-scheme: dark;
}
html[data-theme='light'] {
color-scheme: light;
}
@layer components {
/*
* Global Data Table Inset Contract: 24px left lead-in, 32px right inset.

View File

@@ -1,7 +1,14 @@
import type {Metadata, Viewport} from 'next';
import {cookies} from 'next/headers';
import {Sora, Inter} from 'next/font/google';
import './globals.css';
import {getServerSession} from '@/features/auth/services/serverSession';
import {
htmlThemeAttr,
parseThemeMode,
THEME_MODE_COOKIE,
type ThemeMode,
} from '@/shared/theme/themeMode';
import {Providers} from './providers';
const sora = Sora({
@@ -39,10 +46,34 @@ export const metadata: Metadata = {
},
};
export const viewport: Viewport = {
colorScheme: 'dark',
themeColor: '#000000',
};
/**
* Resolved per request rather than exported as a constant, so the UA chrome
* (address bar, native scrollbars, the paint before CSS arrives) matches the
* mode this visitor actually chose. A hardcoded `colorScheme: 'dark'` here
* was one of the reasons picking Light did nothing visible.
*/
export async function generateViewport(): Promise<Viewport> {
const mode = await readThemeMode();
if (mode === 'light') {
return {colorScheme: 'light', themeColor: '#F7F7F8'};
}
if (mode === 'dark') {
return {colorScheme: 'dark', themeColor: '#000000'};
}
return {
colorScheme: 'light dark',
themeColor: [
{media: '(prefers-color-scheme: light)', color: '#F7F7F8'},
{media: '(prefers-color-scheme: dark)', color: '#000000'},
],
};
}
async function readThemeMode(): Promise<ThemeMode> {
const store = await cookies();
return parseThemeMode(store.get(THEME_MODE_COOKIE)?.value);
}
export default async function RootLayout({
children,
@@ -56,19 +87,27 @@ export default async function RootLayout({
* already answered in this very request.
*/
const session = await getServerSession();
const themeMode = await readThemeMode();
return (
// The theme attributes are server-rendered so the @scope rules in
// loyaly.css match on the very first byte — no flash from stock defaults.
//
// data-theme now comes from the cookie, and is ABSENT for 'system' so
// color-scheme stays `light dark` and light-dark() follows the OS. Astryx's
// <Theme> re-syncs this same attribute after hydration; it writes exactly
// what htmlThemeAttr() writes here, so the two never disagree.
<html
lang="en"
data-theme="dark"
data-theme={htmlThemeAttr(themeMode)}
data-astryx-theme="loyaly"
className={`${sora.variable} ${inter.variable}`}
suppressHydrationWarning
>
<body>
<Providers initialSession={session}>{children}</Providers>
<Providers initialSession={session} initialThemeMode={themeMode}>
{children}
</Providers>
</body>
</html>
);

View File

@@ -7,6 +7,11 @@ import {LinkProvider} from '@astryxdesign/core/Link';
import {ToastViewport} from '@astryxdesign/core/Toast';
import {loyalyTheme} from '@/theme';
import {WorkspaceProvider} from '@/shared/providers/WorkspaceProvider';
import {
ThemeModeProvider,
useThemeMode,
} from '@/shared/providers/ThemeModeProvider';
import type {ThemeMode} from '@/shared/theme/themeMode';
import {SessionProvider} from '@/features/auth/providers/SessionProvider';
import type {AuthSession} from '@/features/auth/types/auth';
import {LoyalyAiProvider} from '@/features/loyaly-ai/providers/LoyalyAiProvider';
@@ -27,13 +32,36 @@ import {LoyalyAiProvider} from '@/features/loyaly-ai/providers/LoyalyAiProvider'
export function Providers({
children,
initialSession,
initialThemeMode,
}: {
children: React.ReactNode;
/** Resolved from the signed cookie in the root layout — see SessionProvider. */
initialSession: AuthSession | null;
/** Resolved from the theme-mode cookie in the root layout. */
initialThemeMode: ThemeMode;
}) {
return (
<Theme theme={loyalyTheme} mode="dark">
// Outside <Theme>, because <Theme> takes the mode as a prop — a context
// rendered inside it could never reach it.
<ThemeModeProvider initialMode={initialThemeMode}>
<ThemedProviders initialSession={initialSession}>
{children}
</ThemedProviders>
</ThemeModeProvider>
);
}
function ThemedProviders({
children,
initialSession,
}: {
children: React.ReactNode;
initialSession: AuthSession | null;
}) {
const {mode} = useThemeMode();
return (
<Theme theme={loyalyTheme} mode={mode}>
<LinkProvider component={NextLink}>
{/* One line, and every motion.* in the app honours prefers-reduced-motion. */}
<MotionConfig reducedMotion="user">

View File

@@ -24,7 +24,7 @@ interface MockUserRecord extends AuthUser {
const USERS: MockUserRecord[] = [
{
id: 'usr_owner_01',
email: 'aravind@nealre.in',
email: 'aravind@loyaly.in',
name: 'Aravind',
role: 'owner',
organisation: 'Loyaly Retail Pvt Ltd',
@@ -39,12 +39,20 @@ const USERS: MockUserRecord[] = [
password: 'admin123',
},
{
id: 'usr_mgr_01',
email: 'priya.sharma@loyaly.ai',
name: 'Priya Sharma',
role: 'manager',
id: 'usr_owner_03',
email: 'fazul@loyaly.in',
name: 'Fazul Ilahi',
role: 'owner',
organisation: 'Loyaly Retail Pvt Ltd',
password: 'indiranagar@01',
password: 'admin123',
},
{
id: 'usr_mgr_01',
email: 'suriya@loyaly.in',
name: 'Suriya',
role: 'owner',
organisation: 'Loyaly Retail Pvt Ltd',
password: 'admin123',
},
{
id: 'usr_analyst_01',

View File

@@ -18,8 +18,8 @@ export function CommerceRecentOrders() {
<div className="rounded-xl border border-border bg-card p-4 space-y-3 shadow-sm">
<div className="flex items-center justify-between border-b border-border pb-3">
<div>
<Heading level={4} className="text-base font-bold text-white flex items-center gap-2">
<Icon icon={ICONS.purchases} size="sm" className="text-zinc-400" />
<Heading level={4} className="text-base font-bold text-primary flex items-center gap-2">
<Icon icon={ICONS.purchases} size="sm" className="text-secondary" />
Recent Orders
</Heading>
<Text size="sm" color="secondary">
@@ -29,21 +29,21 @@ export function CommerceRecentOrders() {
<Badge variant="success" label="Live Feed" />
</div>
<div className="divide-y divide-zinc-800/80">
<div className="divide-y divide-border">
{RECENT_ORDERS.map((ord) => (
<div key={ord.id} className="py-2.5 first:pt-0 last:pb-0 flex items-center justify-between text-sm">
<div>
<div className="font-bold text-white flex items-center gap-2">
<div className="font-bold text-primary flex items-center gap-2">
{ord.code}
<span className="text-xs text-zinc-400 font-normal">• {ord.customer}</span>
<span className="text-xs text-secondary font-normal">• {ord.customer}</span>
</div>
<div className="text-xs text-zinc-400 font-medium">
<div className="text-xs text-secondary font-medium">
{ord.store} ({ord.method})
</div>
</div>
<div className="text-right">
<div className="font-bold text-white tabular-nums">{ord.amount}</div>
<div className="text-xs text-zinc-400">{ord.time}</div>
<div className="font-bold text-primary tabular-nums">{ord.amount}</div>
<div className="text-xs text-secondary">{ord.time}</div>
</div>
</div>
))}

View File

@@ -13,7 +13,7 @@ export function CommerceRevenueSummary() {
return (
<div className="rounded-xl border border-border bg-card p-4 space-y-4 shadow-sm">
<div className="border-b border-border pb-3">
<Heading level={4} className="text-base font-bold text-white">
<Heading level={4} className="text-base font-bold text-primary">
Revenue Summary
</Heading>
<Text size="sm" color="secondary">

View File

@@ -34,7 +34,7 @@ export function CommerceSalesInsights() {
<div className="rounded-xl border border-border bg-card p-4 space-y-3 shadow-sm">
<div className="flex items-center justify-between border-b border-border pb-3">
<div>
<Heading level={4} className="text-base font-bold text-white flex items-center gap-2">
<Heading level={4} className="text-base font-bold text-primary flex items-center gap-2">
<Icon icon={ICONS.ai} size="sm" className="text-amber-400" />
Sales Insights
</Heading>
@@ -55,7 +55,7 @@ export function CommerceSalesInsights() {
}`}
>
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-white">{item.title}</span>
<span className="text-xs font-bold text-primary">{item.title}</span>
<Badge
variant={item.intent === 'warning' ? 'warning' : 'success'}
label={item.badge}

View File

@@ -21,7 +21,7 @@ export function CommerceWorkspace() {
return (
<VStack gap={5}>
{/* Sticky Header matching Dashboard visual language */}
<div className="sticky -top-5 z-40 -mx-5 px-5 pt-5 pb-4 bg-[#0F0F10] border-b border-[#2F2F2F] shadow-sm">
<div className="sticky -top-5 z-40 -mx-5 px-5 pt-5 pb-4 bg-surface border-b border-border shadow-sm">
<PageHeader
eyebrow="Sales & Revenue Analytics"
title="Commerce"

View File

@@ -9,7 +9,7 @@ import {BUSINESS_ALERTS} from '../../services/commerceService';
export function BusinessAlerts() {
return (
<div className="space-y-3">
<Heading level={4} className="text-base font-bold text-white">
<Heading level={4} className="text-base font-bold text-primary">
Real-Time Business & Operational Alerts
</Heading>
@@ -48,7 +48,7 @@ export function BusinessAlerts() {
}
/>
<div>
<span className="text-sm font-bold text-white block">{alert.title}</span>
<span className="text-sm font-bold text-primary block">{alert.title}</span>
<Text size="sm" color="secondary" className="mt-0.5">
{alert.description}
</Text>
@@ -60,10 +60,10 @@ export function BusinessAlerts() {
/>
</div>
<div className="pt-2 border-t border-white/5 flex justify-end">
<div className="pt-2 border-t border-border flex justify-end">
<button
type="button"
className="text-xs font-bold text-white hover:text-amber-300 transition-colors flex items-center gap-1"
className="text-xs font-bold text-primary hover:text-amber-300 transition-colors flex items-center gap-1"
>
{alert.actionText} →
</button>

View File

@@ -31,7 +31,7 @@ export function OperationalFlowcharts() {
return (
<div className="rounded-xl border border-border bg-card p-5 space-y-6 shadow-sm overflow-hidden">
<div>
<Heading level={4} className="text-base font-bold text-white">
<Heading level={4} className="text-base font-bold text-primary">
Operational Flow Diagrams
</Heading>
<Text size="sm" color="secondary">
@@ -48,12 +48,12 @@ export function OperationalFlowcharts() {
<div className="flex items-center gap-2 min-w-[640px]">
{ORDER_FLOW.map((node, i) => (
<div key={node.step} className="flex items-center gap-2 flex-1">
<div className="flex-1 rounded-lg border border-zinc-700 bg-zinc-900/90 p-2.5 text-center shadow-sm">
<div className="text-xs font-bold text-white">{node.step}</div>
<div className="text-[10px] text-zinc-400 font-medium mt-0.5">{node.detail}</div>
<div className="flex-1 rounded-lg border border-border-strong bg-card p-2.5 text-center shadow-sm">
<div className="text-xs font-bold text-primary">{node.step}</div>
<div className="text-[10px] text-secondary font-medium mt-0.5">{node.detail}</div>
</div>
{i < ORDER_FLOW.length - 1 ? (
<span className="text-zinc-500 font-bold text-sm shrink-0">→</span>
<span className="text-disabled font-bold text-sm shrink-0">→</span>
) : null}
</div>
))}
@@ -72,7 +72,7 @@ export function OperationalFlowcharts() {
<div key={node.step} className="flex items-center gap-2 flex-1">
<div className="flex-1 rounded-lg border border-emerald-950/60 bg-emerald-950/20 p-2.5 text-center shadow-sm">
<div className="text-xs font-bold text-emerald-300">{node.step}</div>
<div className="text-[10px] text-zinc-400 font-medium mt-0.5">{node.detail}</div>
<div className="text-[10px] text-secondary font-medium mt-0.5">{node.detail}</div>
</div>
{i < REVENUE_FLOW.length - 1 ? (
<span className="text-emerald-600 font-bold text-sm shrink-0">→</span>
@@ -94,7 +94,7 @@ export function OperationalFlowcharts() {
<div key={node.step} className="flex items-center gap-2 flex-1">
<div className="flex-1 rounded-lg border border-indigo-950/60 bg-indigo-950/20 p-2.5 text-center shadow-sm">
<div className="text-xs font-bold text-indigo-300">{node.step}</div>
<div className="text-[10px] text-zinc-400 font-medium mt-0.5">{node.detail}</div>
<div className="text-[10px] text-secondary font-medium mt-0.5">{node.detail}</div>
</div>
{i < INVENTORY_FLOW.length - 1 ? (
<span className="text-indigo-600 font-bold text-sm shrink-0">→</span>

View File

@@ -15,14 +15,14 @@ export function OrdersChart() {
<div className="rounded-xl border border-border bg-card p-4 space-y-3 overflow-hidden shadow-sm">
<div className="flex items-center justify-between">
<div>
<Heading level={4} className="text-base font-bold text-white">
<Heading level={4} className="text-base font-bold text-primary">
Orders Volume Trend
</Heading>
<Text size="sm" color="secondary">
Completed order count grouped by hourly buckets
</Text>
</div>
<span className="text-xs font-bold text-white bg-zinc-800 px-2.5 py-1 rounded-full border border-zinc-700">
<span className="text-xs font-bold text-primary bg-muted px-2.5 py-1 rounded-full border border-border-strong">
{totalOrders.toLocaleString()} Orders
</span>
</div>

View File

@@ -19,7 +19,7 @@ export function PaymentBreakdown() {
<div className="rounded-xl border border-border bg-card p-5 space-y-4 shadow-sm overflow-hidden">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<Heading level={4} className="text-base font-bold text-white">
<Heading level={4} className="text-base font-bold text-primary">
Payment Methods Breakdown
</Heading>
<Text size="sm" color="secondary">
@@ -34,8 +34,8 @@ export function PaymentBreakdown() {
onClick={() => setSelectedMethod(null)}
className={`px-2.5 py-1 rounded-md text-xs font-bold transition-all ${
selectedMethod === null
? 'bg-zinc-700 text-white shadow-sm'
: 'bg-zinc-900/80 text-zinc-400 hover:text-white border border-zinc-800'
? 'bg-muted text-primary shadow-sm'
: 'bg-card text-secondary hover:text-primary border border-border'
}`}
>
All
@@ -49,8 +49,8 @@ export function PaymentBreakdown() {
}
className={`px-2.5 py-1 rounded-md text-xs font-bold transition-all flex items-center gap-1.5 ${
selectedMethod === item.method
? 'text-white border shadow-sm'
: 'bg-zinc-900/80 text-zinc-400 hover:text-white border border-zinc-800'
? 'text-primary border shadow-sm'
: 'bg-card text-secondary hover:text-primary border border-border'
}`}
style={{
borderColor: selectedMethod === item.method ? item.color : undefined,
@@ -68,7 +68,7 @@ export function PaymentBreakdown() {
</div>
{/* Visual Ring / Stack Bar */}
<div className="h-4 w-full rounded-full bg-zinc-800 overflow-hidden flex shadow-inner p-0.5">
<div className="h-4 w-full rounded-full bg-muted overflow-hidden flex shadow-inner p-0.5">
{paymentData.map((item) => {
const isSelected = selectedMethod === null || selectedMethod === item.method;
return (
@@ -101,10 +101,10 @@ export function PaymentBreakdown() {
}
className={`flex items-center justify-between p-3 rounded-xl cursor-pointer transition-all border ${
isSelected
? 'bg-zinc-800/90 shadow-md ring-1'
? 'bg-muted shadow-md ring-1'
: selectedMethod !== null
? 'bg-zinc-900/40 border-zinc-800/60 opacity-60 hover:opacity-100'
: 'bg-zinc-900/60 border-zinc-800 hover:border-zinc-700'
? 'bg-card border-border opacity-60 hover:opacity-100'
: 'bg-card border-border hover:border-border-strong'
}`}
style={{
borderColor: isSelected ? item.color : undefined,
@@ -116,17 +116,17 @@ export function PaymentBreakdown() {
style={{backgroundColor: item.color}}
/>
<div>
<span className="text-xs font-bold text-white block truncate">
<span className="text-xs font-bold text-primary block truncate">
{item.method}
</span>
<span className="text-[10px] font-semibold text-zinc-400">
<span className="text-[10px] font-semibold text-secondary">
{item.percentage}% share
</span>
</div>
</div>
<div className="text-right shrink-0">
<div className="text-xs font-bold text-white tabular-nums">
<div className="text-xs font-bold text-primary tabular-nums">
{item.amount}
</div>
{isSelected ? (
@@ -140,21 +140,21 @@ export function PaymentBreakdown() {
{/* Selected Channel Deep-Dive Banner */}
{activeItem ? (
<div className="p-3 rounded-lg bg-zinc-900/90 border border-zinc-700 flex items-center justify-between text-xs animate-fadeIn">
<div className="p-3 rounded-lg bg-card border border-border-strong flex items-center justify-between text-xs animate-fadeIn">
<div className="flex items-center gap-2">
<span
className="size-2.5 rounded-full"
style={{backgroundColor: activeItem.color}}
/>
<span className="font-bold text-white">{activeItem.method} Channel Details:</span>
<span className="text-zinc-300">
<span className="font-bold text-primary">{activeItem.method} Channel Details:</span>
<span className="text-secondary">
{activeItem.amount} collected across selected scope terminals.
</span>
</div>
<button
type="button"
onClick={() => setSelectedMethod(null)}
className="text-zinc-400 hover:text-white font-bold text-xs"
className="text-secondary hover:text-primary font-bold text-xs"
>
Clear Filter ✕
</button>

View File

@@ -13,7 +13,7 @@ export function RevenueChart() {
<div className="rounded-xl border border-border bg-card p-4 space-y-3 overflow-hidden shadow-sm">
<div className="flex items-center justify-between">
<div>
<Heading level={4} className="text-base font-bold text-white">
<Heading level={4} className="text-base font-bold text-primary">
Revenue Trend
</Heading>
<Text size="sm" color="secondary">

View File

@@ -9,7 +9,7 @@ export function StorePerformanceChart() {
return (
<div className="rounded-xl border border-border bg-card p-5 space-y-4 shadow-sm overflow-hidden">
<div>
<Heading level={4} className="text-base font-bold text-white">
<Heading level={4} className="text-base font-bold text-primary">
Top Performing Outlets
</Heading>
<Text size="sm" color="secondary">
@@ -23,12 +23,14 @@ export function StorePerformanceChart() {
return (
<div key={store.store} className="space-y-1">
<div className="flex justify-between items-center text-xs font-semibold">
<span className="text-white font-bold">{store.store}</span>
<span className="text-primary font-bold">{store.store}</span>
<span className="text-emerald-400 font-bold tabular-nums">
₹{(store.Revenue / 100000).toFixed(1)}L
</span>
</div>
<div className="h-6 w-full rounded-md bg-zinc-900/80 p-0.5 border border-zinc-800">
{/* bg-muted, not bg-card: the track sits INSIDE a card, so it
needs the next surface step up to read as a track at all. */}
<div className="h-6 w-full rounded-md bg-muted p-0.5 border border-border">
<div
className="h-full rounded bg-gradient-to-r from-emerald-600 to-emerald-400 transition-all duration-500"
style={{width: `${widthPercent}%`}}

View File

@@ -12,7 +12,7 @@ export function TopProductsTable() {
return (
<div className="rounded-xl border border-border bg-card overflow-hidden shadow-sm space-y-3">
<div className="p-4 border-b border-border bg-card">
<Heading level={4} className="text-base font-bold text-white">
<Heading level={4} className="text-base font-bold text-primary">
Top Selling Products Leaderboard
</Heading>
<Text size="sm" color="secondary">
@@ -23,7 +23,7 @@ export function TopProductsTable() {
<div className="overflow-x-auto w-full">
<table className="w-full text-left text-sm border-collapse">
<thead>
<tr className="border-b border-zinc-800 bg-zinc-900/60 text-white font-bold">
<tr className="border-b border-border bg-card text-primary font-bold">
<th className="px-4 py-3 text-left">Product Name</th>
<th className="px-4 py-3 text-left">Category</th>
<th className="px-4 py-3 text-right">Units Sold</th>
@@ -32,13 +32,13 @@ export function TopProductsTable() {
<th className="px-4 py-3 text-center">Stock Status</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800/60">
<tbody className="divide-y divide-border">
{products.map((prod) => (
<tr key={prod.id} className="hover:bg-white/[0.02] transition-colors">
<td className="px-4 py-3.5 text-white font-semibold">{prod.name}</td>
<td className="px-4 py-3.5 text-zinc-300 font-medium">{prod.category}</td>
<td className="px-4 py-3.5 text-right text-white font-bold tabular-nums">{prod.unitsSold.toLocaleString()}</td>
<td className="px-4 py-3.5 text-right text-white font-bold tabular-nums">{prod.revenue}</td>
<tr key={prod.id} className="hover:bg-overlay-hover transition-colors">
<td className="px-4 py-3.5 text-primary font-semibold">{prod.name}</td>
<td className="px-4 py-3.5 text-secondary font-medium">{prod.category}</td>
<td className="px-4 py-3.5 text-right text-primary font-bold tabular-nums">{prod.unitsSold.toLocaleString()}</td>
<td className="px-4 py-3.5 text-right text-primary font-bold tabular-nums">{prod.revenue}</td>
<td className="px-4 py-3.5 text-right font-bold tabular-nums">
<span className={prod.growthDirection === 'up' ? 'text-emerald-400' : 'text-rose-400'}>
{prod.growth}

View File

@@ -29,8 +29,8 @@ export function CommerceFilterToolbar({
onClick={() => onFilterChange({...filters, dateRange: r})}
className={`px-2.5 py-1 rounded-md transition-colors capitalize ${
filters.dateRange === r
? 'bg-zinc-800 text-white font-bold'
: 'text-zinc-400 hover:text-white'
? 'bg-muted text-primary font-bold'
: 'text-secondary hover:text-primary'
}`}
>
{r === 'today' ? 'Today' : r === '7d' ? '7 Days' : r === '30d' ? '30 Days' : '90 Days'}
@@ -42,7 +42,7 @@ export function CommerceFilterToolbar({
<select
value={filters.storeId}
onChange={(e) => onFilterChange({...filters, storeId: e.target.value})}
className="h-8 rounded-lg border border-border bg-card px-2.5 text-xs font-semibold text-white outline-none focus:border-zinc-500"
className="h-8 rounded-lg border border-border bg-card px-2.5 text-xs font-semibold text-primary outline-none focus:border-border-strong"
>
<option value="all">All Active Stores</option>
<option value="indiranagar">Indiranagar Flagship</option>

View File

@@ -33,15 +33,15 @@ import {ICONS} from '@/shared/utils/icons';
const MAX_HEIGHT_PX = 132;
const PILL = [
'rounded-[28px] bg-zinc-900/90 border border-zinc-700/80 shadow-md',
'rounded-[28px] bg-card border border-border-strong shadow-md',
'transition-colors duration-150',
'focus-within:border-zinc-500 focus-within:ring-1 focus-within:ring-zinc-500',
'focus-within:border-border-strong focus-within:ring-1 focus-within:ring-border-strong',
'pl-4 pr-2 py-1.5',
].join(' ');
const FIELD = [
'flex-1 min-w-0 resize-none bg-transparent border-0 outline-none',
'text-sm font-medium text-white placeholder:text-zinc-400 placeholder:font-normal',
'text-sm font-medium text-primary placeholder:text-secondary placeholder:font-normal',
'py-2 leading-6 overflow-y-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden',
].join(' ');

View File

@@ -17,11 +17,11 @@ export function ChartBlock({charts}: {charts: ChartPartSpec[]}) {
className="rounded-xl border border-border bg-card p-4 shadow-sm space-y-3 overflow-hidden"
>
<div>
<Heading level={4} className="text-base font-bold text-white">
<Heading level={4} className="text-base font-bold text-primary">
{c.title}
</Heading>
{c.subtitle ? (
<p className="text-sm font-medium text-zinc-300 mt-0.5">{c.subtitle}</p>
<p className="text-sm font-medium text-secondary mt-0.5">{c.subtitle}</p>
) : null}
</div>

View File

@@ -57,13 +57,13 @@ export function InsightCardsBlock({insights}: {insights: ReportInsight[]}) {
<div className="flex flex-wrap items-start justify-between gap-x-2 gap-y-1.5">
<div className="flex items-start gap-2 min-w-0">
<span className="text-lg shrink-0">{meta.icon}</span>
<span className="text-base font-bold text-white whitespace-normal break-words min-w-0">
<span className="text-base font-bold text-primary whitespace-normal break-words min-w-0">
{insight.title}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
{insight.confidence ? (
<span className="text-sm font-semibold text-zinc-300 font-mono tabular-nums whitespace-nowrap">
<span className="text-sm font-semibold text-secondary font-mono tabular-nums whitespace-nowrap">
{insight.confidence}% confidence
</span>
) : null}
@@ -71,14 +71,14 @@ export function InsightCardsBlock({insights}: {insights: ReportInsight[]}) {
</div>
</div>
<p className="text-sm font-medium text-zinc-200 leading-relaxed whitespace-normal break-words">
<p className="text-sm font-medium text-primary leading-relaxed whitespace-normal break-words">
{insight.explanation}
</p>
{insight.recommendedAction ? (
<div className="pt-2 flex flex-wrap items-baseline gap-x-2 gap-y-1 text-sm font-semibold">
<span className="text-amber-300 font-bold shrink-0">Action:</span>
<span className="text-white whitespace-normal break-words min-w-0">
<span className="text-primary whitespace-normal break-words min-w-0">
{insight.recommendedAction}
</span>
</div>

View File

@@ -81,7 +81,7 @@ export function KpiGridBlock({kpis}: {kpis: ReportKpi[]}) {
so a long token can still break as a last resort but ordinary
prose never breaks mid-word.
*/}
<span className="text-xs font-semibold text-zinc-300 whitespace-normal break-words min-w-0 flex-1">
<span className="text-xs font-semibold text-secondary whitespace-normal break-words min-w-0 flex-1">
{kpi.title}
</span>
{kpi.iconName ? (
@@ -98,14 +98,14 @@ export function KpiGridBlock({kpis}: {kpis: ReportKpi[]}) {
width decides whether there is room for the larger setting,
which is the question `sm:` was answering with the window's.
*/}
<span className="text-xl @xs/report:text-2xl font-bold tracking-tight text-white tabular-nums whitespace-nowrap shrink-0">
<span className="text-xl @xs/report:text-2xl font-bold tracking-tight text-primary tabular-nums whitespace-nowrap shrink-0">
{kpi.value}
</span>
{kpi.trend ? (
<span
title={kpi.trend}
className={`text-xs font-bold truncate max-w-full min-w-0 ${
isUp ? 'text-emerald-400' : isDown ? 'text-rose-400' : 'text-zinc-300'
isUp ? 'text-emerald-400' : isDown ? 'text-rose-400' : 'text-secondary'
}`}
>
{kpi.trend}

View File

@@ -16,7 +16,7 @@ export function TableBlock({tables}: {tables: TablePartSpec[]}) {
>
{t.title ? (
<div className="p-4 border-b border-border bg-card">
<Heading level={4} className="text-base font-bold text-white whitespace-normal break-words">
<Heading level={4} className="text-base font-bold text-primary whitespace-normal break-words">
{t.title}
</Heading>
</div>
@@ -38,11 +38,11 @@ export function TableBlock({tables}: {tables: TablePartSpec[]}) {
className="w-full min-w-full table-auto text-left text-sm border-collapse"
>
<thead>
<tr className="border-b border-zinc-700 bg-zinc-800/80 text-white font-bold">
<tr className="border-b border-border-strong bg-muted text-primary font-bold">
{t.columns.map((col) => (
<th
key={col.key}
className={`px-3.5 py-3.5 whitespace-nowrap text-sm font-bold text-white ${
className={`px-3.5 py-3.5 whitespace-nowrap text-sm font-bold text-primary ${
col.align === 'end'
? 'text-right'
: col.align === 'center'
@@ -55,9 +55,9 @@ export function TableBlock({tables}: {tables: TablePartSpec[]}) {
))}
</tr>
</thead>
<tbody className="divide-y divide-zinc-800/80">
<tbody className="divide-y divide-border">
{t.rows.map((row, rIdx) => (
<tr key={rIdx} className="hover:bg-white/[0.04] transition-colors">
<tr key={rIdx} className="hover:bg-overlay-hover transition-colors">
{t.columns.map((col) => {
const val = row[col.key];
const valStr = String(val ?? '');
@@ -66,7 +66,7 @@ export function TableBlock({tables}: {tables: TablePartSpec[]}) {
return (
<td
key={col.key}
className={`px-3.5 py-3.5 text-white font-semibold align-middle text-sm ${
className={`px-3.5 py-3.5 text-primary font-semibold align-middle text-sm ${
col.align === 'end'
? 'text-right tabular-nums whitespace-nowrap'
: col.align === 'center'

View File

@@ -8,9 +8,11 @@ import {Button} from '@astryxdesign/core/Button';
import {Text} from '@astryxdesign/core/Text';
import {useToast} from '@astryxdesign/core/Toast';
import {StaticPanel} from '@/shared/components/patterns/PanelCard';
import {useThemeMode} from '@/shared/providers/ThemeModeProvider';
import type {ThemeMode} from '@/shared/theme/themeMode';
/** Theme mode is deliberately absent — it lives in ThemeModeProvider. */
export interface SystemPreferences {
theme: 'dark' | 'light' | 'system';
currency: 'INR' | 'USD' | 'EUR' | 'GBP';
language: 'en' | 'hi' | 'kn' | 'ta';
dateFormat: 'DD/MM/YYYY' | 'MM/DD/YYYY' | 'YYYY-MM-DD';
@@ -18,7 +20,6 @@ export interface SystemPreferences {
}
const DEFAULT_PREFS: SystemPreferences = {
theme: 'dark',
currency: 'INR',
language: 'en',
dateFormat: 'DD/MM/YYYY',
@@ -27,6 +28,14 @@ const DEFAULT_PREFS: SystemPreferences = {
export function PreferencesForm() {
const toast = useToast();
/**
* Theme is the one preference that does NOT go through the save cycle.
* It repaints the workspace the instant it changes and persists itself to
* the theme-mode cookie, so there is nothing left for Save to commit — and
* a mode you can see but have not "saved" would be the confusing state.
* The rest of the form is still mock local state.
*/
const {mode: themeMode, setMode: setThemeMode} = useThemeMode();
const [prefs, setPrefs] = useState<SystemPreferences>(DEFAULT_PREFS);
const [savedPrefs, setSavedPrefs] = useState<SystemPreferences>(DEFAULT_PREFS);
const [isSaving, setIsSaving] = useState(false);
@@ -80,8 +89,8 @@ export function PreferencesForm() {
<FormLayout direction="horizontal">
<Selector
label="Theme Interface Mode"
value={prefs.theme}
onChange={(v) => set('theme', v as SystemPreferences['theme'])}
value={themeMode}
onChange={(v) => setThemeMode(v as ThemeMode)}
options={[
{label: 'Dark Mode (Monochrome Premium)', value: 'dark'},
{label: 'Light Mode', value: 'light'},

View File

@@ -19,7 +19,10 @@ import {ChartTooltip} from './ChartTooltip';
*/
export const AXIS_TICK = {
fill: '#e4e4e7',
// recharts writes this straight onto the SVG fill attribute, where var()
// resolves natively — so ticks follow the mode instead of staying at the
// one light gray that only ever read on a dark canvas.
fill: CHART.axis,
fontSize: 12,
fontWeight: 500,
} as const;

View File

@@ -152,41 +152,41 @@ export function DownloadDropdown({
left: `${coords.left}px`,
zIndex: 99999,
}}
className="w-56 rounded-xl border border-zinc-700 bg-[#18181b] backdrop-blur-xl shadow-2xl z-[99999] py-1.5 divide-y divide-zinc-800 select-none"
className="w-56 rounded-xl border border-border-strong bg-popover backdrop-blur-xl shadow-2xl z-[99999] py-1.5 divide-y divide-border select-none"
>
<button
type="button"
className="w-full text-left px-4 py-3 hover:bg-white/10 flex items-center gap-3 transition-colors cursor-pointer"
className="w-full text-left px-4 py-3 hover:bg-overlay-pressed flex items-center gap-3 transition-colors cursor-pointer"
onClick={() => handleSelectFormat('pdf')}
>
<span className="text-xl">📄</span>
<div>
<div className="font-semibold text-sm text-white">PDF Document</div>
<div className="text-xs text-zinc-300">.pdf file format</div>
<div className="font-semibold text-sm text-primary">PDF Document</div>
<div className="text-xs text-secondary">.pdf file format</div>
</div>
</button>
<button
type="button"
className="w-full text-left px-4 py-3 hover:bg-white/10 flex items-center gap-3 transition-colors cursor-pointer"
className="w-full text-left px-4 py-3 hover:bg-overlay-pressed flex items-center gap-3 transition-colors cursor-pointer"
onClick={() => handleSelectFormat('excel')}
>
<span className="text-xl">📊</span>
<div>
<div className="font-semibold text-sm text-white">Excel Spreadsheet</div>
<div className="text-xs text-zinc-300">.xlsx file format</div>
<div className="font-semibold text-sm text-primary">Excel Spreadsheet</div>
<div className="text-xs text-secondary">.xlsx file format</div>
</div>
</button>
<button
type="button"
className="w-full text-left px-4 py-3 hover:bg-white/10 flex items-center gap-3 transition-colors cursor-pointer"
className="w-full text-left px-4 py-3 hover:bg-overlay-pressed flex items-center gap-3 transition-colors cursor-pointer"
onClick={() => handleSelectFormat('csv')}
>
<span className="text-xl">📑</span>
<div>
<div className="font-semibold text-sm text-white">CSV Data File</div>
<div className="text-xs text-zinc-300">.csv file format</div>
<div className="font-semibold text-sm text-primary">CSV Data File</div>
<div className="text-xs text-secondary">.csv file format</div>
</div>
</button>
</div>,

View File

@@ -65,7 +65,7 @@ import {useAccountActions} from './useAccountActions';
const LAYER_OFFSET = 'mb-2';
/** The chip. Rounded like a nav item, and lit while its menu is open. */
const CHIP = 'rounded-lg transition-colors duration-150 cursor-pointer hover:bg-white/[0.06] p-2';
const CHIP = 'rounded-lg transition-colors duration-150 cursor-pointer hover:bg-overlay-hover p-2';
/**
* The collapsed chip, sized to the rail rather than to IconButton's default.

View File

@@ -0,0 +1,62 @@
'use client';
import {createContext, useCallback, useContext, useMemo, useState} from 'react';
import {
THEME_MODE_COOKIE,
THEME_MODE_MAX_AGE,
type ThemeMode,
} from '@/shared/theme/themeMode';
/**
* Holds the workspace colour mode and persists it to a cookie.
*
* This sits ABOVE <Theme> in app/providers.tsx — it has to, because <Theme>
* takes the mode as a prop and cannot read a context rendered inside itself.
* Everything downstream of the mode change is CSS: <Theme> flips
* `color-scheme` on its wrapper and on <html>, and every colour token in
* loyaly.css is a `light-dark()` pair, so the whole surface repaints without
* a single component re-reading a colour.
*
* The cookie (not localStorage) is what lets the server render the chosen
* mode on the first byte — see @/shared/theme/themeMode.
*/
interface ThemeModeValue {
/** What the user picked, including the literal 'system'. */
mode: ThemeMode;
setMode: (mode: ThemeMode) => void;
}
const ThemeModeContext = createContext<ThemeModeValue | null>(null);
ThemeModeContext.displayName = 'ThemeModeContext';
export function ThemeModeProvider({
initialMode,
children,
}: {
/** Read from the cookie in the root layout, so SSR paints it correctly. */
initialMode: ThemeMode;
children: React.ReactNode;
}) {
const [mode, setModeState] = useState<ThemeMode>(initialMode);
const setMode = useCallback((next: ThemeMode) => {
setModeState(next);
// Written directly rather than through a server action: this is a display
// preference with no server-side consequence, and a round trip would put
// a network hop between the click and the repaint.
document.cookie = `${THEME_MODE_COOKIE}=${next}; path=/; max-age=${THEME_MODE_MAX_AGE}; samesite=lax`;
}, []);
const value = useMemo(() => ({mode, setMode}), [mode, setMode]);
return <ThemeModeContext value={value}>{children}</ThemeModeContext>;
}
export function useThemeMode(): ThemeModeValue {
const ctx = useContext(ThemeModeContext);
if (!ctx) {
throw new Error('useThemeMode must be used within <ThemeModeProvider>');
}
return ctx;
}

View File

@@ -0,0 +1,45 @@
/**
* The colour-mode contract, shared by the server layout and the client
* provider. Kept free of 'use client' and of any React import so the root
* layout (a server component) can read the cookie without pulling the
* provider module into the server graph.
*
* WHY A COOKIE AND NOT localStorage
* <Theme> renders a wrapper element whose color-scheme comes from the mode
* prop, so the mode has to be known during SSR — a localStorage read only
* happens after hydration, which would mean either a flash of the wrong
* scheme or a hydration mismatch on that wrapper. The cookie travels with
* the request, so the server renders the mode the user actually chose.
*/
export type ThemeMode = 'dark' | 'light' | 'system';
export const THEME_MODE_COOKIE = 'loyaly.theme-mode';
/** One year — a display preference should outlive the session cookie. */
export const THEME_MODE_MAX_AGE = 60 * 60 * 24 * 365;
/**
* Dark is the product's identity, so it is what an unknown visitor gets —
* not 'system'. Only an explicit choice moves off it.
*/
export const DEFAULT_THEME_MODE: ThemeMode = 'dark';
export function parseThemeMode(value: string | undefined | null): ThemeMode {
return value === 'light' || value === 'dark' || value === 'system'
? value
: DEFAULT_THEME_MODE;
}
/**
* The value for `<html data-theme>`.
*
* 'system' deliberately maps to undefined: the attribute's whole job is to
* pin `color-scheme`, and system mode wants `light dark` — i.e. no pin — so
* that `light-dark()` follows the OS. This mirrors what Astryx's <Theme>
* does to documentElement after hydration; matching it here is what keeps
* the server and client attribute identical.
*/
export function htmlThemeAttr(mode: ThemeMode): 'light' | 'dark' | undefined {
return mode === 'system' ? undefined : mode;
}