Finance Chief design system

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.

Principles

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.

Brand & voice

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

Foundations

Every value below is read from the token file at build time; every swatch paints with the live token.

Color

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

Aa 123
--foreground#eceae5primary text
Aa 123
--muted#9a968esecondary text
Aa 123
--text-primaryvar(--foreground)= #eceae5
Aa 123
--text-secondaryvar(--muted)= #9a968e
Aa 123
--text-tertiary#8a8880hints, captions, axis labels — 4.6:1+ on cards (WCAG AA)
Aa 123
--text-on-accent#0b1413dark ink on teal / solid fills
Aa 123
--text-on-fill#f0f6f5light ink on the deep fills (≈6.3:1)

Borders

--border#24282dhairline
--border-subtlevar(--border)= #24282d
--border-strong#3a3f47hover / emphasis

Accent

--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)= #6fb3ad

Semantic & 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 alarming

Charts

--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, inner

Typography

Albert Sans, with tabular figures so money columns align. Roles, not sizes.

0123 $45
--font-sansvar(--font-albert, ui-sans-serif), system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif
The voice
--font-serifvar(--font-sans)= var(--font-albert, ui-sans-serif), system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif
text-display2.5rem / 1$1,240,500
text-title1.75rem / 1.1₹4.2 Cr
text-heading1.25rem / 1.25Your three paths
text-subheading1rem / 1.4Household net worth
text-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 accounts
text-micro0.6875rem / 1.3P10 $2.1M · P90 $5.8M

Spacing

The app defines no spacing tokens; it uses Tailwind’s 4px rhythm. These are the steps the primitives use.

14pxdot gaps
1.56pxlabel ↔ value in Stat
28pxchip gaps
312pxrow gaps, section header
416pxSurface sm
520pxSurface md, ExpandableStat
624pxPageIntro gap
728pxSurface lg
936pxSurface xl (sm+)
1248pxbetween page sections

Radius

Semantic names that never collide with Tailwind’s.

rounded-badge
--radius-sm6pxbadges, chips
rounded-field
--radius-md10pxbuttons, inputs, inner tiles
rounded-card
--radius-lg18pxcards — Origin-calm: a touch softer
rounded-hero
--radius-xl24pxhero surfaces
rounded-pill
--radius-pill999px

Elevation

Cards 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 lift
shadow-overlay
--shadow-overlayinset 0 1px 0 rgba(255, 255, 255, 0.06), 0 16px 48px -12px rgba(0, 0, 0, 0.6)

Glass & scrim

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.

Wglass-chromeactive
.we-scrim — dims and lightly diffuses the page behind sheets
--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-blur28px

Motion

Motion 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)
.we-rise · --duration-entrance · calc(i × --stagger) — silent under prefers-reduced-motion

Contrast

Measured live from the computed tokens, with tints composited over the page — every text pair against WCAG AA.

PairSampleRatioWCAG AA

Components

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.

Shared set

Primitives every screen composes, from one shared library.

Button

The clickable affordance. Actionable looks actionable (law 4): hover, press and a visible focus ring on every variant.

Use when

  • One primary per view — the true call to action.
  • Secondary for the safe alternatives beside it.
  • Ghost for quiet tertiary actions in toolbars and rows.
  • Danger only for destructive, confirmed actions.

Don't

  • Two primaries competing in one region.
  • A light-teal fill behind text — filled controls are the deep accent-fill.
  • Links that navigate dressed as buttons.

States × variants

DefaultHoverFocusPressedDisabled
primary
secondary
ghost
danger

More versions

size="sm" — compact toolbars and rows
icon — leading glyph

Live — try it

pressed 0×

Badge

Status and category tags. Tone is the meaning (law 2), so a glance reads state before words.

Use when

  • Status: on track, action needed, live.
  • Category tags on transaction rows: Spent, Income, Wealth.
  • Soft is the default; solid or outline for emphasis.

Don't

  • As a button — badges are not interactive.
  • Long sentences — one or two words.
  • Tone for decoration; every tone means something.

States × variants

neutralaccentpositivecautiondangerinfo
softneutralaccentpositivecautiondangerinfo
solidneutralaccentpositivecautiondangerinfo
outlineneutralaccentpositivecautiondangerinfo

More versions

LiveBest path
with icon — a toned dot or glyph

Live — try it

on track

Select

A native select dressed to match: OS arrow removed, the system chevron added, token-styled hover and focus.

Use when

  • Choosing one of a short, known list (window, scenario, sort).
  • size="sm" for inline filters in a toolbar.

Don't

  • Fewer than three options — use a segmented control.
  • Long searchable lists — use the command palette.

States × variants

DefaultHoverFocusDisabled
md
sm

Live — try it

value = 12mo

Surface

The card primitive. Prominence = importance (law 1): level raises it through the elevation tokens instead of another border.

Use when

  • Data objects: the hero figure, charts, tables, competing scenario cards.
  • level 2 for nested or secondary cards, level 3 for overlays.
  • hero once per page — the surface carrying the headline number.

