Dining settled back
−18%Three quiet months in a row.
A dark, calm personal-CFO console. Five chair-laws govern it, and a build-time lint enforces tokens-only.
The single source of truth. Every screen composes these tokens and primitives, and each primitive bakes in a chair-law: you should know what a thing is, and how it's doing, before you read a word.
The five chair-laws, quoted from the token file that enforces them.
Prominence = importance → elevation steps (bg → surface-1/2/3)
State before words → locked semantic + finance-role colors
Fact ≠ forecast → forecasts render softer (see Stat `kind`)
Actionable looks so → accent + focus ring + motion tokens
One element, one job → primitives compose these; nothing hardcodes
Motion communicates, never decorates.
One typeface, Albert Sans — the voice and the fact, as in Warden.
Mode
Dark-only by design.
Accent
Brand teal for text, lines and icons; filled controls use the deep accent-fill with light on-fill ink — a light-teal button with dark text fails readability on this canvas.
Type
Albert Sans for everything — Warden’s face, one family across both apps — with tabular figures so money columns align. The old serif roles (currency signs, toolbar titles, the wordmark, .emph) resolve to it too.
Glass
Chrome only (dock, tab bar, palette, sheet scrims); data surfaces stay solid. Intensity is a user preference (clear / tinted / solid).
Depth
Cards rise through three elevation levels instead of stacking borders; each carries a 1px top edge-light.
Motion
One curve — the hydraulic ease-out — on a duration ladder (fast · base · morph · entrance) with a per-sibling stagger.
$1,240,500+$4,280
Stay the course on the steady plan — the verdict wears the serif; the numbers never do.
lenses picked for this data — not a gallery
Every value below is read from the token file at build time; every swatch paints with the live token.
Dark-only. Semantic and finance roles are locked, so meaning is consistent everywhere.
Surfaces
--background#0c0d10page floor--surface#16181cdefault card--bg-basevar(--background)= #0c0d10--bg-sunken#08090bwells, progress tracks, chart plot areas--surface-1var(--surface)= #16181c--surface-2#1e2126raised: hover, nested, secondary cards--surface-3#262a30overlay: popover, command palette, tooltip--gradient-herolinear-gradient(180deg, #191c21 0%, #14161a 100%)Text
--foreground#eceae5primary text--muted#9a968esecondary text--text-primaryvar(--foreground)= #eceae5--text-secondaryvar(--muted)= #9a968e--text-tertiary#8a8880hints, captions, axis labels — 4.6:1+ on cards (WCAG AA)--text-on-accent#0b1413dark ink on teal / solid fills--text-on-fill#f0f6f5light ink on the deep fills (≈6.3:1)Borders
--border#24282dhairline--border-subtlevar(--border)= #24282d--border-strong#3a3f47hover / emphasisAccent
--accent#6fb3adbrand teal--accent-strong#87c4bfhover / active--accent-softrgba(111, 179, 173, 0.14)tinted fill--accent-fill#2f6a65solid button / active-pill fill--accent-fill-strong#397b75hover — smallest darkening of #3a7d77, same hue, that clears AA with --text-on-fill (4.50:1)--ring-colorvar(--accent)= #6fb3adSemantic & finance roles
--positive#5cc08cgain / money-in / on-track--caution#e0a161warning--danger#e0796bloss / off-track--positive-softrgba(92, 192, 140, 0.15)--caution-softrgba(224, 161, 97, 0.15)--danger-softrgba(224, 121, 107, 0.15)--infovar(--accent)= #6fb3ad--info-softvar(--accent-soft)= rgba(111, 179, 173, 0.14)--gainvar(--positive)= #5cc08c--lossvar(--danger)= #e0796b--money-invar(--positive)= #5cc08c--money-outvar(--text-secondary)= #9a968espending is normal, not alarmingCharts
--chart-money-in#6fae91sage — earned, at area scale--chart-money-out#c08a81clay — spent, at area scale (visible, never alarming)--chart-1#6fb3adteal — primary / actuals--chart-2#9b8fd4violet — second series / best-case--chart-3#d98b9erose--chart-4#7fa7d4blue--chart-5#8cc6a0sage--chart-6#d6b06agold--chart-7#d98a5bcopper — extends the categorical set (spending donut)--chart-8#b483c9orchid--chart-9#6cc2c9cyan--chart-10#8593a8slate--chart-grid#23272d--chart-axisvar(--text-tertiary)= #8a8880--chart-bandrgba(111, 179, 173, 0.12)forecast cone, outer--chart-band-strongrgba(111, 179, 173, 0.22)forecast cone, innerAlbert Sans, with tabular figures so money columns align. Roles, not sizes.
--font-sansvar(--font-albert, ui-sans-serif), system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif--font-serifvar(--font-sans)= var(--font-albert, ui-sans-serif), system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-seriftext-display2.5rem / 1$1,240,500text-title1.75rem / 1.1₹4.2 Crtext-heading1.25rem / 1.25Your three pathstext-subheading1rem / 1.4Household net worthtext-body0.875rem / 1.6Spending tracked the six-month average this week.text-label0.8125rem / 1.4Invested / yr (avg)text-caption0.75rem / 1.4synced 3h ago · 12 accountstext-micro0.6875rem / 1.3P10 $2.1M · P90 $5.8MThe app defines no spacing tokens; it uses Tailwind’s 4px rhythm. These are the steps the primitives use.
14pxdot gaps1.56pxlabel ↔ value in Stat28pxchip gaps312pxrow gaps, section header416pxSurface sm520pxSurface md, ExpandableStat624pxPageIntro gap728pxSurface lg936pxSurface xl (sm+)1248pxbetween page sectionsSemantic names that never collide with Tailwind’s.
rounded-badge--radius-sm6pxbadges, chipsrounded-field--radius-md10pxbuttons, inputs, inner tilesrounded-card--radius-lg18pxcards — Origin-calm: a touch softerrounded-hero--radius-xl24pxhero surfacesrounded-pill--radius-pill999pxCards rise through three levels instead of stacking borders; every level bakes in the 1px top edge-light.
shadow-card--shadow-cardvar(--edge-light), 0 1px 2px rgba(0, 0, 0, 0.3)= inset 0 1px 0 rgba(255, 255, 255, 0.045), 0 1px 2px rgba(0, 0, 0, 0.3)shadow-soft--shadow-softvar(--edge-light), 0 12px 40px -16px rgba(0, 0, 0, 0.6)= inset 0 1px 0 rgba(255, 255, 255, 0.045), 0 12px 40px -16px rgba(0, 0, 0, 0.6)hero / callout liftshadow-overlay--shadow-overlayinset 0 1px 0 rgba(255, 255, 255, 0.06), 0 16px 48px -12px rgba(0, 0, 0, 0.6)Chrome only — the dock, the tab bar, the palette, stage scrims. Data surfaces stay solid. The recipe (iOS 27): blur + saturate diffusion, a darkened outer edge, a specular top catch-light.
--surface-glassrgba(22, 24, 28, 0.62)frosted material — dock, tab bar, palette--glass-rimrgba(255, 255, 255, 0.07)1px border on glass panes--glass-specularrgba(255, 255, 255, 0.1)inner top highlight--glass-edgergba(8, 9, 11, 0.55)darkened outer edge ring--shadow-glassinset 0 1px 0 var(--glass-specular), 0 0 0 1px var(--glass-edge), 0 14px 40px -14px rgba(0, 0, 0, 0.65)= inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 0 0 1px rgba(8, 9, 11, 0.55), 0 14px 40px -14px rgba(0, 0, 0, 0.65)--glass-tintvar(--surface-glass)= rgba(22, 24, 28, 0.62)28px--glass-blur28pxMotion communicates, never decorates. One ease (fast attack, long settle) and a duration ladder; every value is a token, and prefers-reduced-motion silences all of it. Click a track to play it.
--ease-outcubic-bezier(0.2, 0.8, 0.2, 1)--duration-fast120ms--duration200ms--duration-morph380mslayout morphs: dock pill ⇄ rail, chart ⇄ stage--duration-entrance480mscontent settling into place (we-rise)--stagger70msper-sibling entrance offset (cards, tiles)Measured live from the computed tokens, with tints composited over the page — every text pair against WCAG AA.
| Pair | Sample | Ratio | WCAG AA |
|---|
The shared set first, then the components only Finance Chief has. Hover, focus and pressed are pinned so states sit side by side; View code reads each file from the repo.
Primitives every screen composes, from one shared library.
Status and category tags. Tone is the meaning (law 2), so a glance reads state before words.
Use when
Don't
States × variants
| neutral | accent | positive | caution | danger | info | |
|---|---|---|---|---|---|---|
| soft | neutral | accent | positive | caution | danger | info |
| solid | neutral | accent | positive | caution | danger | info |
| outline | neutral | accent | positive | caution | danger | info |
More versions
Live — try it
A native select dressed to match: OS arrow removed, the system chevron added, token-styled hover and focus.
Use when
Don't
States × variants
| Default | Hover | Focus | Disabled | |
|---|---|---|---|---|
| md | ||||
| sm |
Live — try it
12moThe card primitive. Prominence = importance (law 1): level raises it through the elevation tokens instead of another border.
Use when
Don't
States × variants
| level 1 | level 2 | level 3 | hero | |
|---|---|---|---|---|
| padding md | Level 1 | Level 2 | Hero |
More versions
smmdlgLive — try it
Household net worth
$1,240,500
One header rhythm everywhere. Section labels a region; PageIntro is the one title + lede + actions row, and it stacks on a phone.
Use when
Don't
Versions
Last 12 months
Three paths, one engine.
The list-row disclosure — a hairline row, a small chevron chip, a quiet body. A skin over Disclosure.
Use when
Don't
States × variants
| Closed | Hover | Open | |
|---|---|---|---|
| row | Brokerage and retirement. | Brokerage and retirement. | Brokerage and retirement. |
Live — try it
The section-level disclosure: a heading row whose meta answers the header before you open it.
Use when
Don't
States × variants
| Closed | Hover | Open | |
|---|---|---|---|
| section | Rows inside. | Rows inside. | Rows inside. |
Live — try it
The children bring their own surfaces, so the header groups cards without double-carding.
The micro-icons that appear everywhere. One drawing each; direction is a prop, not another pasted SVG.
Use when
Don't
Versions
One grammar for every chart: series paint on mount, a ghost crosshair follows the pointer, and the chart’s own header is the readout.
Use when
Don't
Versions
Live — try it
What a wealth app needs that a generic system does not: money that always looks like money, facts that never pass as forecasts.
The figure renderer — guarantees money always looks like money: tabular digits, currency-aware, colored by sign only when asked.
Use when
Don't
States × variants
| usd | usd compact | inr crore | inr adaptive | serifSign | |
|---|---|---|---|---|---|
| default | $1,240,500 | $1.2M | ₹4.2 Cr | ₹4.2 L | 1,240,500 |
| muted | $1,240,500 | $1.2M | ₹4.2 Cr | ₹4.2 L | 1,240,500 |
| gain | $1,240,500 | $1.2M | ₹4.2 Cr | ₹4.2 L | 1,240,500 |
| loss | $1,240,500 | $1.2M | ₹4.2 Cr | ₹4.2 L | 1,240,500 |
More versions
Live — try it
▲ gain / ▼ loss in the locked semantic colors. The color comes from the sign, never from the caller.
Use when
Don't
States × variants
| gain | loss | |
|---|---|---|
| usd | $2.1K | $340 |
| inr | ₹0.02 Cr | ₹0 Cr |
| compact off | $2,100 | $340 |
Live — try it
The hero atom: label · value · optional delta · sub. Size maps prominence, tone maps meaning, and a forecast can never pass as a fact.
Use when
Don't
States × variants
| fact | forecast | tone positive | with delta | |
|---|---|---|---|---|
| hero | Net worth $1,240,500 | Net worth, 2040 $3.9M | Odds of the goal 81% | Monthly burn $8,920$340 |
| default | Net worth $1,240,500 | Net worth, 2040 $3.9M | Odds of the goal 81% | Monthly burn $8,920$340 |
| compact | Net worth $1,240,500 | Net worth, 2040 $3.9M | Odds of the goal 81% | Monthly burn $8,920$340 |
More versions
Household net worth
$1.46M assets − $219K debt
Live — try it
Household net worth
$1.46M assets − $219K debt
A summary KPI that drills into the rows that make it up. The rows inside always sum to the headline.
Use when
Don't
States × variants
| Closed | Hover | Open | |
|---|---|---|---|
| positive | Rows sum to the headline. | Rows sum to the headline. | Rows sum to the headline. |
| caution | Rows sum to the headline. | Rows sum to the headline. | Rows sum to the headline. |
More versions
Live — try it
The master component for every engine read — sentiment-led, metric-forward, reasoning on demand.
Use when
Don't
States × variants
| Default | Hover | |
|---|---|---|
| positive | Dining settled back−18%Three quiet months in a row. | Dining settled back−18%Three quiet months in a row. |
| caution | Subscriptions crept up+$46/moTwo new services this quarter. | Subscriptions crept up+$46/moTwo new services this quarter. |
| danger | Runway below the floor2.1 moIt refills next payday. | Runway below the floor2.1 moIt refills next payday. |
| info | The engine re-ran the plan81%New balances moved the odds. | The engine re-ran the plan81%New balances moved the odds. |
| neutral | Nothing unusual this week—Spending tracked its average. | Nothing unusual this week—Spending tracked its average. |
More versions
Live — try it
Three quiet months in a row; the trend line is flat again.
Compared with the trailing six-month average of the same category.
The one-line verdict at the top of the Roadmap: a sentiment rail, an eyebrow, an editorial headline and a quiet "the risk" line.
Use when
Don't
States × variants
| boxed | bare | |
|---|---|---|
| positive | The call Stay on the steady plan | The call Stay on the steady plan |
| caution | The call Stay on the steady plan | The call Stay on the steady plan |
| danger | The call Stay on the steady plan | The call Stay on the steady plan |
| neutral | The call Stay on the steady plan | The call Stay on the steady plan |
Live — try it
The call
The risk · a long market drawdown in the first five years.
The non-advice line that travels with every recommendation or projection, so the caveat sits next to the claim.
Use when
Don't
Live — try it
Projections & recommendations are estimates for personal planning — not financial, investment, or tax advice, and not a recommendation to buy or sell any security. Terms & privacy
THE reveal primitive. Accordion, CollapsibleSection, ExpandableStat and profile rows are this one mechanism in different clothes.
Use when
Don't
States × variants
| Closed | Hover | Open | |
|---|---|---|---|
| md | Detail. | Detail. | Detail. |
| sm | Detail. | Detail. | Detail. |
More versions
Six linked accounts.
Live — try it
1,500 simulated futures, each drawing a return and volatility for every year.
A figure that counts up into place on first paint (700ms), then snaps on later changes — the ceremony is the arrival.
Use when
Don't
Versions
Live — try it
iOS 27’s transparency slider, honored: retunes the dock, tab bar and palette material from airy to opaque.
Use when
Don't
States × variants
| Default | Hover (unselected) | |
|---|---|---|
| segments | Glass intensity How translucent the floating chrome (dock, tab bar, palette) is. | Glass intensity How translucent the floating chrome (dock, tab bar, palette) is. |
Live — try it
Glass intensity
How translucent the floating chrome (dock, tab bar, palette) is.
Real control: your pick restyles the dock and tab bar on this page and is remembered on this device.
Live around this page rather than rendered twice inside it.
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/cn';
import { NAV_ICONS, type NavIconName } from '@/components/navIcons';
/*
* Four sections. Investments and Scenarios moved INTO Dashboard as sub-tabs — they answer "where do
* I stand", same as Home, and listing them here made the nav look like four equal places when it was
* really two questions and two utilities. See dashboardTabs.ts.
*
* Ask and Profile are the other two sections, but they are rendered as their own rows below rather
* than listed here: Ask carries the accent icon and the ⌘K hint, Profile its own active treatment.
* Four peers to a reader, three shapes in the markup.
*/
const SECTIONS: { href: string; label: string; icon: NavIconName }[] = [
{ href: '/', label: 'Dashboard', icon: 'dashboard' },
{ href: '/spending', label: 'Spending', icon: 'spending' },
];
/* One row, both states: icon always; label + colorized icon only in the rail
(via the .we-lbl / .we-ic-* rules in globals.css). Tooltip only in the pill. */
const row =
'relative flex h-10 shrink-0 items-center gap-2.5 rounded-pill px-2.5 text-label transition-colors duration-fast ease-out-soft';
/**
* NavDock — the desktop nav: a floating liquid-glass pill (icon-only, vertically
* centered, content flowing past behind it) that MORPHS into a Golden-Gate
* edge-to-edge rail on ⌘\. One physical object, two states; the grid tracks +
* pill geometry interpolate together (CSS in globals.css). Mobile web keeps the
* floating BottomTabBar instead.
*/
/*
* `onToggle` is gone from the dock, not from the app.
*
* Hers, 1 October 2026: the monogram, the dividers and the chevron all came out — "I really don't
* think we need it." She is right that four icons with tooltips need no chrome around them.
*
* The consequence worth stating: the labelled rail is now reachable by ⌘\ only. That is a keyboard
* affordance with no visible door, which normally fails the chair test — but the rail only ever
* added labels to icons that already have tooltips, so nothing is reachable solely through it.
*/
export function NavDock({ rail }: { rail: boolean }) {
const pathname = usePathname();
const isActive = (href: string) => (href === '/' ? pathname === '/' : pathname.startsWith(href));
return (
<nav aria-label="Primary" className="we-dock glass-chrome">
{SECTIONS.map((s) => {
const active = isActive(s.href);
return (
<Link
key={s.href}
href={s.href}
aria-current={active ? 'page' : undefined}
data-tour={`nav-${s.label.toLowerCase()}`}
className={cn(
row,
active
? 'bg-accent-soft text-accent'
: 'text-foreground-secondary hover:bg-surface-2 hover:text-foreground',
)}
>
<span className={cn('shrink-0', `we-ic-${s.icon}`, active && 'text-accent')}>
{NAV_ICONS[s.icon]}
</span>
<span className="we-lbl">{s.label}</span>
<span className="we-tip" role="presentation">
{s.label}
</span>
</Link>
);
})}
<div className="flex shrink-0 flex-col gap-0.5">
{/*
CLICKING goes to the Ask PAGE. ⌘K still opens the palette.
Ask became a real page, and the dock kept opening the modal — so the one nav entry
labelled Ask led somewhere other than the thing called Ask. Both render the same
component, so this was never two surfaces, but it was two answers to one click, and
a control has to telegraph where it goes.
The keyboard shortcut keeps the palette because that is what a palette is for:
summoned from anywhere without leaving the page you are on.
*/}
<Link
href="/ask"
aria-current={isActive('/ask') ? 'page' : undefined}
className={cn(
row,
isActive('/ask')
? 'bg-surface-2 text-foreground'
: 'text-foreground-secondary hover:bg-surface-2 hover:text-foreground',
)}
>
<span className="shrink-0 text-accent">{NAV_ICONS.ask}</span>
<span className="we-lbl">
Ask <span className="text-foreground-tertiary">· ⌘K</span>
</span>
<span className="we-tip" role="presentation">
Ask · ⌘K
</span>
</Link>
<Link
href="/profile"
aria-current={isActive('/profile') ? 'page' : undefined}
className={cn(
row,
isActive('/profile')
? 'bg-accent-soft text-accent'
: 'text-foreground-secondary hover:bg-surface-2 hover:text-foreground',
)}
>
<span className="shrink-0">{NAV_ICONS.profile}</span>
<span className="we-lbl">Profile</span>
<span className="we-tip" role="presentation">
Profile
</span>
</Link>
</div>
</nav>
);
}
'use client';
import { useEffect } from 'react';
import Link from 'next/link';
import { Ask, ASK_CHIPS } from '@/components/Ask';
/**
* CommandPalette — ⌘K drops a liquid-glass sheet over the dashboard and hands
* you the Ask bar. This is where the NLQ lives now: one keystroke away from
* every screen instead of a fixture on one page. Esc or the scrim dismisses.
*/
export function CommandPalette({ open, onClose }: { open: boolean; onClose: () => void }) {
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
window.addEventListener('keydown', onKey);
return () => {
document.body.style.overflow = prev;
window.removeEventListener('keydown', onKey);
};
}, [open, onClose]);
if (!open) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-label="Ask Finance Chief"
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto px-4 pb-10 pt-20 sm:pt-28"
>
<div className="we-scrim absolute inset-0" onClick={onClose} aria-hidden />
<div className="glass-chrome relative w-full max-w-2xl rounded-hero p-4 sm:p-5">
<div className="mb-3 flex items-baseline justify-between gap-3 px-1">
<p className="font-serif text-heading italic text-foreground">Ask</p>
<p className="text-micro text-foreground-tertiary">
esc to close · <kbd>⌘G</kbd>{' '}
<Link href="/god" onClick={onClose} className="hover:text-foreground">
God Mode
</Link>
</p>
</div>
<Ask autoFocus bare chips={ASK_CHIPS} />
{/* Jump rows — the palette is also the fastest door into the Explore stages. */}
<div className="mt-3 flex flex-wrap items-center gap-2 px-1">
<span className="text-micro font-medium uppercase tracking-caps text-foreground-tertiary">
Jump to
</span>
<Link
href="/explore/spending"
onClick={onClose}
className="rounded-pill border border-border px-3 py-1 text-caption text-foreground-secondary transition-colors duration-fast ease-out-soft hover:border-accent hover:text-accent"
>
Explore spending
</Link>
<Link
href="/scenarios"
onClick={onClose}
className="rounded-pill border border-border px-3 py-1 text-caption text-foreground-secondary transition-colors duration-fast ease-out-soft hover:border-accent hover:text-accent"
>
Scenario studio
</Link>
</div>
</div>
</div>
);
}
import { isSampleVisit } from '@/lib/config/visit';
/**
* A fixed, unobtrusive marker shown only in the public demo — signals the figures are a fictional
* sample, not real personal finances. Renders nothing when this request may see her data.
*/
export async function DemoBadge() {
if (!(await isSampleVisit())) return null;
return (
<div
title="Fictional sample data — not real personal finances"
className="fixed bottom-28 right-4 z-50 flex items-center gap-2 rounded-pill border border-accent bg-accent-soft px-3 py-1.5 text-caption font-medium text-accent shadow-lg sm:bottom-4"
>
<span className="inline-block h-1.5 w-1.5 rounded-pill bg-accent" />
<span className="sm:hidden">Demo</span>
<span className="hidden sm:inline">Demo data · fictional sample</span>
</div>
);
}
'use client';
import { useState } from 'react';
import {
Area,
ComposedChart,
Line,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { MonteCarloResult } from '@/lib/logic/monteCarlo';
import { cn } from '@/lib/cn';
import { AnimatedNumber, Money, Stat, Surface, chart } from '@/components/ui';
import { probPct } from '@/lib/format';
const CR = 1e7;
const cr = (inr: number) => +(inr / CR).toFixed(1);
/**
* Probabilistic forecast — the honest twin of the deterministic trajectory.
* Speaks the one chart grammar: the cone paints on mount, a ghost crosshair
* follows the pointer, and scrubbing a year re-anchors the three percentile
* stats to that year (labels say so) — the header is the readout.
*/
export function MonteCarloForecast({
mc,
goalINR,
scenarioLabel,
}: {
mc: MonteCarloResult;
goalINR: number;
scenarioLabel: string;
}) {
const data = mc.years.map((year, i) => ({
year,
outer: [cr(mc.p10INR[i]), cr(mc.p90INR[i])], // P10–P90
inner: [cr(mc.p25INR[i]), cr(mc.p75INR[i])], // P25–P75
mid: cr(mc.p50INR[i]),
}));
const prob = probPct(mc.probGoalINR);
const goalCr = Math.round(goalINR / CR);
const probTone = prob >= 75 ? 'text-positive' : prob >= 50 ? 'text-accent' : 'text-caution';
const [hover, setHover] = useState<number | null>(null);
const i = hover != null ? mc.years.indexOf(hover) : mc.years.length - 1;
const scrubbing = hover != null;
const yearLabel = scrubbing ? ` · ${hover}` : '';
return (
<Surface padding="lg">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-label text-foreground-secondary">Probability forecast · {scenarioLabel}</h2>
<p className="mt-1 max-w-xl text-caption text-foreground-tertiary">
{mc.sims.toLocaleString()} simulated futures — each year's return drawn from history (~
{Math.round(mc.meanReturn * 100)}% average, ±{Math.round(mc.volReturn * 100)}% swings) rather
than a fixed rate. The cone is where your net worth actually lands; the band's width is
market uncertainty made visible. Scrub the cone to read any year.
</p>
</div>
<div className="text-right">
<p className={cn('text-title', probTone)}>
<AnimatedNumber value={prob} format={(n) => `${Math.round(n)}%`} plain />
</p>
<p className="text-caption text-foreground-tertiary">of outcomes clear ₹{goalCr} Cr</p>
</div>
</div>
{/* the readout — re-anchors to the scrubbed year */}
<div className="mt-5 grid grid-cols-3 gap-3">
<Stat
size="compact"
tone="danger"
label={`Unlucky — 1 in 10 end below${yearLabel}`}
value={<Money value={mc.p10INR[i]} currency="inr" />}
sub="only 1 in 10 end worse"
/>
<Stat
size="compact"
tone="accent"
label={`Typical${yearLabel}`}
value={<Money value={mc.p50INR[i]} currency="inr" />}
sub={scrubbing ? 'half end above, half below' : 'half of futures end above this'}
/>
<Stat
size="compact"
tone="positive"
label={`Lucky — 1 in 10 end above${yearLabel}`}
value={<Money value={mc.p90INR[i]} currency="inr" />}
sub="only 1 in 10 end better"
/>
</div>
<div className="mt-5 h-64 w-full select-none">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={data}
margin={{ top: 8, right: 16, bottom: 4, left: 0 }}
onMouseMove={(e) => setHover(e?.activeLabel != null ? Number(e.activeLabel) : null)}
onMouseLeave={() => setHover(null)}
>
<XAxis dataKey="year" tick={chart.axisTick} axisLine={{ stroke: chart.gridStroke }} tickLine={false} />
<YAxis tick={chart.axisTick} axisLine={false} tickLine={false} width={44} tickFormatter={(v) => `₹${v}`} />
<Tooltip content={() => null} cursor={chart.ghostCursor} />
<Area type="monotone" dataKey="outer" stroke="none" fill={chart.band.outer} {...chart.paintOn} />
<Area type="monotone" dataKey="inner" stroke="none" fill={chart.band.inner} {...chart.paintOn} />
<Line type="monotone" dataKey="mid" stroke={chart.accentLine} strokeWidth={2.5} dot={false} {...chart.paintOn} />
<ReferenceLine
y={goalCr}
stroke={chart.goalLine}
strokeDasharray="4 4"
label={{ value: `₹${goalCr} Cr goal`, fontSize: 10, fill: chart.goalLine, position: 'insideTopRight' }}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
<div className="mt-3 flex flex-wrap gap-3 text-micro text-foreground-tertiary">
<span className="flex items-center gap-1.5">
<span className="inline-block h-2 w-3 rounded-pill" style={{ background: chart.accentLine }} />
the typical future
</span>
<span className="flex items-center gap-1.5">
<span className="inline-block h-2 w-3 rounded-badge" style={{ background: chart.band.inner }} />
half of all futures
</span>
<span className="flex items-center gap-1.5">
<span className="inline-block h-2 w-3 rounded-badge" style={{ background: chart.band.outer }} />
8 in 10 futures
</span>
</div>
</Surface>
);
}
'use client';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import type { DataSourceStatus, FreshnessState } from '@/lib/logic/freshness';
import { relativeAgo } from '@/lib/format';
import { cn } from '@/lib/cn';
import { Badge, Button, Surface } from '@/components/ui';
import { SyncNowButton } from '@/components/SyncNowButton';
/** fresh = green, due = amber, overdue = red. */
const DOT: Record<FreshnessState, string> = {
fresh: 'bg-positive',
due: 'bg-caution',
overdue: 'bg-danger',
};
const inputCls =
'h-9 w-28 rounded-field border border-border bg-surface-2 px-2 tnum text-body text-foreground ' +
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background';
function cadenceWord(days: number): string {
if (days >= 90) return 'quarterly';
if (days >= 30) return 'monthly';
return `every ${days}d`;
}
function statusLine(s: DataSourceStatus): string {
// Worked out, not fetched — so there is no "last synced" to report, and nothing to go stale.
if (s.kind === 'derived') return s.suggestionNote ?? 'Computed from known terms — always current';
if (s.kind === 'auto') {
if (!s.lastUpdated) return 'Not connected';
return `Synced ${relativeAgo(s.lastUpdated)} · auto ${cadenceWord(s.cadenceDays)}`;
}
if (s.kind === 'ai') {
if (!s.lastUpdated) return 'Not yet valued · Sync to AI-value';
return `AI-valued ${relativeAgo(s.lastUpdated)} · auto ${cadenceWord(s.cadenceDays)}`;
}
if (!s.lastUpdated) return 'Never set';
if (s.state === 'fresh') return `Updated ${relativeAgo(s.lastUpdated)} · review ${cadenceWord(s.cadenceDays)}`;
if (s.state === 'due') return `Update due · last ${relativeAgo(s.lastUpdated)}`;
return `Overdue · last updated ${relativeAgo(s.lastUpdated)}`;
}
/** Edit affordance: always rendered (so touch devices show it), subtle until row-hover on desktop. */
function Pencil() {
return (
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
</svg>
);
}
/**
* The data "control center": Sync-now force-refreshes every auto/AI source; manual sources (mortgage,
* India lands) carry a pencil to pin a value and a reminder when due. The India flat is AI-driven —
* it shows the AI's ₹/sqft rate and isn't hand-edited.
*/
export function DataFreshnessCard({ sources, bare = false }: { sources: DataSourceStatus[]; bare?: boolean }) {
const router = useRouter();
const [editing, setEditing] = useState<string | null>(null);
const [value, setValue] = useState('');
const [saving, setSaving] = useState(false);
const needsAttention = sources.filter((s) => s.kind === 'manual' && s.state !== 'fresh');
function startEdit(s: DataSourceStatus) {
setEditing(s.id);
setValue(String(s.suggestionInput ?? s.currentInput ?? ''));
}
async function save(s: DataSourceStatus) {
const n = parseFloat(value);
if (!isFinite(n)) return;
setSaving(true);
try {
await fetch('/api/override', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: s.id, balance: Math.round(n * (s.scale ?? 1)), currency: s.currency }),
});
setEditing(null);
router.refresh();
} finally {
setSaving(false);
}
}
const Wrap = bare ? 'div' : Surface;
return (
<Wrap {...(bare ? {} : { padding: 'md' as const })}>
<div className={cn('flex items-start justify-between gap-3', bare && 'justify-end')}>
{!bare && (
<div>
<h2 className="text-label font-medium text-foreground">Data sources</h2>
<p className="text-caption text-foreground-secondary">
Live accounts auto-refresh every few days; manual items remind you when they're due.
</p>
</div>
)}
<SyncNowButton />
</div>
{needsAttention.length > 0 && (
<p className="mt-3 rounded-field bg-caution-soft px-3 py-2 text-caption text-caution">
{needsAttention.length} manual {needsAttention.length === 1 ? 'item needs' : 'items need'} updating:{' '}
{needsAttention.map((s) => s.label).join(', ')}.
</p>
)}
<ul className="mt-4 flex flex-col gap-2.5 text-body">
{sources.map((s) => (
<li key={s.id} className="group">
<div className="flex items-center justify-between gap-3">
<span className="flex min-w-0 items-center gap-2">
<span className={cn('inline-block h-2 w-2 shrink-0 rounded-pill', DOT[s.state])} />
<span className="truncate text-foreground">{s.label}</span>
<Badge tone="neutral" variant="outline" className="shrink-0 uppercase">
{s.kind}
</Badge>
</span>
<span className="flex shrink-0 items-center gap-2 text-right text-caption text-foreground-secondary">
{s.valueDisplay && <span className="tnum text-foreground">{s.valueDisplay}</span>}
<span className="hidden sm:inline">{statusLine(s)}</span>
{s.editable && (
<button
onClick={() => (editing === s.id ? setEditing(null) : startEdit(s))}
aria-label={`Edit ${s.label}`}
title="Edit value"
className="shrink-0 text-foreground-secondary opacity-60 transition-opacity duration-fast ease-out-soft hover:text-foreground hover:opacity-100 group-hover:opacity-100"
>
<Pencil />
</button>
)}
</span>
</div>
{editing === s.id && (
<div className="mt-2 rounded-field border border-border bg-background-sunken p-2.5">
<div className="flex flex-wrap items-center gap-2 text-caption">
<input
type="number"
step="any"
value={value}
onChange={(e) => setValue(e.target.value)}
className={inputCls}
/>
<span className="text-foreground-secondary">{s.inputUnit}</span>
{s.suggestionInput != null && (
<button
onClick={() => setValue(String(s.suggestionInput))}
className="rounded-pill border border-border px-2 py-0.5 text-foreground-secondary transition-colors duration-fast ease-out-soft hover:text-foreground"
>
Use AI: {s.suggestionInput} {s.inputUnit}
</button>
)}
<Button variant="primary" size="sm" onClick={() => save(s)} disabled={saving}>
{saving ? 'Saving…' : 'Save'}
</Button>
<Button variant="secondary" size="sm" onClick={() => setEditing(null)}>
Cancel
</Button>
</div>
{s.suggestionNote && <p className="mt-1.5 text-micro text-foreground-tertiary">AI: {s.suggestionNote}</p>}
</div>
)}
</li>
))}
</ul>
</Wrap>
);
}
Every one is a call made on a real screen, promoted to law and enforced by design-lint or the primitives.
Do
Don’t
De-card
A box only where content is a data object (hero, charts, tables, competing cards). Statements, verdicts, portals, and inputs go bare — editorial rails, hairline rows. Inside a card: separator rows, never boxes-in-boxes.
Ink numbers
Headline figures render in foreground ink. Sentiment rides a small element — a 1.5px toned dot, a delta chip, a badge — never full-saturation color at display scale.
Deep fills
Filled controls use the deep accent-fill token with light on-fill ink (≈6.3:1). Light teal never sits behind text; white is not a token.
Role names only
No raw type sizes, no raw radii, no hex, no arbitrary colors — role names only. design-lint fails the build on any of it, so the dialect can't drift.
Every token in this system comes from the app — the spec proposed nothing.