🦄 Unicorn.Land ▸ docs/plans/2026-03-01-whealthy-simplified-implementation.md
updated 2026-03-01

Whealthy Simplified — Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Rebuild Whealthy as a story-driven “answer first” wealth longevity calculator with simplified scope and Unicorn.Land design system integration, deploying to whealthy.unicorn.land.

Architecture: Rebuild the UI layer in-place within the existing whealthy/ directory. Keep the simulation engine (lib/simulation.ts, lib/tax.ts) untouched. Create a simplified params interface that maps to the full Params type internally. Replace all UI components with the new answer-first design. Switch deployment from GitHub Pages to Cloudflare Pages.

Tech Stack: Next.js 16 (static export), React 19, TypeScript, Tailwind 4 with Unicorn.Land theme tokens, Recharts, Zustand 5, Zod


Task 1: Design System Integration — Fonts & Tokens

Files: - Create: whealthy/src/app/fonts.ts - Modify: whealthy/src/app/globals.css - Modify: whealthy/src/app/layout.tsx - Modify: whealthy/tailwind.config.ts

Step 1: Install Outfit font

Currently uses Geist. Switch to Outfit (Google Fonts) + keep JetBrains Mono.

Create whealthy/src/app/fonts.ts:

import { Outfit, JetBrains_Mono } from "next/font/google";

export const outfit = Outfit({
  subsets: ["latin"],
  variable: "--font-outfit",
  display: "swap",
});

export const jetbrainsMono = JetBrains_Mono({
  subsets: ["latin"],
  variable: "--font-mono",
  display: "swap",
});

Step 2: Replace globals.css with Unicorn.Land tokens

Replace the entire CSS custom properties section in whealthy/src/app/globals.css with the --ul-* token system from design-system/css/unicorn-land.css. Key mappings:

@import "tailwindcss";