Don't

  • Statements, verdicts and inputs — those go bare (de-card).
  • A box inside a box — use separator rows inside a card.

States × variants

level 1level 2level 3hero
padding md

Level 1

Level 2

Level 3

Hero

More versions

sm
md
lg
padding sm · md · lg

Live — try it

Household net worth

$1,240,500

Section & PageIntro

One header rhythm everywhere. Section labels a region; PageIntro is the one title + lede + actions row, and it stacks on a phone.

Use when

  • Section for every titled region on a screen.
  • PageIntro for the top of a page or tab with actions.

Don't

  • Hand-rolled two-column headers.
  • Headings without the role-named type scale.

Versions

Spending

Last 12 months

Section — title, description, action

Scenarios

Three paths, one engine.

PageIntro — title, lede, actions

Accordion

The list-row disclosure — a hairline row, a small chevron chip, a quiet body. A skin over Disclosure.

Use when

  • "Why?" and method notes under a figure.
  • Lists of rows where each opens to detail.

Don't

  • Hiding the answer — the summary should already say what is inside.
  • Nesting accordions.

States × variants

ClosedHoverOpen
row
Brokerage and retirement.
Brokerage and retirement.
Brokerage and retirement.

Live — try it

One home, valued from recent comparable sales.
Brokerage and retirement accounts.
Jewelry, valued at today's rate.

CollapsibleSection

The section-level disclosure: a heading row whose meta answers the header before you open it.

Use when

  • Grouping full cards under one tappable header.
  • Long pages where secondary sections can rest closed.

Don't

  • Wrapping it in another card (double-carding).
  • A header without meta — say what is inside.

States × variants

ClosedHoverOpen
section

Rows inside.

Rows inside.

Rows inside.

Live — try it

The children bring their own surfaces, so the header groups cards without double-carding.

Glyphs

The micro-icons that appear everywhere. One drawing each; direction is a prop, not another pasted SVG.

Use when

  • Any chevron or the Explore mark.
  • Direction through the direction prop.

Don't

  • Pasting a new chevron SVG into a component.

Versions

Chevron — down · up · right · left
ExpandGlyph — "this opens a stage"

Chart grammar

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

  • chart.series for categorical color, assigned by meaning.
  • chart.band for forecast cones — uncertainty made visible (law 3).
  • chart.moneyIn / moneyOut at area scale.

Don't

  • Floating tooltip boxes over the plot.
  • Animating live-recompute charts on every interaction.
  • Saturated semantic colors across half the viewport.

Versions

Categorical series — chart-1 … chart-10

Live — try it

Finance Chief components

What a wealth app needs that a generic system does not: money that always looks like money, facts that never pass as forecasts.

Money

The figure renderer — guarantees money always looks like money: tabular digits, currency-aware, colored by sign only when asked.

Use when

  • Every rendered amount, USD or INR.
  • tone="auto" with signed for gains and losses.
  • serifSign on the one headline figure per page.

Don't

  • Formatting currency by hand in a component.
  • Full-saturation tone at display scale — ink the numbers.
  • Projections: those belong in Stat kind="forecast".

States × variants

usdusd compactinr croreinr adaptiveserifSign
default$1,240,500$1.2M₹4.2 Cr₹4.2 L1,240,500
muted$1,240,500$1.2M₹4.2 Cr₹4.2 L1,240,500
gain$1,240,500$1.2M₹4.2 Cr₹4.2 L1,240,500
loss$1,240,500$1.2M₹4.2 Cr₹4.2 L1,240,500

More versions

+$2,100−$340
signed + tone="auto" — color comes from the sign

Live — try it

$1,240,500

Delta

▲ gain / ▼ loss in the locked semantic colors. The color comes from the sign, never from the caller.

Use when

  • Change beside a headline figure.
  • Period-over-period movement in a row.

Don't

  • Hand-wiring ▲/▼ and a tone in a screen.
  • As the headline itself — it rides beside an ink number.

States × variants

gainloss
usd$2.1K$340
inr₹0.02 Cr₹0 Cr
compact off$2,100$340

Live — try it

$2.1K

Stat

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

  • Every KPI: net worth, monthly burn, odds.
  • kind="forecast" for any projection — it gains a ~ and softens.

Don't

  • Re-declaring a local label/value pair.
  • A forecast in fact styling.

States × variants

factforecasttone positivewith 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,240,500

$1.46M assets − $219K debt

sub — the line that explains the figure

Live — try it

Household net worth

$1,240,500$2.1K

$1.46M assets − $219K debt

ExpandableStat

A summary KPI that drills into the rows that make it up. The rows inside always sum to the headline.

Use when

  • Totals built from a list: cash across accounts, debt across loans.
  • hint with the row count, so it telegraphs that it opens.

Don't

  • Rows that do not add up to the headline.
  • Saturated value color — sentiment rides the small dot.

States × variants

