Survival Budget
Living Interaction Design Specification
A privacy-first iOS budgeting app that organizes expenses into four priority tiers so you always know what to cut when money gets tight.
Platform
iOS (Capacitor)
Stack
React + Vite + Tailwind
Data
localStorage only
Updated
September 2026
System Specification
This living specification documents the interaction patterns, design constraints, and system decisions that govern how the product behaves.
It serves as the reference for design tokens, component patterns, and interaction rules used across the product.
Product Philosophy
The thinking behind the design decisions.
The Core Insight
Your budget builds from the ground up. Survival expenses, housing, food, utilities, are the foundation. Once those are covered, you move up through Important, Quality of Life, and Future Building. When money gets tight, you adjust from the top. The foundation stays protected.
Design Principles
Survival is non-negotiable
The foundation model. Survival gets funded before anything above it. When you need to adjust, you start from the top. The foundation is the last thing you touch.
No red, no shame
Brand-orange for warnings instead of red. The app never uses language or color that implies failure. Over-budget states say "Over Allocated", not "Over budget" in red. Financial anxiety is real; the UI shouldn't add to it.
Your data, your device
All data lives in localStorage. No server, no sync, no analytics, no tracking. The App Store privacy label says "Data Not Collected." Financial data is sensitive; the simplest security is not having it.
Permission to live
The Quality of Life tier exists because budgeting apps that only restrict spending are demoralizing. Netflix, a gym membership, a coffee habit. These aren't frivolous. They're permission to live while being responsible.
The Priority Pyramid
Rendered with the app's design tokens: the same fills, bar widths and icons as the onboarding welcome screen. Each tier narrows as it rises, so the base reads as the widest and most protected.
Cover the base first. Adjust from the top.
Language Standard
Canonical phrasing for the allocation model. Applied consistently across the app UI, this spec, and all documentation.
Canonical One-Liner
"Cover the base first. Adjust from the top."
Used as the primary tagline for the allocation model in the app UI and this spec.
Structural Rule
The pyramid is structural, not directional. Survival sits at the base because it is the foundation: the widest bar, the last thing to cut. The system enforces:
- Bottom-up allocation: funding starts at Survival and proceeds upward
- Top-down reduction: when income falls short, Future Building is cut first
Approved Phrasing
- "Cover the base first"
- "Builds from the ground up"
- "Foundation model"
- "Allocation proceeds from lowest tier to highest"
- "Adjust from the top"
- "Cut from the top down"
Disallowed Phrasing
"Money flows top to bottom""Top-to-bottom"(as a directional description of allocation)"Waterfall"(as the model name)"Cut from the bottom up"
Spec Naming Convention
Model Name
Tiered Allocation Model
Not
Income Waterfall Calculation
In code, the variable remaining decrements per tier. This standard governs how the model is described, not how it executes.
Design Tokens
Every token rendered as a live specimen. Source of truth: tailwind.config.js + src/index.css
Colors
Brand Orange
#c94117
Primary (CTAs, brand)
#db4e1f
Light (gradients)
#b33710
Dark (hover states)
#fff7ed
orange-50 (backgrounds)
#ffedd5
orange-100 (subtle fills)
Priority Tiers
Survival
Icon #2563eb / bg #dbeafe
Important
Icon #c94117 / bg #ffedd5
Quality of Life
Icon #db2777 / bg #fce7f3
Future Building
Icon #16a34a / bg #dcfce7
Tier icons use the 600 shade on a 100 background (BudgetApp.tsx). Tier badges and labels use the 700 shade on the same background (ExpenseCategories.tsx).
Semantic
Success
#16a34a, 4.5:1
Warning
#ea580c, 4.5:1
Error
#dc2626, 4.5:1
Info
#2563eb, 4.5:1
Neutrals (WCAG contrast on white)
gray-900 #111827, 15.68:1, Primary text
gray-800 #1f2937, 12.63:1, Secondary text
gray-700 #374151, 9.73:1, Tertiary text
gray-600 #4b5563, 7.05:1, Muted text
gray-500 #6b7280, 4.93:1, Disabled text
gray-400 #9ca3af, 3.17:1, Borders (passes 3:1)
gray-300 #d1d5db, 1.85:1, Decorative only
Typography
Typeface
Inter, one family for every role
Headings, body, labels and figures all use Inter at 400, 500, 600 and 700. Hierarchy comes from weight, size and space rather than a second face.
Scale in use
Weights: font-medium for labels and body emphasis, font-semibold for titles and actions, font-bold with tracking-tight for headings and figures.
Spacing
4px space-1: spacing-xs
8px space-2: spacing-sm
16px space-4: spacing-md (content padding)
24px space-6: spacing-lg (card padding, component gap)
32px space-8: spacing-xl
48px space-12: spacing-2xl (touch targets)
Content width: max-w-[420px] · Content padding: px-4 py-3 · Card padding: p-6
Elevation (Shadows)
shadow-sm
Cards, inputs, content containers
shadow-md
Income card, summary hero, CTAs
shadow-lg
Modals, overlays, drawer
Borders & Dividers
border-black/5: Soft divider (cards, nav borders)
border-gray-400: Form borders (3.17:1 WCAG)
border-l-4 border-green-500: Covered
border-l-4 border-orange-500: Borderline
Touch Targets
WCAG AAA
iOS minimum
The Button component enforces min-h-[48px] min-w-[48px] on every variant, and nav buttons use the same 48px minimum height. Compact controls (icon buttons, toast actions, inline links) hold a 44px floor, the WCAG 2.5.5 target-size minimum. Nothing interactive ships below 44px. The header app icon is a fixed 36×36px image with rounded-lg (8px) corners (not a tap target).
Z-Index Scale
| Layer | Z-Index | Usage |
|---|---|---|
| Sticky UI | 10 | MobileHeader, MobileNavigation |
| Dropdowns | 20 | Goal card menus, popovers |
| Fixed notices | 30 | AppInstallBanner, OfflineIndicator |
| Backdrops | 40 | Modal/drawer backgrounds |
| Modals | 50 | BottomSheet, ConfirmationModal, Toast |
| Nested modals | 60 | AddMoneyModal (over other modals) |
| ExpenseModal | 9999 | Full-screen expense form |
| Skip link | 100 | Keyboard accessibility |
Component Library
Live specimens rendered with the app's actual design tokens.
Button
Source: src/components/ui/Button.tsx · All variants enforce 48x48px minimum touch target.
Variants
Sizes
States
Full Width
Card Patterns
Standard Card
Card Title
Body text inside the standard card pattern.
bg-white rounded-2xl shadow-sm border border-black/5 p-6
Gradient Card (Income Hero)
Monthly Income
$4,500
Affordability Cards
Survival
Covered: $450 remaining
Important
Near Limit
Info Callout
Tip: Start with your survival expenses. Housing, food, utilities, transport.
Mobile Header
Source: src/components/Mobile/MobileHeader.tsx
With tagline (Home)
Survival Budget
Priority-based budgeting
Without tagline (other screens)
Settings
Header Structural Baseline
A) Header Top Baseline
- Desktop web top inset:
:root { --app-header-top: 1.5rem }(24px) - iOS standalone/fullscreen:
safe-topusesmax(44px, calc(env(safe-area-inset-top) + 16px)) - Desktop web does NOT use a forced 44px minimum
B) Two-Line Reservation Rule
- Header always reserves two text lines (title + tagline slot)
- Title aligns to top of icon · Tagline aligns to bottom of icon
- If tagline is absent:
\u00A0placeholder,aria-hidden,select-none - No conditional removal of the tagline element is allowed
- Title uses
leading-noneto eliminate half-leading gap above the text glyph - Text wrapper uses
min-h-[36px]to match icon height exactly
C) Optical Alignment
- Outer row:
items-start(top-aligns icon/text group with action buttons) - Inner left group:
items-start(aligns icon top with title block top) - Icon: 36×36px with
rounded-lg(8px corners)
D) Sticky Policy
- Sticky: budget, categories, history
- Non-sticky: home, savings, settings, privacy (default)
E) Chrome Rules
- No border-bottom · No shadow · No backdrop blur
- Background: opaque
bg-white
bg-white safe-top · Conditionally sticky top-0 z-20 on budget/categories/history · No shadow, border, or blur
Bottom Navigation
Source: src/components/Mobile/MobileNavigation.tsx · 4 tabs, always visible.
fixed bottom-0 left-0 right-0 bg-white border-t border-black/5 z-10 · Active: text-brand-orange · Inactive: text-gray-500
Empty State
Source: src/components/EmptyState.tsx · Pattern: icon circle + heading + supportive text + CTA + help text.
No savings goals yet
Set a goal and watch your progress grow. Whether it's an emergency fund or a vacation, we'll help you get there.
Tracking goals helps you stay motivated and see real progress.
Tone: never blaming, always suggesting next step.
Toast
"Netflix" deleted
Fixed bottom-20, z-50 · slide-up animation · auto-dismiss 5s · white underlined action on gray-800
Bottom Sheet
Source: src/components/Mobile/BottomSheet.tsx
Survival Expenses
Rent
$2,400
Groceries
$600
Drag handle + swipe-to-close (100px threshold) · createPortal to body · Backdrop z-40, sheet z-50 · Title border: border-gray-400
Expense Modal
Source: src/components/Mobile/ExpenseModal.tsx
Add Expense
Full-screen on mobile · createPortal to body · z-[9999] · focus trap · Escape to close
Toggle Switch
Interactive Prototype
A clickable phone frame using the app's real design tokens. Navigate with the bottom tabs or tap interactive elements.
Survival Budget
Home
Screen Specifications
Each screen documented with purpose, content, interactions, and edge cases.
Onboarding (4 steps)
Purpose: First-run experience introducing the priority model and collecting survival data.
Step 1: Welcome
Priority pyramid visualization, "Survival is non-negotiable" headline, two benefits with checkmarks, Get Started (primary) and Skip to Budget (secondary) CTAs.
Step 2: Income & Survival Setup
Monthly income input, three survival expense fields (rent, groceries, utilities). All inputs enforce 16px minimum font to prevent iOS zoom.
Step 3: Budget Snapshot
Summary card showing entered data + calculated remaining. "Next" advances to Step 4, Widget Setup. Source: BudgetSnapshot.tsx. Sets onboardingCompleted = true in localStorage.
Step 4: Widget Setup
Home-screen widget instructions: long-press until icons jiggle, tap + or Edit in the top-left, search for "Survival Budget", choose a size and tap Add Widget. Source: WidgetSetupScreen.tsx.
Edge cases: Skip with empty data (income defaults to 0). User returns after partial onboarding (checked via localStorage flag). Skip triggers ConfirmationModal.
Home
Purpose: Landing screen for returning users, at-a-glance priority status.
Entry: Bottom nav "Home" tab (default after onboarding).
Content: Status card carrying the getSurvivalStatus() label and description, with Monthly income and Survival covered rows. Flexible-money card: "You have / $X / to work with". "After Quality of Life" card, labelled "Balanced" under $100 and "Available to save or enjoy" at or above it. "What's covered" list. Source: SummaryDashboard.tsx.
State variations: New user (no priorities) shows "Set your priorities" guidance card. Returning user (>5 min session gap) reloads from localStorage.
Budget (Core Screen)
Purpose: Primary workspace for priority-based expense allocation.
Content: Income gradient hero card (income / allocated / remaining). Four priority accordion cards (expandable). SpendingPlan callout.
SpendingPlan card: Persistent callout below priority cards. Line 1: "This month: Focus on Priorities {count}", count reflects tiers that have expenses entered AND are affordable (funded + affordable). Line 2 (conditional: shown only when income surplus > 0 and a next tier exists): "You have $X unallocated. Consider adding {Next Tier} expenses." Special case when count is 0 (no expenses entered): "Start by adding {Next Tier} expenses to build your plan."
Interactions: Toggle priority expand. Add expense (opens ExpenseModal). Adjust expenses (opens BottomSheet). Edit income (opens small BottomSheet). Toggle expense enabled/disabled. Swipe to edit/delete.
Calculation: Allocation proceeds from lowest tier to highest. Remaining = income minus sum of enabled expenses per tier. Reductions proceed from highest tier downward. Affordability: 100% = green (Covered), ≥50% = borderline orange (Near Limit), <50% = orange (Over Allocated).
Overlay management: Sheet closes before modal opens. Modal close returns to sheet via returnToSheetAfterModal flag (50ms delay).
Savings Goals
Purpose: Create and track savings goals with progress visualization.
Content: Add button (FAB, top-right). Summary gradient card (active/completed/total). Goal cards with progress bars.
Interactions: Add goal (AddGoalModal). Add money (AddMoneyModal, z-60). Edit goal. Delete goal with confirmation.
Budget sync: Saving or toggling a budget expense linked to a savings goal updates that goal's currentAmount in real time via updateSavingsGoalFromBudget() in BudgetApp.
Settings
Data export (JSON/CSV), Reset App (with ConfirmationModal), Privacy link, build version. Reset clears all localStorage and returns to onboarding.
Categories
Cross-cutting view of expenses by category. Access via hamburger menu only (not bottom nav). Groups across all priority tiers.
Budget History
View and restore past budget snapshots. Create snapshot, view details, restore (overwrites current). Hamburger menu only.
Privacy
Privacy policy for App Store compliance. Standalone at /privacy for web. In-app screen for native. Hamburger menu only.
Data Architecture
All data lives in localStorage. No server, no sync, no analytics.
Storage Keys
| Key | Type | Purpose |
|---|---|---|
| onboardingCompleted | string | Routing gate (onboarding vs. home) |
| budgetData | JSON | Income + base survival expenses |
| lastSession | timestamp | Session timeout detection (5 min) |
| budgetHistory | JSON[] | Budget snapshots |
| savingsGoals | JSON[] | Savings goals (flat SavingsGoal[] array) |
| appVersion | string | Data migration version (currently "1.0.0") |
| lastSnapshotDate | string | Date of last budget history snapshot |
| budgetPriorities | JSON | Priority tiers with expenses (stored separately from budgetData) |
Storage Shapes
Exact JSON shapes written to localStorage, verified against read/write boundaries in source.
budgetData
Write: BudgetApp.tsx · Read: BudgetApp.tsx, budgetHistory.ts
budgetPriorities
Write: BudgetApp.tsx · Read: BudgetApp.tsx, App.tsx, budgetHistory.ts
budgetHistory
Write: budgetHistory.ts · Read: budgetHistory.ts
id: string;
date: string; // ISO month (YYYY-MM)
timestamp: number;
income: number;
priorities: {
id: number; name: string;
totalExpenses: number; activeExpenses: number;
expenseCount: number;
isAffordable: boolean; isBorderline: boolean;
}[];
totalSpending: number;
moneyLeft: number;
survivalCovered: boolean;
note?: string;
isManual?: boolean;
}
type BudgetHistory = BudgetSnapshot[];
Core Types
id: number; // 1-4 (Survival → Future)
name: string;
expenses: ExpenseItem[];
isAffordable: boolean;
isBorderline: boolean;
}
type ExpenseItem = {
id: string;
name: string;
amount: number;
enabled: boolean;
category?: ExpenseCategory;
savingsGoalId?: string; // Links to SavingsGoal
isRecurring?: boolean;
}
type SavingsGoal = {
id: string;
name: string;
targetAmount: number;
currentAmount: number;
deadline?: string;
category?: 'emergency' | 'purchase' | 'experience' | 'other';
createdAt: string; // ISO timestamp
updatedAt: string; // ISO timestamp
}
Tiered Allocation Calculation
Income is allocated from the base upward through the four priority tiers. At each level, the remaining budget determines affordability.
for (tier of [Survival, Important, QoL, Future]) {
const tierCost = sum(tier.expenses.filter(e => e.enabled));
if (remaining >= tierCost)
tier.isAffordable = true; // Green: "Covered"
else if (remaining >= tierCost * 0.5)
tier.isBorderline = true; // Orange: "Near Limit"
else
// Orange: "Over Allocated"
remaining -= tierCost;
}
Borderline threshold: 50% (from constants.ts AFFORDABILITY_THRESHOLD).
Interaction Patterns
Recurring patterns that appear across multiple screens.
Modal Pattern
All modals use createPortal(ui, document.body) for reliable layering on iOS Safari.
Backdrop
z-40, semi-transparent black
Content
z-50 (z-9999 for ExpenseModal)
Focus
Trapped within modal boundaries
Dismiss
Escape key, backdrop tap, close button
Overlay Conflict Resolution
When a modal needs to open from within a bottom sheet:
- Close the bottom sheet
- Set
returnToSheetAfterModal = true - Open the modal (50ms delay)
- On modal close: if flag is set, reopen sheet after 50ms
Haptic Feedback
Light
10ms
Navigation, toggles, add
Medium
15ms
Delete confirm, refresh
Heavy
[10,10,20]ms
Destructive actions
Graceful fallback: try/catch wrapper, no-op on unsupported devices.
Pull to Refresh
Threshold
60px pull distance
Resistance
0.3 factor
Spinner
Brand-orange RefreshCw icon
Delay
1.5s perceived loading
Available on: Home, Categories, History, Settings. Not on Budget or Savings (in-place editing).
Swipe to Reveal
Deadzone
8px before direction lock
Snap threshold
75px (150px max offset)
Actions revealed
Edit (blue) + Delete (orange)
Haptic
Feedback at snap threshold
Empty States
Pattern: icon circle + heading + supportive description + CTA button + help text.
Tone: Never blaming, always suggesting next step. Examples: "No income added yet. Even if it's uncertain, let's put something down." / "No savings goals yet. Set a goal and watch your progress grow."
Affordability Indicators
Covered: remaining ≥ tier cost
Near Limit: remaining ≥ 50% of tier cost
Over Allocated: remaining < 50% of tier cost
Rule: Affordability indicators never use red. The second and third states both use border-brand-orange, intentionally. No shame in the system. Red is reserved for destructive actions only (danger Button variant, e.g. "Reset App") and form validation errors.
Render: each priority card in BudgetApp.tsx takes its left border from priority.isAffordable ? 'border-green-500' : 'border-brand-orange'.
Decision Log
Architectural decisions with rationale. The "why" behind the product.
useState navigation over React Router
Context
Mobile app with 8 screens, deployed to iOS via Capacitor.
Decision
useState<Screen> with manual directional transitions.
Rationale
Simpler mental model. No URL routing needed for native. Enables directional slide transitions based on screen order index. Avoids Capacitor routing conflicts.
Trade-offs
No deep linking. No browser back button. Manual scroll restoration needed.
localStorage as sole data store
Context
Privacy-first budgeting app handling sensitive financial data.
Decision
All data in localStorage. No server, no sync, no analytics.
Rationale
Zero data exposure risk. Works offline. No server costs. App Store privacy compliance: "Data Not Collected." The simplest security is not having the data.
Trade-offs
No cross-device sync. Data loss on clear/uninstall. Limited storage (~5MB). Mitigated with JSON/CSV export in Settings.
4 fixed priority tiers (not custom categories)
Context
Needed a simple mental model for budget organization.
Decision
Survival > Important > Quality of Life > Future Building. Fixed, not configurable.
Rationale
Reduces decision fatigue. Creates a clear cut order: always adjust from the top down. Prevents analysis paralysis. Guides users toward sustainable budgeting without requiring financial literacy.
Trade-offs
Less flexibility, accepted deliberately. Tier names and their order are the same for every user, which keeps the cut order unambiguous even when someone would rank an expense differently.
Brand orange for warnings (never red)
Context
Budgeting apps create anxiety. Red implies failure and shame.
Decision
Use brand-orange (#c94117) for all warning/alert states. Never red in the budget UI.
Rationale
Orange signals "attention needed" without shame. Aligns with brand identity. Reduces financial anxiety: the app is a tool, not a judge.
Trade-offs
Reduced urgency signaling. May not match user expectations from banking apps. Red is reserved for danger variant buttons only (destructive actions like "Reset App").
createPortal for all overlays
Context
Fixed positioning inside scrollable containers is unreliable on iOS Safari.
Decision
All modals and sheets use createPortal(ui, document.body).
Rationale
Bypasses stacking context issues. Ensures overlays are truly viewport-fixed. Prevents iOS Safari scroll bugs where fixed elements move with page content.
Trade-offs
Need manual focus management. Z-index coordination across portalled elements. Overlay conflict resolution pattern required (sheet → modal → sheet).
Haptic feedback on every interaction
Context
Native iOS app needs premium tactile feel to compete with native apps.
Decision
Haptic trigger on all taps, toggles, swipes, and transitions.
Rationale
Bridges the gap between web and native feel. Provides confirmation without visual noise. Three intensity levels (light/medium/heavy) map to action significance.
Trade-offs
Minimal battery impact. Requires graceful fallback on web/unsupported devices.
50% borderline affordability threshold
Context
Binary affordable/not-affordable felt too harsh and uninformative.
Decision
Three states: affordable (100%), borderline (≥50%), needs adjustment (<50%).
Rationale
"Almost covered" is meaningfully different from "nowhere close." The borderline state acknowledges partial progress and encourages small adjustments rather than wholesale cuts.
Trade-offs
Additional complexity in UI messaging. Users need to understand three states instead of two.