update some ui changes

This commit is contained in:
2026-09-28 23:53:28 +05:30
parent 0fee985fd1
commit 76839eede2
16 changed files with 430 additions and 60 deletions

View File

@@ -18,8 +18,8 @@ export default function manifest(): MetadataRoute.Manifest {
'Footfall, campaign and LYT redemption analytics for Loyaly merchants.',
start_url: '/dashboard',
display: 'standalone',
background_color: '#000000',
theme_color: '#000000',
background_color: '#F7F7F8',
theme_color: '#F7F7F8',
icons: [
{src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any'},
{src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any'},

View File

@@ -5,9 +5,26 @@ import {useResource} from '@/shared/hooks/useResource';
import {useScope} from '@/shared/hooks/useScope';
import type {Resource} from '@/shared/hooks/useResource';
import type {Sale} from '@/features/commerce/types/sale';
import {useWorkspace} from '@/shared/providers/WorkspaceProvider';
import {useSampleFallback} from '@/shared/mocks/withSample';
import {buildSampleSales} from '@/shared/mocks/sampleData';
/** The workspace's real store ids a sample bill can belong to. */
function useSampleSites(storeId: string): string[] {
const {stores} = useWorkspace();
return storeId === 'all' ? stores.filter((s) => s.id !== 'all').map((s) => s.id) : [storeId];
}
export function useSales(): Resource<Sale[]> {
return useResource(saleRepository.list(useScope()));
const scope = useScope();
const {storeId, range} = scope;
const sites = useSampleSites(storeId);
// SAMPLE while no sale is recorded for the scope — see useSampleFallback.
return useSampleFallback(
useResource(saleRepository.list(scope)),
(now) => buildSampleSales(range, storeId, sites, now),
[range, storeId, sites],
);
}
/**
@@ -18,7 +35,13 @@ export function useSales(): Resource<Sale[]> {
*/
export function useTodaySales(enabled = true): Resource<Sale[]> {
const {storeId} = useScope();
return useResource(
const sites = useSampleSites(storeId);
const real = useResource(
enabled ? saleRepository.list({storeId, range: 'today'}) : null,
);
return useSampleFallback(
real,
(now) => buildSampleSales('today', storeId, sites, now),
['today', storeId, sites],
);
}

View File

@@ -1,6 +1,8 @@
'use client';
import {Grid} from '@astryxdesign/core/Grid';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {SampleTag} from '@/shared/mocks/SampleTag';
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
import {MetricCard} from '@/shared/components/patterns/MetricCard';
import {SkeletonMetricGrid} from '@/shared/components/patterns/LoadingState';
@@ -53,21 +55,28 @@ export function KpiRow({resource}: {resource: Resource<Kpi[]>}) {
loading={<SkeletonMetricGrid columns={columns} />}
>
{(kpis) => (
<Grid columns={columns} gap={4}>
{kpis.map((k) => (
<MetricCard
key={k.id}
label={k.label}
value={k.value}
format={k.unit === 'inr' ? formatInrCompact : formatCompact}
icon={KPI_ICON[k.id]}
deltaPct={k.deltaPct}
isRiseGood={k.isRiseGood}
trend={k.trend}
accent={KPI_ACCENT[k.id]}
/>
))}
</Grid>
<VStack gap={2}>
{resource.isSample ? (
<HStack hAlign="end">
<SampleTag show />
</HStack>
) : null}
<Grid columns={columns} gap={4}>
{kpis.map((k) => (
<MetricCard
key={k.id}
label={k.label}
value={k.value}
format={k.unit === 'inr' ? formatInrCompact : formatCompact}
icon={KPI_ICON[k.id]}
deltaPct={k.deltaPct}
isRiseGood={k.isRiseGood}
trend={k.trend}
accent={KPI_ACCENT[k.id]}
/>
))}
</Grid>
</VStack>
)}
</AsyncBoundary>
);

View File

@@ -66,6 +66,7 @@ export function useDashboardKpis(
// The server's clock, from whichever response landed — every relative time
// on this page measures against it rather than the device.
const meta = footfall.meta ?? conversion.meta;
const isSample = footfall.isSample || conversion.isSample;
if (footfall.status === 'error' || conversion.status === 'error') {
return {
@@ -97,6 +98,7 @@ export function useDashboardKpis(
refetch,
isRefreshing,
meta,
isSample,
} as Resource<Kpi[]>;
}, [footfall, conversion]);
}

View File

@@ -9,6 +9,9 @@ import type {
FootfallReport,
} from '@/features/dashboard/types/reports';
import type {VisitsPage} from '@/features/dashboard/types/visits';
import {reportHasSignal} from '@/shared/hooks/resourceUtils';
import {useSampleFallback} from '@/shared/mocks/withSample';
import {buildSampleConversion, buildSampleFootfall} from '@/shared/mocks/sampleData';
/**
* One hook per resource, scoped by the workspace's {site, range}.
@@ -20,13 +23,29 @@ import type {VisitsPage} from '@/features/dashboard/types/visits';
export function useFootfallReport(
opts: {bucket?: string; compare?: boolean} = {},
): Resource<FootfallReport> {
return useResource(reportRepository.footfall(useScope(), opts));
const scope = useScope();
const {storeId, range} = scope;
// SAMPLE while the report has no visitors — see useSampleFallback.
return useSampleFallback(
useResource(reportRepository.footfall(scope, opts)),
(now) => buildSampleFootfall(range, storeId, now, opts.bucket),
[range, storeId, opts.bucket],
(d) => reportHasSignal(d, 'visitors'),
);
}
export function useConversionReport(
opts: {bucket?: string; compare?: boolean} = {},
): Resource<ConversionReport> {
return useResource(reportRepository.conversion(useScope(), opts));
const scope = useScope();
const {storeId, range} = scope;
// SAMPLE while the report has no purchases — see useSampleFallback.
return useSampleFallback(
useResource(reportRepository.conversion(scope, opts)),
(now) => buildSampleConversion(range, storeId, now, opts.bucket),
[range, storeId, opts.bucket],
(d) => reportHasSignal(d, 'purchases') || reportHasSignal(d, 'revenue'),
);
}
export function useRecentVisits(limit = 6): Resource<VisitsPage> {

View File

@@ -34,7 +34,9 @@ export function ActivitiesPanel() {
const [recording, setRecording] = useState(false);
const canRecord =
activities.status === 'success' && activities.data.some((a) => a.isActive);
activities.status === 'success' &&
!activities.isSample &&
activities.data.some((a) => a.isActive);
const columns: TableColumn<ActivityRow>[] = [
{

View File

@@ -9,18 +9,48 @@ import type {
Campaign,
Journey,
} from '@/features/engagement/types/engagement';
import {useSampleFallback} from '@/shared/mocks/withSample';
import {
buildSampleActivityRows,
buildSampleCampaignRows,
buildSampleJourney,
} from '@/shared/mocks/sampleData';
// Each falls back to SAMPLE while the endpoint has nothing — see
// useSampleFallback. The sample carries `attribution: 'estimated'`, so the
// AttributionNote still shows.
export function useActivities(): Resource<ActivityRow[]> {
return useResource(engagementRepository.activities(useScope()));
const scope = useScope();
const {storeId, range} = scope;
return useSampleFallback(
useResource(engagementRepository.activities(scope)),
(now) => buildSampleActivityRows(range, storeId, now),
[range, storeId],
);
}
export function useCampaigns(): Resource<Campaign[]> {
return useResource(engagementRepository.campaigns(useScope()));
const scope = useScope();
const {storeId, range} = scope;
return useSampleFallback(
useResource(engagementRepository.campaigns(scope)),
(now) => buildSampleCampaignRows(range, storeId, now),
[range, storeId],
);
}
/** Empty only when there are no stages at all; a stage of zero people is data. */
export function useJourney(): Resource<Journey> {
return useResource(engagementRepository.journey(useScope()), {
isEmpty: (j) => j.stages.length === 0,
});
const scope = useScope();
const {storeId, range} = scope;
return useSampleFallback(
useResource(engagementRepository.journey(scope), {
isEmpty: (j) => j.stages.length === 0,
}),
(now) => buildSampleJourney(range, storeId, now),
[range, storeId],
// JourneyPanel treats a journey where nobody reached any stage as empty.
(j) => j.stages.some((s) => s.customers > 0),
);
}

View File

@@ -92,8 +92,8 @@ export function PreferencesForm() {
value={themeMode}
onChange={(v) => setThemeMode(v as ThemeMode)}
options={[
{label: 'Dark Mode (Monochrome Premium)', value: 'dark'},
{label: 'Light Mode', value: 'light'},
{label: 'Dark Mode (Monochrome Premium)', value: 'dark'},
{label: 'System OS Preference', value: 'system'},
]}
/>

View File

@@ -46,6 +46,7 @@ export function PanelCard<T>({
subtitle={subtitle}
actions={actions}
level={headingLevel}
isSample={resource.isSample}
/>
<AsyncBoundary resource={resource} loading={loading} empty={empty}>
{children}

View File

@@ -22,6 +22,7 @@ export function SectionHeader({
subtitle,
actions,
level = 2,
isSample: isSampleData = false,
}: {
title: string;
/**
@@ -33,9 +34,12 @@ export function SectionHeader({
subtitle?: React.ReactNode;
actions?: React.ReactNode;
level?: 2 | 3 | 4 | 5;
/** The panel's resource is a sample standing in for an empty endpoint. */
isSample?: boolean;
}) {
// Inside a <SampleScope>, every panel says so — see shared/mocks.
const isSample = useInSampleScope();
const inScope = useInSampleScope();
const isSample = inScope || isSampleData;
return (
<HStack hAlign="between" vAlign="start" gap={3}>
<VStack gap={0.5}>
@@ -52,7 +56,11 @@ export function SectionHeader({
<HStack gap={1} vAlign="center">
<SampleTag
show={isSample}
description="The platform does not report this yet — showing an example"
description={
inScope
? 'The platform does not report this yet — showing an example'
: 'No data for this period yet — showing an example'
}
/>
{actions}
</HStack>

View File

@@ -1,16 +1,24 @@
'use client';
import {HStack} from '@astryxdesign/core/Layout';
import {Icon} from '@astryxdesign/core/Icon';
import {Text} from '@astryxdesign/core/Text';
import {ICONS} from '@/shared/utils/icons';
import {formatDelta} from '@/shared/utils/format';
/**
* Modern illuminated period-over-period change pill badge.
* Period-over-period change: an arrow and a signed percentage, in the
* semantic colour for whether the move is good.
*
* Inline, not a pill. The pill's padding and border pushed "+10.3%" past an
* 88px table column and it wrapped onto two lines; the colour and arrow carry
* the meaning on their own. `nowrap` keeps the figure on one line wherever
* it sits.
*/
export function MetricDelta({
value,
isRiseGood = true,
size: _size = 'sm',
size = 'sm',
}: {
value: number;
isRiseGood?: boolean;
@@ -18,30 +26,20 @@ export function MetricDelta({
}) {
const isFlat = Math.abs(value) < 0.05;
const isGood = value > 0 === isRiseGood;
if (isFlat) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-zinc-500/10 dark:bg-zinc-400/10 text-zinc-600 dark:text-zinc-400 border border-zinc-500/15">
<span className="w-1 h-1 rounded-full bg-zinc-400" />
<span>0.0%</span>
</span>
);
}
const badgeClass = isGood
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20'
: 'bg-rose-500/10 text-rose-600 dark:text-rose-400 border-rose-500/20';
const tone = isFlat ? 'text-secondary' : isGood ? 'text-success' : 'text-error';
return (
<span
className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-semibold tracking-tight border transition-all ${badgeClass}`}
>
<Icon
icon={value > 0 ? ICONS.up : ICONS.down}
size="xsm"
color={isGood ? 'success' : 'error'}
/>
<span>{formatDelta(value)}</span>
</span>
<HStack gap={0.5} vAlign="center" className={`inline-flex ${tone}`}>
{isFlat ? null : (
<Icon
icon={value > 0 ? ICONS.up : ICONS.down}
size="xsm"
color={isGood ? 'success' : 'error'}
/>
)}
<Text size={size} weight="semibold" color="inherit" textWrap="nowrap" hasTabularNumbers>
{isFlat ? '0.0%' : formatDelta(value)}
</Text>
</HStack>
);
}

View File

@@ -39,6 +39,7 @@ export function joinResources<A, B>(
},
isRefreshing: a.isRefreshing || b.isRefreshing,
meta: a.meta ?? b.meta,
isSample: a.isSample || b.isSample,
};
if (a.status === 'error') return {...common, status: 'error', data: undefined, error: a.error};
if (b.status === 'error') return {...common, status: 'error', data: undefined, error: b.error};

View File

@@ -35,6 +35,12 @@ export type Resource<T> = AsyncState<T> & {
* wrong countdown to any user whose device clock is off.
*/
meta?: ApiMeta;
/**
* TEMPORARY — set by useSampleFallback() when the real endpoint had nothing
* and an example is on screen instead. PanelCard shows the "Sample data"
* token from it. Goes with shared/mocks.
*/
isSample?: boolean;
};
/** What the fetch resolved to, tagged with the request it belongs to. */

View File

@@ -57,6 +57,17 @@ import type {LoginAuditItem} from '@/features/settings/components/SecurityManage
import type {
RangeKey,
} from '@/shared/types/api';
import type {
ConversionReport,
FootfallReport,
ReportBucket,
} from '@/features/dashboard/types/reports';
import type {Sale} from '@/features/commerce/types/sale';
import type {
ActivityRow,
Campaign,
Journey,
} from '@/features/engagement/types/engagement';
// ===========================================================================
// Seeded randomness
@@ -1635,3 +1646,217 @@ export const SAMPLE_AI_EXPORT_ROWS = [
/** The assistant landing's store-pulse strip — no live telemetry feed yet. */
export const SAMPLE_AI_PULSE = {footfallDeltaPct: 18.4, syncPct: 99.8};
// ===========================================================================
// Fallbacks for REAL endpoints that came back empty
//
// Read through useSampleFallback() (withSample.ts): the real response always
// wins when it carries anything, and the panel wears the "Sample data" token
// whenever one of these is on screen. Shapes match the BFF payloads exactly,
// so the panels render them with no special case.
// ===========================================================================
/** Daily buckets from the shared timeseries, so every sample chart agrees. */
function sampleDayBuckets(range: RangeKey, storeId: string, endMs: number): ReportBucket[] {
return buildTimeseries(range, storeId, endMs).map((p, i) => {
const rng = createRng('report-bucket', storeId, range, p.t, i);
const newVisitors = Math.round(p.visitors * rng.float(0.28, 0.42));
return {
label: p.t,
visitors: p.visitors,
newVisitors,
returningVisitors: p.visitors - newVisitors,
purchases: p.purchases,
revenue: p.revenue,
conversion: p.conversion,
};
});
}
/** Days rolled into weeks, labelled by each week's first day. */
function rollWeeks(days: ReportBucket[]): ReportBucket[] {
const weeks: ReportBucket[] = [];
days.forEach((d, i) => {
if (i % 7 === 0) weeks.push({...d});
else {
const w = weeks[weeks.length - 1];
w.visitors += d.visitors;
w.newVisitors += d.newVisitors;
w.returningVisitors += d.returningVisitors;
w.purchases += d.purchases;
w.revenue += d.revenue;
}
});
return weeks.map((w) => ({
...w,
conversion: w.visitors ? Number(((w.purchases / w.visitors) * 100).toFixed(1)) : 0,
}));
}
function sampleBuckets(range: RangeKey, storeId: string, endMs: number, bucket?: string) {
const days = sampleDayBuckets(range, storeId, endMs);
return bucket === 'week' ? rollWeeks(days) : days;
}
const previousEnd = (range: RangeKey, endMs: number) => endMs - RANGE_DAYS[range] * 86400000;
export function buildSampleFootfall(
range: RangeKey,
storeId: string,
endMs: number,
bucket?: string,
): FootfallReport {
const buckets = sampleBuckets(range, storeId, endMs, bucket);
const visits = buckets.reduce((a, b) => a + b.visitors, 0);
const newVisitors = buckets.reduce((a, b) => a + b.newVisitors, 0);
// Unique people, not bucket-visitors: regulars come more than once a window.
const total = Math.round(visits * 0.72);
const previousVisits = totalVisitors(range, storeId, previousEnd(range, endMs));
return {
total,
newVisitors: Math.min(newVisitors, total),
returningVisitors: Math.max(0, total - newVisitors),
visits,
buckets,
timezone: 'Asia/Kolkata',
previousTotal: Math.round(previousVisits * 0.72),
};
}
export function buildSampleConversion(
range: RangeKey,
storeId: string,
endMs: number,
bucket?: string,
): ConversionReport {
const buckets = sampleBuckets(range, storeId, endMs, bucket);
const purchases = buckets.reduce((a, b) => a + b.purchases, 0);
const revenue = buckets.reduce((a, b) => a + b.revenue, 0);
const visitors = buckets.reduce((a, b) => a + b.visitors, 0);
const prev = buildTimeseries(range, storeId, previousEnd(range, endMs));
return {
purchases,
revenue,
conversionPct: visitors ? Number(((purchases / (visitors * 0.72)) * 100).toFixed(1)) : null,
basketSize: purchases ? Math.round(revenue / purchases) : null,
buckets,
timezone: 'Asia/Kolkata',
previousPurchases: prev.reduce((a, p) => a + p.purchases, 0),
previousRevenue: prev.reduce((a, p) => a + p.revenue, 0),
};
}
const SAMPLE_CUSTOMERS = ['Priya S.', 'Arjun M.', 'Kavya R.', 'Rahul N.', 'Sneha P.', 'Vikram T.', 'Ananya K.', 'Rohan D.'];
const SAMPLE_CASHIERS = ['Meena', 'Karthik', 'Divya', 'Suresh'];
/** Keeps a 90-day all-stores list from building tens of thousands of rows. */
const MAX_SAMPLE_SALES = 1500;
const IST_OFFSET_MS = 5.5 * 3600000;
/**
* Bills for the sale list. `siteIds` are the workspace's REAL store ids, so
* Top performing outlets names the merchant's own shops.
*/
export function buildSampleSales(
range: RangeKey,
storeId: string,
siteIds: string[],
endMs: number,
): Sale[] {
const days = RANGE_DAYS[range];
const sites = siteIds.length ? siteIds : [storeId];
const perDayCap = Math.max(4, Math.floor(MAX_SAMPLE_SALES / days));
const sales: Sale[] = [];
buildTimeseries(range, storeId, endMs).forEach((p, dayIdx) => {
const rng = createRng('sale', storeId, range, p.t);
const count = Math.min(perDayCap, Math.max(3, Math.round(p.purchases / 6)));
const dayStart = Date.parse(`${p.t}T00:00:00Z`) - IST_OFFSET_MS;
for (let i = 0; i < count; i++) {
// Shop hours 10:00–21:00 IST, with an evening lean.
const hour = Math.min(21, Math.floor(10 + 11 * Math.sqrt(rng.next())));
const at = dayStart + hour * 3600000 + rng.int(0, 59) * 60000;
const purchasedLines = rng.int(1, 4);
const enquiryLines = rng.next() < 0.3 ? rng.int(1, 2) : 0;
const totalPaise = purchasedLines * rng.int(249, 1299) * 100;
const known = rng.next() < 0.6;
sales.push({
id: `sample-${p.t}-${i}`,
invoiceNo: `INV-${p.t.replaceAll('-', '').slice(2)}-${String(dayIdx * 100 + i).padStart(4, '0')}`,
siteId: sites[rng.int(0, sites.length - 1)],
customerRef: known ? `cust-${rng.int(100, 999)}` : null,
customerLabel: known ? rng.pick(SAMPLE_CUSTOMERS) : null,
staffName: rng.pick(SAMPLE_CASHIERS),
totalPaise,
currency: 'INR',
status: rng.next() < 0.02 ? 'void' : 'completed',
at: new Date(at).toISOString(),
purchasedLines,
enquiryLines,
lines: [],
});
}
});
return sales.sort((a, b) => b.at.localeCompare(a.at));
}
/** The engagement catalogue, from the same activity metrics the intelligence panels use. */
export function buildSampleActivityRows(
range: RangeKey,
storeId: string,
endMs: number,
): ActivityRow[] {
return buildActivityMetrics(range, storeId, endMs)
.filter((m) => m.status !== 'draft')
.map((m) => ({
id: m.id,
kind: m.id,
name: m.label,
isActive: m.status === 'live',
events: m.count,
participants: m.impact.customers,
anonymous: Math.round(m.impact.customers * 0.12),
repeatVisitors: m.impact.repeatVisits,
purchases: m.impact.purchases,
attributedRevenue: m.impact.attributedRevenueInr,
currency: 'INR',
attribution: 'estimated' as const,
}));
}
export function buildSampleCampaignRows(
range: RangeKey,
storeId: string,
endMs: number,
): Campaign[] {
const metrics = buildActivityMetrics(range, storeId, endMs);
return buildCampaigns(range, storeId, endMs).map((c, i) => {
const m = metrics.find((x) => x.id === c.activityId);
const startDaysAgo = [24, 40, 18, 60][i % 4];
const isActive = c.status === 'live';
return {
id: c.id,
name: c.name,
startsAt: new Date(endMs - startDaysAgo * 86400000).toISOString(),
endsAt: isActive ? null : new Date(endMs - 6 * 86400000).toISOString(),
isActive,
activities: [c.activityId],
events: c.steps[0].value,
participants: m?.impact.customers ?? c.steps[0].value,
purchasers: c.steps[2].value,
attributedRevenue: c.attributedRevenueInr,
attribution: c.attribution,
};
});
}
export function buildSampleJourney(range: RangeKey, storeId: string, endMs: number): Journey {
return {
stages: buildJourney(range, storeId, endMs).map((s) => ({
id: s.id,
label: s.label,
customers: s.value,
})),
attribution: 'estimated',
};
}

View File

@@ -6,8 +6,11 @@
* useSampleResource() no endpoint exists at all; the sample IS the data, and
* the panel sits inside a <SampleScope>.
*
* A section that HAS a real endpoint never falls back to sample data: an
* empty response renders the panel's empty state.
* useSampleFallback() a real endpoint exists but returned nothing (or
* failed); an example stands in and the panel wears
* the "Sample data" token via `resource.isSample`.
* Off with NEXT_PUBLIC_SAMPLE_FALLBACK=off, which
* restores the plain empty states.
*
* Every sample value comes from shared/mocks/sampleData.ts — the single file
* to edit, and eventually delete.
@@ -65,3 +68,46 @@ export function useSampleResource<T>(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
}
/** Set NEXT_PUBLIC_SAMPLE_FALLBACK=off to show real empty states instead. */
export const SAMPLE_FALLBACK = process.env.NEXT_PUBLIC_SAMPLE_FALLBACK !== 'off';
const hasRows = (d: unknown) => !Array.isArray(d) || d.length > 0;
/**
* The real resource while it carries anything; otherwise a sample of the
* same shape, marked `isSample`. Loading stays loading — a sample never
* flashes in ahead of a real response.
*
* `hasData` decides what "carries anything" means: a report whose buckets
* are all zero arrives as success but is still nothing to draw.
*/
export function useSampleFallback<T>(
real: Resource<T>,
build: (nowMs: number) => T,
deps: readonly unknown[],
hasData: (data: T) => boolean = hasRows,
): Resource<T> {
const needed =
SAMPLE_FALLBACK &&
(real.status === 'empty' ||
real.status === 'error' ||
(real.status === 'success' && !hasData(real.data)));
const key = JSON.stringify(deps);
const {refetch} = real;
const sample = useMemo<Resource<T> | null>(() => {
if (!needed) return null;
const nowMs = sampleNowMs();
return {
status: 'success',
data: build(nowMs),
error: undefined,
refetch,
isRefreshing: false,
meta: {generatedAt: new Date(nowMs).toISOString()},
isSample: true,
} as Resource<T>;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [needed, key, refetch]);
return sample ?? real;
}

View File

@@ -20,10 +20,10 @@ export const THEME_MODE_COOKIE = 'loyaly.theme-mode';
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.
* Light is what an unknown visitor gets — not 'system', so the console looks
* the same on every machine until somebody picks otherwise in Preferences.
*/
export const DEFAULT_THEME_MODE: ThemeMode = 'dark';
export const DEFAULT_THEME_MODE: ThemeMode = 'light';
export function parseThemeMode(value: string | undefined | null): ThemeMode {
return value === 'light' || value === 'dark' || value === 'system'