ClosedHoverOpen
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

tone — default · danger · accent

Live — try it

  • Checking$21,400
  • Savings$24,800
  • Cash card$2,000

Insight

The master component for every engine read — sentiment-led, metric-forward, reasoning on demand.

Use when

  • Anything the engine noticed: a trend, a risk, a win.
  • bare inside a card that already has a surface.
  • reasoning for the "Why?" behind the read.

Don't

  • Paragraphs — one title, one line, one metric.
  • A boxed Insight inside a card — use bare.

States × variants

DefaultHover
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/mo

Two new services this quarter.

Subscriptions crept up

+$46/mo

Two new services this quarter.

danger

Runway below the floor

2.1 mo

It refills next payday.

Runway below the floor

2.1 mo

It refills next payday.

info

The engine re-ran the plan

81%

New balances moved the odds.

The engine re-ran the plan

81%

New balances moved the odds.

neutral

Nothing unusual this week

—

Spending tracked its average.

Nothing unusual this week

—

Spending tracked its average.

More versions

Groceries steady

+2%

Travel above plan

+$380
bare — rows inside an existing card

New balances synced

81%
action — a next step under the read

Live — try it

Dining settled back to its usual level

−18%

Three quiet months in a row; the trend line is flat again.

Compared with the trailing six-month average of the same category.

Callout — "The Call"

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

  • Once per page, for the recommendation itself.
  • bare for the editorial version on the canvas.

Don't

  • More than one per view.
  • Charts or lists inside it — it states the call, nothing else.

States × variants

boxedbare
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

Stay the course on the steady plan.

The risk · a long market drawdown in the first five years.

Disclaimer

The non-advice line that travels with every recommendation or projection, so the caveat sits next to the claim.

Use when

  • Under The Call, the hero projection and every Ask answer.

Don't

  • Hiding it one tab away from the claim.
  • Rewording it per screen — one text, one component.

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

Disclosure

THE reveal primitive. Accordion, CollapsibleSection, ExpandableStat and profile rows are this one mechanism in different clothes.

Use when

  • Any "tap to see more" — build a skin over it, never a second mechanism.
  • chevronSize="sm" in dense rows.

Don't

  • A new chevron, a new height animation or a new aria wiring.

States × variants

ClosedHoverOpen
md

Detail.

Detail.

Detail.

sm

Detail.

Detail.

Detail.

More versions

Six linked accounts.

trailing — meta before the chevron

Live — try it

1,500 simulated futures, each drawing a return and volatility for every year.

AnimatedNumber

A figure that counts up into place on first paint (700ms), then snaps on later changes — the ceremony is the arrival.

Use when

  • Headline figures on first paint.
  • delay to stagger a row of figures.

Don't

  • Live-recompute surfaces (scenario sliders) — those snap.
  • Counting on every refresh.

Versions

1,240,500
currency — serif sign, Inter digits
81%
plain + format — a percentage

Live — try it

1,240,50086,25081%

GlassIntensity

iOS 27’s transparency slider, honored: retunes the dock, tab bar and palette material from airy to opaque.

Use when

  • Profile → appearance, once.

Don't

  • Glass on data surfaces — glass is chrome only.

States × variants

DefaultHover (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.

App chrome

Live around this page rather than rendered twice inside it.

NavDockA floating liquid-glass pill that morphs into an edge-to-edge rail on ⌘\.Left edge of this page — press ⌘\ to morph 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>
    );
}
CommandPalette⌘K drops a liquid-glass sheet over the page and hands you the Ask bar.Press ⌘K anywhere.
'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>
    );
}
DemoBadgeSignals the figures are a fictional sample.Bottom-right of every page in the public demo.
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>
    );
}
MonteCarloForecastProbabilistic forecast — the honest twin of the deterministic trajectory.Dashboard.
'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&apos;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&apos;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>
    );
}
DataFreshnessCardFresh = green, due = amber, overdue = red, for every data source.Profile.
'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&apos;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>
    );
}

Rules

Every one is a call made on a real screen, promoted to law and enforced by design-lint or the primitives.

Do

  • Inside a card: separator rows, never boxes-in-boxes.
  • Disclosure rows always sum to the headline — opening never surprises.
  • Role names only for type, radius and color.
  • Every recommendation or projection travels with the Disclaimer.

Don’t

  • Full-saturation color at display scale — ink the numbers.
  • Light teal behind text; white is not a token.
  • Floating tooltip boxes on charts.
  • Hardcoded colors, raw type sizes or raw radii (build-time lint).

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.

Spec decisions

Every token in this system comes from the app — the spec proposed nothing.

  • Contrast audit (WCAG AA, every text and fill pair) — measured live in the table above.
  • Docs kept in sync with code: this page names the face the app actually loads — Albert Sans.
  • Spacing stays on Tailwind’s 4px scale; the app defines no spacing tokens of its own.
Finance Chief design system · tokens and primitives, enforced by a build-time lint
Demo