:root {
  /* Unicorn.Land Opal Scale */
  --ul-opal-50: #eefbf8;
  --ul-opal-100: #d5f5ef;
  --ul-opal-200: #aeebe0;
  --ul-opal-300: #80dbce;
  --ul-opal-400: #56dbd0;
  --ul-opal-500: #2fb8ad;
  --ul-opal-600: #0d9488;
  --ul-opal-700: #0f766e;
  --ul-opal-800: #115e59;
  --ul-opal-900: #134e4a;

  /* Unicorn.Land Stone Neutrals (warm, NOT blue-gray) */
  --ul-stone-50: #fafaf9;
  --ul-stone-100: #f5f5f4;
  --ul-stone-200: #e7e5e4;
  --ul-stone-300: #d6d3d1;
  --ul-stone-400: #a8a29e;
  --ul-stone-500: #78716c;
  --ul-stone-600: #57534e;
  --ul-stone-700: #44403c;
  --ul-stone-800: #292524;
  --ul-stone-900: #1c1917;

  /* Semantic */
  --ul-accent: #56dbd0;
  --ul-accent-hover: #2fb8ad;
  --ul-bg: #fafaf9;
  --ul-bg-surface: #f5f5f4;
  --ul-border: #e7e5e4;
  --ul-text: #1c1917;
  --ul-text-secondary: #78716c;
  --ul-text-muted: #a8a29e;
  --ul-error: #dc2626;
  --ul-warning: #ca8a04;

  /* Whealthy-specific */
  --wh-coral: #f87171;
  --wh-coral-muted: #fecaca;
  --wh-gold: #ca8a04;
  --wh-gold-muted: #fef3c7;

  /* Typography */
  --ul-font-sans: "Outfit", ui-sans-serif, system-ui, sans-serif;
  --ul-font-mono: "JetBrains Mono", ui-monospace, monospace;

  /* Spacing */
  --ul-radius-sm: 4px;
  --ul-radius-md: 6px;
  --ul-radius-lg: 8px;
  --ul-radius-xl: 12px;

  /* Shadows */
  --ul-shadow-sm: 0 1px 3px rgba(28, 25, 23, 0.06), 0 1px 2px rgba(28, 25, 23, 0.04);
  --ul-shadow-md: 0 4px 6px rgba(28, 25, 23, 0.06), 0 2px 4px rgba(28, 25, 23, 0.04);

  /* Rainbow shimmer gradient */
  --ul-rainbow: linear-gradient(135deg, #ff6b9d, #ff8a65, #ffd54f, #81c784, #64b5f6, #ba68c8);
}

@media (prefers-color-scheme: dark) {
  :root {
    --ul-bg: #1c1917;
    --ul-bg-surface: #292524;
    --ul-border: #44403c;
    --ul-text: #f5f5f4;
    --ul-text-secondary: #a8a29e;
    --ul-text-muted: #78716c;
  }
}

/* Rainbow shimmer animation */
@keyframes rainbow-shift {
  0%, 100% { background-position: 0% 50%; }
  50% { background-position: 100% 50%; }
}

.rainbow-shimmer {
  background: var(--ul-rainbow);
  background-size: 300% 300%;
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
  background-clip: text;
  animation: rainbow-shift 6s ease infinite;
}

/* Base styles */
body {
  font-family: var(--ul-font-sans);
  background: var(--ul-bg);
  color: var(--ul-text);
}

Step 3: Update layout.tsx

Replace Geist fonts with Outfit + JetBrains Mono imports from fonts.ts. Update <html> className to use the new font variables. Update metadata title to “Whealthy — Wealth Longevity Planner” and description.

Step 4: Update tailwind.config.ts

Extend theme with Unicorn.Land color tokens (opal scale, stone scale, semantic aliases) and font families. Map --ul-* vars to Tailwind utilities so we can use bg-opal-400, text-stone-900, etc.

Step 5: Commit

git add -A && git commit -m "feat: integrate Unicorn.Land design tokens, swap to Outfit + JetBrains Mono"

Task 2: Simplified Types & Defaults

Files: - Create: whealthy/src/lib/simplified-types.ts - Create: whealthy/src/lib/simplified-defaults.ts

Step 1: Define simplified params interface

Create whealthy/src/lib/simplified-types.ts. This is the user-facing interface — much simpler than the full Params type. It maps to Params internally.

import { z } from "zod";

export const simplifiedParamsSchema = z.object({
  // Mode
  calculationMode: z.enum(["forward", "reverse"]).default("reverse"),

  // Core (the editable sentence)
  currentAge: z.number().min(18).max(100).default(35),
  deathAge: z.number().min(30).max(120).default(85),
  annualSpending: z.number().min(0).default(80000),
  currency: z.string().default("$"),

  // Mode-specific
  startWealth: z.number().min(0).default(1000000),          // forward mode input
  desiredTerminalWealth: z.number().min(0).default(500000),  // reverse mode input

  // Spending rule
  spendingRule: z.enum(["fixed", "%wealth", "guardrails"]).default("fixed"),
  expenseInflation: z.number().min(0).max(0.2).default(0.025),
  spendPctWealth: z.number().min(0).max(1).default(0.04),
  guardrailTarget: z.number().min(0).max(1).default(0.04),
  guardrailMinChange: z.number().min(-1).max(0).default(-0.025),
  guardrailMaxChange: z.number().min(0).max(1).default(0.05),

  // Returns (historical defaults)
  equityReturn: z.number().default(0.07),
  equityWeight: z.number().min(0).max(1).default(0.60),
  cashReturn: z.number().default(0.02),
  cashWeight: z.number().min(0).max(1).default(0.40),
  equityVolatility: z.number().default(0.16),
  cashVolatility: z.number().default(0.01),
  dividendYield: z.number().default(0.02),

  // Tax
  taxJurisdiction: z.enum(["germany", "us", "uk", "custom"]).default("us"),
  effectiveTaxRate: z.number().min(0).max(1).default(0.21),  // for custom
  dividendTaxRate: z.number().min(0).max(1).default(0.21),
  capitalGainsTaxRate: z.number().min(0).max(1).default(0.21),
  interestTaxRate: z.number().min(0).max(1).default(0.21),

  // Monte Carlo
  runMonteCarlo: z.boolean().default(false),
  numPaths: z.number().min(100).max(2000).default(500),
});

export type SimplifiedParams = z.infer<typeof simplifiedParamsSchema>;

Step 2: Create defaults and mapping function

Create whealthy/src/lib/simplified-defaults.ts. Export DEFAULT_SIMPLIFIED_PARAMS and a toFullParams(simplified: SimplifiedParams): Params function that maps simplified params to the full Params type expected by the simulation engine. Fill in all the complex fields the engine needs (private equity disabled, philanthropy zeroed, real mode false, etc.).

import type { Params } from "./types";
import type { SimplifiedParams } from "./simplified-types";

export const DEFAULT_SIMPLIFIED_PARAMS: SimplifiedParams = {
  calculationMode: "reverse",
  currentAge: 35,
  deathAge: 85,
  annualSpending: 80000,
  currency: "$",
  startWealth: 1000000,
  desiredTerminalWealth: 500000,
  spendingRule: "fixed",
  expenseInflation: 0.025,
  spendPctWealth: 0.04,
  guardrailTarget: 0.04,
  guardrailMinChange: -0.025,
  guardrailMaxChange: 0.05,
  equityReturn: 0.07,
  equityWeight: 0.60,
  cashReturn: 0.02,
  cashWeight: 0.40,
  equityVolatility: 0.16,
  cashVolatility: 0.01,
  dividendYield: 0.02,
  taxJurisdiction: "us",
  effectiveTaxRate: 0.21,
  dividendTaxRate: 0.21,
  capitalGainsTaxRate: 0.21,
  interestTaxRate: 0.21,
  runMonteCarlo: false,
  numPaths: 500,
};

export function toFullParams(s: SimplifiedParams): Params {
  // Map simplified → full Params for the simulation engine
  // Sets all omitted fields to safe defaults (no private equity, no philanthropy, no one-offs)
  return {
    currency: s.currency,
    calculationMode: s.calculationMode,
    startWealth: s.startWealth,
    liquidShare: 1, // all liquid (no private equity)
    currentAge: s.currentAge,
    deathAge: s.deathAge,
    spendingRule: s.spendingRule,
    annualExpenseNow: s.annualSpending,
    expenseInflation: s.expenseInflation,
    spendPctWealth: s.spendPctWealth,
    guardrails: {
      targetPctWealth: s.guardrailTarget,
      minChange: s.guardrailMinChange,
      maxChange: s.guardrailMaxChange,
    },
    philanthropyMode: "fixed",
    philanthropyFixedNow: 0,
    philanthropyPercent: 0,
    taxJurisdiction: s.taxJurisdiction === "custom" ? "custom" : s.taxJurisdiction,
    taxResidences: [s.taxJurisdiction === "custom" ? "us" : s.taxJurisdiction],
    taxResidenceWeights: { [s.taxJurisdiction === "custom" ? "us" : s.taxJurisdiction]: 1 },
    doubleTaxRelief: 0,
    taxInterest: s.interestTaxRate,
    taxDividends: s.dividendTaxRate,
    taxRealizedGains: s.capitalGainsTaxRate,
    holdingCompanyStructure: {
      publicDividendSource: s.taxJurisdiction === "germany" ? "germany" : "us",
      privateDividendSource: "us",
      usOwnershipPct: 0,
      germanOwnershipPct: 0,
      isVermoegensverwaltend: false,
      germanTradeTaxRate: 0,
      usCorporateTaxRate: 0.21,
    },
    assetAlloc: { public: s.equityWeight * 100, private: 0, cash: s.cashWeight * 100 },
    assetReturn: { public: s.equityReturn, private: 0, cash: s.cashReturn },
    assetVol: { public: s.equityVolatility, private: 0, cash: s.cashVolatility },
    publicDivYield: s.dividendYield,
    publicRealizationRate: 0.25,
    privateRealizationRate: 0,
    privCommit: { enabled: false, totalCommitment: 0, callYears: 0, distLagYears: 0, distYears: 0, distMultiple: 0 },
    runMonteCarlo: s.runMonteCarlo,
    numPaths: s.numPaths,
    oneOffs: [],
    desiredTerminalWealth: s.desiredTerminalWealth,
    lifetimeSpendingTotal: 0,
    realMode: false,
  };
}

Note: The exact field names and structure must match the existing Params type in lib/types.ts. Verify against the actual type definition during implementation and adjust as needed.

Step 3: Run existing tests to make sure nothing breaks

Run: cd whealthy && npx vitest run Expected: All existing tests pass (we haven’t touched engine files).

Step 4: Commit

git add src/lib/simplified-types.ts src/lib/simplified-defaults.ts
git commit -m "feat: add simplified params interface with mapping to full engine params"

Task 3: Simplified Zustand Store

Files: - Create: whealthy/src/store/useSimplifiedStore.ts

Step 1: Write the store

New Zustand store using SimplifiedParams. Persists to localStorage under a new key ("whealthy-v2-params"). Provides setParam, updateParams, reset. No JSON import/export, no presets, no one-offs.

import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import { SimplifiedParams } from "@/lib/simplified-types";
import { DEFAULT_SIMPLIFIED_PARAMS } from "@/lib/simplified-defaults";

type SimplifiedStore = {
  params: SimplifiedParams;
  setParam: <K extends keyof SimplifiedParams>(key: K, value: SimplifiedParams[K]) => void;
  updateParams: (patch: Partial<SimplifiedParams>) => void;
  reset: () => void;
};

export const useSimplifiedStore = create<SimplifiedStore>()(
  persist(
    (set) => ({
      params: DEFAULT_SIMPLIFIED_PARAMS,
      setParam: (key, value) =>
        set((state) => ({ params: { ...state.params, [key]: value } })),
      updateParams: (patch) =>
        set((state) => ({ params: { ...state.params, ...patch } })),
      reset: () => set({ params: DEFAULT_SIMPLIFIED_PARAMS }),
    }),
    {
      name: "whealthy-v2-params",
      storage: createJSONStorage(() => localStorage),
    }
  )
);

Step 2: Commit

git add src/store/useSimplifiedStore.ts
git commit -m "feat: add simplified Zustand store with localStorage persistence"

Task 4: Hero Component — The Answer + Editable Sentence

Files: - Create: whealthy/src/components/hero.tsx

Step 1: Build the Hero component

The hero is the centerpiece. It shows: - The big number (rainbow shimmer when healthy, opal when neutral, coral when concerning) - The editable sentence with inline number inputs - The mode toggle (reverse ↔ forward)

Props:

type HeroProps = {
  mode: "forward" | "reverse";
  answer: number;          // the computed answer (required wealth or ruin age)
  answerLabel: string;     // "You need" or "Your money lasts until age"
  currency: string;
  currentAge: number;
  deathAge: number;
  annualSpending: number;
  desiredTerminalWealth: number;
  startWealth: number;
  ruinProbability?: number; // from Monte Carlo, drives visual state
  onModeChange: (mode: "forward" | "reverse") => void;
  onParamChange: <K extends keyof SimplifiedParams>(key: K, value: SimplifiedParams[K]) => void;
};

The hero number uses font-mono (JetBrains Mono) at 4rem/64px bold. Apply .rainbow-shimmer class when ruinProbability is undefined or < 0.1, text-[--ul-accent] when 0.1–0.3, text-[--wh-coral] when > 0.3.

Inline editable values: render as <span> with contentEditable or as a slim <input type="number"> styled to look like text (no border, underline on hover, font-mono). The sentence reads:

Reverse mode: “I’m [35], I want to die at [85], I spend [$80,000]/year, and I want [$500,000] left.”

Forward mode: “I’m [35] with [$1,000,000], I spend [$80,000]/year, and I want to know what happens by age [85].”

Mode toggle: small pill toggle below the sentence, text-sm, stone-500 text, opal highlight on active side.

Step 2: Commit

git add src/components/hero.tsx
git commit -m "feat: add Hero component with answer-first display and editable sentence"

Task 5: Key Stats Strip

Files: - Create: whealthy/src/components/key-stats-strip.tsx

Step 1: Build the KeyStatsStrip component

Horizontal row of 3-4 stat cards between hero and charts. Each stat: JetBrains Mono number + Outfit label + optional (i) tooltip.

Stats shown depend on mode: - Reverse: Required wealth (hero handles this), total lifetime spending, total lifetime taxes, effective annual return - Forward: Terminal wealth or ruin age, total lifetime spending, total lifetime taxes, effective annual return - If Monte Carlo on: Ruin probability replaces one stat

Each stat is a <div> with font-mono text-xl font-bold for the number and text-sm text-[--ul-text-secondary] for the label.

Step 2: Commit

git add src/components/key-stats-strip.tsx
git commit -m "feat: add KeyStatsStrip component"

Task 6: Wealth Timeline Chart

Files: - Create: whealthy/src/components/wealth-timeline.tsx

Step 1: Build the WealthTimeline chart

Recharts ComposedChart (or AreaChart) that handles both deterministic and Monte Carlo rendering in one component.

Props:

type WealthTimelineProps = {
  currency: string;
  deterministicData: { age: number; wealth: number }[];
  monteCarloData?: { age: number; p5: number; p25: number; p50: number; p75: number; p95: number }[];
  targetWealth?: number;  // horizontal dashed line (reverse mode)
  showMonteCarlo: boolean;
};

Deterministic mode: Single <Area> with opal stroke and translucent opal fill (fill="var(--ul-opal-400)", fillOpacity={0.1}).

Monte Carlo mode: Layered <Area> components for each percentile band. p5/p95 at 0.05 opacity, p25/p75 at 0.1, p50 as solid opal line. All fills use opal color at varying opacities.

Target line: <ReferenceLine> with dashed stroke at targetWealth value (or at 0 in forward mode).

Styling: Axes in JetBrains Mono (fontFamily: "var(--ul-font-mono)"), gridlines in var(--ul-stone-200), tooltip with warm stone background.

Step 2: Commit

git add src/components/wealth-timeline.tsx
git commit -m "feat: add WealthTimeline chart with deterministic and Monte Carlo modes"

Task 7: Breakdown Chart

Files: - Create: whealthy/src/components/breakdown-chart.tsx

Step 1: Build the BreakdownChart

Recharts AreaChart showing cumulative composition of wealth over time. Stacked areas for: returns (opal), starting wealth carry-forward (gold), spending drain (stone), taxes drain (coral).

Props:

type BreakdownChartProps = {
  currency: string;
  data: {
    age: number;
    cumulativeReturns: number;
    cumulativeSpending: number;
    cumulativeTaxes: number;
    wealth: number;
  }[];
};

The data needs to be derived from SimulationRow[] — sum up returns, spending, and taxes year over year. Create a helper function deriveBreakdownData(rows: SimulationRow[]) in this file.

Step 2: Commit

git add src/components/breakdown-chart.tsx
git commit -m "feat: add BreakdownChart showing cumulative returns/spending/taxes"

Task 8: Tooltip Component

Files: - Create: whealthy/src/components/info-tooltip.tsx

Step 1: Build the InfoTooltip component

Wraps Radix Tooltip. Shows a small (i) icon in text-[--ul-text-muted] that shows a popover on hover/tap.

type InfoTooltipProps = {
  content: string;
};

Renders: <TooltipProvider><Tooltip><TooltipTrigger> (the (i) icon using lucide-react Info icon, 14px) → <TooltipContent> (warm stone bg, text-sm, max-w-xs, var(--ul-bg-surface) background).

This is used throughout the assumptions section. Keep it simple.

Step 2: Commit

git add src/components/info-tooltip.tsx
git commit -m "feat: add InfoTooltip component for inline explainers"

Task 9: Assumptions Section — Returns Card

Files: - Create: whealthy/src/components/assumptions/returns-card.tsx - Create: whealthy/src/components/assumptions/assumption-card.tsx

Step 1: Build the shared AssumptionCard wrapper

A collapsible card component. Closed by default. Click header to expand. Flat white background, 1px var(--ul-border) border, no shadow. Smooth max-height or grid-rows transition.

type AssumptionCardProps = {
  title: string;
  children: React.ReactNode;
  defaultOpen?: boolean;
};

Step 2: Build the ReturnsCard

Shows asset allocation (equity % / cash %) and expected returns with historical defaults. Each input is a slider + number display. Include (i) tooltips explaining: - What equity returns mean historically (~7% nominal long-term average for diversified stocks) - What cash returns mean (~2% nominal, savings/bonds) - What allocation means (how your money is split) - What dividend yield means - What volatility means (for Monte Carlo)

Step 3: Commit

git add src/components/assumptions/
git commit -m "feat: add AssumptionCard wrapper and ReturnsCard"

Task 10: Assumptions Section — Tax Card

Files: - Create: whealthy/src/components/assumptions/tax-card.tsx

Step 1: Build the TaxCard

Top-level: jurisdiction picker (dropdown: US, Germany, UK, Custom). When a jurisdiction is selected, auto-fills the tax rates from taxProfiles.ts.

Simple view: single “effective tax rate” display (read-only, computed as weighted average of the three rates).

Expandable detail: three separate rate inputs — dividend tax, capital gains tax, interest tax. Editable when jurisdiction is “custom”, read-only (with override option) for preset jurisdictions.

Tooltips explaining: - What dividend tax is (tax on income from stock dividends) - What capital gains tax is (tax on profit when you sell investments) - What interest tax is (tax on income from cash/bonds) - What each jurisdiction assumes

Step 2: Commit

git add src/components/assumptions/tax-card.tsx
git commit -m "feat: add TaxCard with jurisdiction picker and rate breakdown"

Task 11: Assumptions Section — Spending & Monte Carlo Cards

Files: - Create: whealthy/src/components/assumptions/spending-card.tsx - Create: whealthy/src/components/assumptions/monte-carlo-card.tsx

Step 1: Build SpendingCard

Spending rule selector (fixed / % of wealth / guardrails). Each mode shows relevant inputs. Fixed shows inflation rate. % of wealth shows the percentage. Guardrails shows target %, min change, max change.

Tooltips explaining each mode and inflation.

Step 2: Build MonteCarloCard

Toggle switch to enable/disable. When enabled: slider for number of simulations (100–2,000). Brief explanation of what Monte Carlo does (“runs thousands of scenarios with randomized returns to show the range of possible outcomes”).

Ruin probability stat shown inline when enabled.

Step 3: Commit

git add src/components/assumptions/spending-card.tsx src/components/assumptions/monte-carlo-card.tsx
git commit -m "feat: add SpendingCard and MonteCarloCard assumption panels"

Task 12: Main Page — Wire Everything Together

Files: - Rewrite: whealthy/src/app/page.tsx

Step 1: Build the new page

Replace the entire page.tsx with the new layout. The page orchestrates:

  1. Read params from useSimplifiedStore
  2. Debounce params (150ms) via useDebouncedValue
  3. Run simulation via useTransition — call toFullParams() then simulateDeterministic() / calculateReverse() / simulateMonteCarlo() as appropriate
  4. Render the three zones:
<main className="max-w-5xl mx-auto px-6 py-12">
  {/* Zone 1: Hero */}
  <Hero ... />

  {/* Zone 2: Visualization */}
  <KeyStatsStrip ... />
  <div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-8">
    <WealthTimeline ... />
    <BreakdownChart ... />
  </div>

  {/* Zone 3: Assumptions */}
  <section className="mt-12 grid grid-cols-1 md:grid-cols-2 gap-4">
    <ReturnsCard ... />
    <TaxCard ... />
    <SpendingCard ... />
    <MonteCarloCard ... />
  </section>

  {/* Footer */}
  <footer className="mt-16 text-center">
    <unicorn-footer></unicorn-footer>
  </footer>
</main>

Step 2: Verify it builds

Run: cd whealthy && npm run build Expected: Static export succeeds.

Step 3: Commit

git add src/app/page.tsx
git commit -m "feat: wire up answer-first page layout with all components"

Task 13: Delete Unused Components

Files: - Delete: whealthy/src/components/params-form.tsx - Delete: whealthy/src/components/deterministic-chart.tsx - Delete: whealthy/src/components/monte-carlo-chart.tsx - Delete: whealthy/src/components/key-stats.tsx - Delete: whealthy/src/components/results-table.tsx - Delete: whealthy/src/components/events-table.tsx - Delete: whealthy/src/components/persist-controls.tsx - Delete: whealthy/src/components/__tests__/ (snapshot tests for old charts) - Keep: whealthy/src/components/ui/ (button, input, label, switch, tabs, tooltip — still used)

Step 1: Remove old components

Delete the files listed above. Keep ui/ primitives.

Step 2: Remove old store if no longer imported

Check if anything still imports useParamsStore. If not, delete whealthy/src/store/useParamsStore.ts. Keep it if the engine tests reference it.

Step 3: Verify build

Run: cd whealthy && npm run build Expected: Builds cleanly with no import errors.

Step 4: Run tests

Run: cd whealthy && npx vitest run Expected: Engine tests still pass. Old snapshot tests (if deleted) no longer run.

Step 5: Commit

git add -A
git commit -m "chore: remove old UI components replaced by answer-first design"

Files: - Modify: whealthy/src/app/layout.tsx

Step 1: Add the footer

Either import the <unicorn-footer> web component (load the script from the design system) or build a simple React footer component that replicates it: “Made in Unicorn.Land” text with the rainbow shimmer on the link, Outfit uppercase at 0.75rem.

The simplest approach is a React component since we’re already in React. Use the .rainbow-shimmer class defined in globals.css.

function Footer() {
  return (
    <footer className="mt-16 py-8 text-center">
      <span className="text-xs uppercase tracking-widest text-[--ul-text-muted]">
        Made in{" "}
        <a href="https://unicorn.land" className="rainbow-shimmer font-semibold">
          Unicorn.Land
        </a>
      </span>
    </footer>
  );
}

Add <Footer /> to the layout or page.

Step 2: Commit

git add src/app/layout.tsx
git commit -m "feat: add Made in Unicorn.Land footer with rainbow shimmer"

Task 15: Deployment — Cloudflare Pages

Files: - Modify: whealthy/next.config.ts - Create: whealthy/wrangler.toml (if using Wrangler) or configure via Cloudflare dashboard

Step 1: Update next.config.ts

Remove the GitHub Pages conditional (basePath, assetPrefix for GITHUB_PAGES). Keep static export (output: "export"). The out/ directory is the publish root for Cloudflare Pages.

Step 2: Configure Cloudflare Pages

Create a simple config or document the dashboard setup: - Project name: whealthy - Build command: npm run build - Build output: out - Custom domain: whealthy.unicorn.land - Environment variables: none needed (all client-side)

Step 3: Verify build output

Run: cd whealthy && npm run build && ls out/ Expected: Static HTML + assets in out/ directory.

Step 4: Commit

git add next.config.ts wrangler.toml
git commit -m "feat: configure Cloudflare Pages deployment for whealthy.unicorn.land"

Task 16: Responsive Polish & Dark Mode Verification

Files: - Modify: various component files as needed

Step 1: Test responsive breakpoints

Run npm run dev and test at: - Mobile (375px): hero number should be ~2.5rem, single column stack, charts full-width - Tablet (768px): two-column assumptions grid - Desktop (1024px+): full layout — charts side by side, assumptions 2-col

Step 2: Test dark mode

Toggle system preference to dark. Verify: - Background switches to stone-900 - Text switches to stone-100 - Cards use stone-800 background - Charts use appropriate dark gridlines - Rainbow shimmer still works on dark background - Opal accent is still visible

Step 3: Fix any issues found

Adjust Tailwind classes and CSS variables as needed.

Step 4: Commit

git add -A
git commit -m "fix: responsive layout and dark mode polish"

Task 17: Tooltip Content — Write All Explainers

Files: - Create: whealthy/src/lib/tooltips.ts

Step 1: Write tooltip content

Create a single file with all tooltip strings. This keeps copy centralized and easy to update.

export const TOOLTIPS = {
  // Returns
  equityReturn: "The average annual return you expect from stocks. Historically, a diversified stock portfolio has returned about 7% per year after inflation.",
  cashReturn: "The return on cash and bonds. Typically 1-3% per year — safer but lower growth.",
  equityWeight: "What percentage of your money is in stocks vs. cash. Higher stock allocation means higher expected returns but more volatility.",
  dividendYield: "The portion of stock returns paid as dividends each year. Dividends are taxed differently than capital gains in most jurisdictions.",
  volatility: "How much returns vary year to year. Higher volatility means your actual returns could differ significantly from the average. Used in Monte Carlo simulations.",

  // Tax
  dividendTax: "Tax on income received from stock dividends. Rates vary by country — typically 15-30%.",
  capitalGainsTax: "Tax on profit when investments are sold. Only applies to realized gains — unrealized appreciation is not taxed.",
  interestTax: "Tax on income from cash, savings, and bonds.",
  jurisdiction: "Select your tax jurisdiction to auto-fill rates. Choose 'Custom' to set your own rates.",

  // Spending
  fixedSpending: "You spend a fixed amount each year, adjusted for inflation. Simple and predictable.",
  pctWealthSpending: "You spend a percentage of your current wealth each year. Spending rises and falls with your portfolio — you'll never run out, but spending may drop significantly in bad years.",
  guardrailsSpending: "A hybrid approach: target a percentage of wealth, but limit how much your spending can change year-to-year. Smooths out the volatility of percentage-based spending.",
  inflation: "How much your spending increases each year due to rising prices. Historical average is about 2-3%.",

  // Monte Carlo
  monteCarlo: "Instead of assuming fixed returns every year, Monte Carlo runs hundreds of scenarios with randomized returns. This shows you the range of possible outcomes — from lucky to unlucky.",
  ruinProbability: "The percentage of simulated scenarios where your money runs out before you die. Lower is better.",
  numPaths: "How many random scenarios to simulate. More paths = more accurate probability estimates, but slower to compute.",
} as const;

Step 2: Wire tooltips into assumption cards

Update each assumption card component to import from TOOLTIPS and pass to <InfoTooltip>.

Step 3: Commit

git add src/lib/tooltips.ts src/components/assumptions/
git commit -m "feat: add tooltip explainer content for all assumptions"

Task 18: Final Integration Test

Step 1: Full build

Run: cd whealthy && npm run build Expected: Clean static export, no errors.

Step 2: Run all tests

Run: cd whealthy && npx vitest run Expected: All engine tests pass.

Step 3: Manual smoke test

Run: cd whealthy && npm run dev

Verify: - [ ] Page loads with reverse mode default - [ ] Hero shows a computed “You need $X” number - [ ] Editing sentence values updates the answer - [ ] Toggling to forward mode changes the sentence and answer - [ ] Wealth timeline chart renders - [ ] Breakdown chart renders - [ ] Key stats strip shows 4 stats - [ ] Each assumption card opens/closes - [ ] Changing return rates updates the answer - [ ] Changing tax jurisdiction updates rates - [ ] Enabling Monte Carlo shows probability bands - [ ] Dark mode works - [ ] Mobile layout stacks correctly - [ ] Rainbow shimmer on hero number when healthy - [ ] “Made in Unicorn.Land” footer shows rainbow shimmer - [ ] Page persists state to localStorage (reload preserves inputs)

Step 4: Commit any final fixes

git add -A
git commit -m "fix: final integration polish"