Visual design spec and component catalog for PlaidBar.
| Token | SwiftUI | Meaning |
|---|---|---|
income |
.green |
Money in (+$3,200) |
expense |
.primary |
Money out ($67.42) |
creditDebt |
.red |
Credit card balances owed |
available |
.green |
Available credit, positive balances |
warning |
.orange |
Utilization above threshold |
positive |
.green |
Gains, good status |
negative |
.red |
Losses, high utilization |
pending |
.orange |
Pending transactions |
sparkline |
.blue |
Balance history sparkline stroke |
brand |
.blue |
App brand color (About view icon, accent) |
brandSecondary |
.orange |
Secondary brand color |
recurring |
.indigo |
Recurring transaction badge background |
| Range | Color | Icon |
|---|---|---|
| 0-29% | Green | checkmark.circle |
| 30-49% | Yellow | exclamationmark.triangle |
| 50-74% | Orange | exclamationmark.triangle.fill |
| 75%+ | Red | xmark.octagon |
Category colors from SpendingCategory.colorHex — fixed hex values for chart segments, legends, and category indicators.
| Category | Display Name | Hex | SF Symbol | Notes |
|---|---|---|---|---|
foodAndDrink |
Food & Drink | #FF6B6B |
fork.knife |
|
transportation |
Transportation | #4ECDC4 |
car.fill |
|
shopping |
Shopping | #45B7D1 |
bag.fill |
|
entertainment |
Entertainment | #96CEB4 |
tv.fill |
|
personalCare |
Personal Care | #FFEAA7 |
heart.fill |
Low dark-mode contrast — see Dark Mode |
healthAndFitness |
Health & Fitness | #DDA0DD |
cross.case.fill |
|
billsAndUtilities |
Bills & Utilities | #98D8C8 |
bolt.fill |
|
homeImprovement |
Home | #F7DC6F |
house.fill |
Low dark-mode contrast — see Dark Mode |
travel |
Travel | #BB8FCE |
airplane |
|
education |
Education | #85C1E9 |
book.fill |
|
subscriptions |
Subscriptions | #F8C471 |
creditcard.fill |
Maps to LOAN_PAYMENTS |
income |
Income | #82E0AA |
arrow.down.circle.fill |
|
transfer |
Transfer In | #AEB6BF |
arrow.left.arrow.right |
|
transferOut |
Transfer Out | #D5DBDB |
arrow.right.circle.fill |
Low dark-mode contrast |
bankFees |
Bank Fees | #E74C3C |
banknote.fill |
|
government |
Government | #5DADE2 |
building.columns.fill |
|
other |
Other | #BDC3C7 |
questionmark.circle.fill |
Low dark-mode contrast |
5 levels, implemented as ViewModifiers in Typography.swift:
| Level | Token | SwiftUI | Used For |
|---|---|---|---|
| Hero | .heroBalance() |
.system(size: 28, weight: .bold, design: .rounded).monospacedDigit() |
Net balance header |
| Title | .sectionTitle() |
.caption.weight(.semibold).textCase(.uppercase) |
BANK ACCOUNTS, CREDIT CARDS |
| Body | system .body |
default | Account names, transaction names |
| Detail | .detailText() |
.caption + .secondary |
Masks, categories, dates |
| Micro | .microText() |
.caption2.weight(.medium) |
Pending badge, percentages |
Usage note:
.callout.weight(.medium)is used for the spending comparison delta text (SpendingView). Not part of the 5-level type scale but used consistently for secondary emphasis.
- Actions use outline variants:
arrow.clockwise,plus.circle,gear - Status uses filled variants:
exclamationmark.triangle.fill,checkmark.circle.fill - Content uses
.regularweight, matched to accompanying text size - Category icons are filled for visual weight in lists (defined per
SpendingCategory)
| Context | Icon | Style |
|---|---|---|
| Add account | plus.circle |
Action (outline) |
| Refresh | arrow.clockwise |
Action (outline) |
| Settings | gear |
Action (outline) |
| Warning | exclamationmark.triangle.fill |
Status (filled) |
| Error | xmark.circle.fill |
Status (filled) |
| Credit owed | creditcard |
Content |
| Search | magnifyingglass |
Content |
8-point grid system via Spacing enum:
| Token | Value | Use |
|---|---|---|
xxs |
2pt | Minimal gaps (label-to-badge vertical) |
xs |
4pt | Tight gaps (icon-to-text) |
sm |
8pt | Standard padding, list item vertical |
md |
12pt | Section spacing, card padding |
lg |
16pt | Horizontal margins, major sections |
xl |
24pt | Hero spacing, modal padding |
rowVertical |
6pt | Row vertical padding (RecurringRow, TransactionRow) |
PlaidBar is a macOS menu bar instrument, so surfaces should feel native,
translucent, and compact rather than like stacked web cards. Shared surface
tokens live in SurfaceTokens and shared modifiers live in
SharedModifiers.swift.
| Token/Modifier | Purpose |
|---|---|
SurfaceTokens.panelFillOpacity |
Default quiet panel fill for dashboard, detail, and recovery surfaces |
SurfaceTokens.insetFillOpacity |
Compact inset controls such as segmented filters and metric pills |
SurfaceTokens.selectedFillOpacity |
Selected account row highlight, backed by a visible accent rail |
SurfaceTokens.panelStrokeOpacity |
Hairline separators around native surfaces |
.nativePanelSurface(...) |
Shared rounded panel treatment with material/fill fallback and optional Liquid Glass enhancement |
.nativeInsetSurface(...) |
Smaller, non-glass inset treatment for controls and dense metric pills |
Liquid Glass is a progressive enhancement only. Apple SwiftUI's Glass.regular
and glassEffect APIs are macOS 26+, while PlaidBar currently supports
macOS 15+. Do not raise the minimum OS for visual polish; use availability-gated
Liquid Glass and keep a SwiftUI material/fill fallback.
Reference: RepoBar menu popover pattern: contribution heatmap header, compact filter bar, dense rows, selected row highlight, and chevron-based drill-in. Use the RepoBar visual language as inspiration, not as literal GitHub UI.
Target anatomy: VStack | compact net-worth header | status strip |
Financial heatmap header (last 365 days, Spend or Net mode) | segmented
filter bar (All, Cash, Credit, Savings, Debt, Status) | summary and
balance context | list of account/card rows | inline selected account/card
detail surface.
| Element | PlaidBar Meaning |
|---|---|
| Heatmap header | Daily spending intensity or net cashflow from transactions, switchable in place. Spend mode uses a GitHub-style green Less/More legend; Net mode uses bidirectional Income/Outflow color keys. |
| Repo row | Account/card row with institution, type, balance, status, and freshness |
| Repo stats | Balance, available credit, utilization, pending count, sync state |
| Selected repo highlight | Selected account/card detail target |
| Submenu/drill-in | Inline account/card detail surface below the selected row |
Account/card row anatomy: status dot or account-type icon | institution + account name | secondary line with type, mask, sync freshness, pending count | trailing primary metric (balance owed/cash balance) | secondary metric (utilization, available credit, or last updated) | chevron.
| State | Behavior |
|---|---|
| Healthy cash account | Green/neutral status, cash balance primary, latest sync secondary |
| Savings account | Cash row with savings label; preserve same density as checking |
| Credit card | Credit balance owed primary, utilization and available credit secondary |
| High utilization | Warning/negative utilization color plus text label, not color alone |
| Degraded item | Warning status and Reconnect in detail surface |
| Selected | Blue/accent highlight matching native menu selection; detail surface opens |
| No data | Keep overview shell and show one compact recovery action |
Anatomy: Institution avatar (28×28 circle, DJB2-hashed color) | Account name (.body) + mask (.detailText()) | Amount (.monospacedDigit) with semantic color | Credit accounts: creditcard icon prefix + utilization badge
Density audit (2026-06-08, T021): Account rows should preserve one shared
two-line rhythm across checking, savings, credit card, loan, investment, and
other account types. The current dashboard row implementation uses one 28pt
leading glyph/status affordance, Spacing.compactRowContentSpacing horizontal
gaps, Spacing.compactRowVerticalPadding vertical padding, a one-line primary
label, a one-line secondary label, a trailing primary amount, a trailing
secondary status/available-credit line, and a chevron. Checking and savings rows
therefore occupy the same height as credit, loan, and other rows; richer credit
metadata is compressed into the trailing secondary line rather than adding a
third text row. Legacy account-list rows keep the same 28pt leading affordance
and two-line text structure, but the dashboard row is the production-density
reference for PR-005 follow-up work.
| Account family | Primary metric | Secondary density rule |
|---|---|---|
| Checking | Cash balance | Type/mask/freshness fit on the subtitle line |
| Savings | Cash balance | Same row height and subtitle rhythm as checking |
| Credit card | Balance owed | Utilization and available credit share one trailing line |
| Loan | Balance owed | Uses the shared debt amount treatment without extra row height |
| Investment/other | Current balance | Uses the same subtitle/status slot as cash rows |
| State | Behavior |
|---|---|
| Default | All elements visible; amount colored by account type |
| Loading (sync in progress) | Shimmer placeholder on amount; avatar and name visible |
| Disconnected (token expired) | Dimmed row (.opacity(0.5)); inline "Reconnect" link; amount shows "—" |
| Error (API failure) | Amount shows "Error" in .secondary; tap row shows error detail |
| Hover | .background(.quaternarySystemFill) highlight on row; cursor: pointer |
Anatomy: Card name + status icon | Progress bar (12pt height, rounded corners, Spacing.sm corner radius) | Balance / limit + available credit + percentage | Font weight increases at warning thresholds
| State | Behavior |
|---|---|
| Default (0-29%) | Green progress fill; checkmark.circle icon; .regular weight |
| Warning (30-49%) | Yellow fill; exclamationmark.triangle icon; .medium weight |
| High (50-74%) | Orange fill; exclamationmark.triangle icon; .semibold weight |
| Critical (75%+) | Red fill; xmark.octagon icon; .bold weight |
| Loading | Shimmer on progress bar and amounts; icon placeholder |
| Account disconnected | Gray progress bar; "Reconnect" inline; amounts show "—" |
Anatomy: Category icon (24pt frame, filled style) | Merchant name (.body) + category (.detailText()) | Amount with semantic color | Optional "Pending" micro badge
| State | Behavior |
|---|---|
| Default (posted) | Full-opacity; amount in income/expense color |
| Pending | .opacity(0.7) on row; orange "Pending" .microText() badge below amount |
| Filtered out | Hidden (.transition(.opacity)) when filter excludes |
| Tap/hover | .background(.quaternarySystemFill) on row |
| Tap → detail sheet | onTapGesture sets selectedTransaction, presenting TransactionDetailView as .sheet |
Anatomy: NavigationStack > Form (grouped) | Header section: category icon (title2) + merchant name (.title3.bold) + raw transaction name (.detailText) | Details section: LabeledContent rows for Amount (color-coded, monospacedDigit), Category (Label with icon), Date, Account, Status (colored dot + "Posted"/"Pending") | Toolbar "Done" button | .presentationSizing(.fitted)
Code reference: Sources/PlaidBar/Views/TransactionDetailView.swift
| State | Behavior |
|---|---|
| Default (posted expense) | Full details; amount in expense color; green "Posted" dot |
| Pending transaction | Amount in expense color; orange "Pending" dot + text |
| Income transaction | Amount in income color with + prefix; green "Posted" dot |
| Expense transaction | Amount in expense color (no prefix); green "Posted" dot |
| Missing category | Category icon falls back to .other (questionmark.circle.fill); LabeledContent("Category") hidden |
| Unknown account | Account row shows "Unknown" |
Anatomy: ScrollView(.horizontal) > HStack of Menu chips | Each chip: text + chevron.down in capsule background | Active chip: .accentColor.opacity(0.15) background, accent foreground | Inactive: .quaternary.opacity(0.5) background, .secondary foreground | Clear button: xmark.circle.fill (appears when ≥1 filter active)
Code reference: Sources/PlaidBar/Views/FilterChipsView.swift
| State | Behavior |
|---|---|
| No filters active | 3 chips (Category, Account, Date=All) in inactive style; no clear button |
| 1+ filters active | Active chip(s) highlighted in accent; clear button visible |
| Category selected | Chip text changes to category display name (e.g., "Food & Drink") |
| Account selected | Chip text changes to account name (e.g., "Chase Checking") |
| Date range selected | Chip text changes to range label (e.g., "This Week") |
| Clear all tapped | All filters reset: category=nil, accountId=nil, dateRange=.all |
Anatomy: VStack | Header: "EST. MONTHLY COST" (.sectionTitle()) + normalized total (.heroBalance()) | Divider | ForEach of RecurringRow items | Empty state: ContentUnavailableView with arrow.clockwise icon
Code reference: Sources/PlaidBar/Views/RecurringView.swift
| State | Behavior |
|---|---|
| Populated | Header shows monthly estimate (normalizes weekly/annual via monthlyMultiplier); rows listed by amount descending |
| Empty (no recurring detected) | ContentUnavailableView: "No Recurring Transactions" with explanation text |
| Normalized amounts | Weekly items × 4.33, annual ÷ 12, quarterly ÷ 3 for monthly total |
Anatomy: HStack | Category icon (.body, .secondary, 24pt frame) | VStack: merchant name (.body) + frequency badge (.microText() in indigo capsule SemanticColors.recurring.opacity(0.15)) + average amount (.detailText(), monospacedDigit) + "Last: {date}" (.detailText()) | .hoverHighlight()
Code reference: Sources/PlaidBar/Views/RecurringView.swift (private struct)
| State | Behavior |
|---|---|
| Weekly | Badge shows "Weekly" in indigo capsule |
| Biweekly | Badge shows "Biweekly" in indigo capsule |
| Monthly | Badge shows "Monthly" in indigo capsule |
| Quarterly | Badge shows "Quarterly" in indigo capsule |
| Annual | Badge shows "Annual" in indigo capsule |
| Hover | .hoverHighlight() background applied |
Anatomy: Form | Master toggle "Enable notifications" | Permission denied warning (if applicable): exclamationmark.triangle icon + explanation text | Section "Transaction Alerts": Large transactions toggle + threshold field (
Code reference: Sources/PlaidBar/Settings/SettingsView.swift
| State | Behavior |
|---|---|
| Notifications off | Master toggle off; all sub-toggles and fields disabled |
| Notifications enabled | Master toggle on; sub-toggles and threshold fields enabled |
| Permission denied (macOS) | Warning banner: exclamationmark.triangle + "Enable in System Settings > Notifications"; master toggle forced off |
| Individual trigger disabled | Specific toggle off; associated threshold field disabled |
| High utilization reference | Shows "Uses credit warning threshold ({X}%)" in .detailText() — threshold set in General tab |
Anatomy: (Inline in SpendingView) VStack | HStack: directional arrow icon + delta text (absolute + percent) in .callout.weight(.medium) | "vs. last period" in .microText() + .secondary | Color: increase → SemanticColors.negative (red), decrease → SemanticColors.positive (green) | .contentTransition(.numericText()) animation
Code reference: Sources/PlaidBar/Views/SpendingView.swift (inline in body)
| State | Behavior |
|---|---|
| Spending increased | arrow.up.right icon; red text; positive delta with "+" prefix |
| Spending decreased | arrow.down.right icon; green text; negative delta |
| No previous period data | Comparison section hidden entirely (if previousPeriodSpending > 0) |
| Period changed | Recalculates based on selectedPeriod (week/month/30d); animated transition |
Anatomy: Header "Local Insight Receipt" + local runtime status pill | one-line headline | evidence chips for source-row count, time window, top display category, recurring estimate, and category-hint count when present | compact confidence and limitation rows | local-only badge + reversible action copy.
Code reference: LocalAIInsightReceipt in Sources/PlaidBarCore/Models/LocalAIInsights.swift; rendered by LocalInsightsCard in Sources/PlaidBar/Views/MainPopover.swift.
| Element | Rule |
|---|---|
| Headline | Short deterministic summary or future local-model summary after known local source identifiers are redacted |
| Evidence chips | Display-safe counts, categories, amounts, and window labels only; never raw account IDs, item IDs, transaction IDs, tokens, or Plaid payload text |
| Time window | Explicit current range such as 2026-06-05 to 2026-06-11; no vague "recently" when source windows are known |
| Local-only badge | Always visible as Local-only; no cloud AI fallback language except to state it is unsupported |
| Confidence | Names deterministic/local source-row confidence and downgrades when no runtime, no rows, or limited history is available |
| Limitations | States missing runtime, missing source rows, missing comparison windows, and display-safe evidence boundaries plainly |
| Unavailable | Shows no-runtime or no-history state without blocking the dashboard; user can continue using non-AI views |
| Reversible action | Category hints are local overlays; accepting or rejecting them is reversible and does not mutate raw Plaid records |
Shared behavior: All charts animate on appearance with .spring(response: 0.3, dampingFraction: 0.8). When accessibilityReduceMotion is on, render immediately without animation.
| Chart | Anatomy | Empty State | Error State |
|---|---|---|---|
| Donut | Category segments; inner label shows category name + % when segment >10% | "No spending data yet" with chart.pie SF Symbol |
"Unable to load" with retry |
| Trend line | Daily spending dots + area fill; x-axis = dates, y-axis = amount | "Not enough data — need 7+ days" | Gray placeholder area |
| Income vs Expense | Monthly grouped bars (green = income, primary = expense) | "Need 1+ month of data" | Gray placeholder bars |
| Utilization gauge | Circular gauge (0-100%); color follows utilization gradient | "No credit cards linked" | "—" with gray ring |
Pattern for all empty states:
| Element | Spec |
|---|---|
| Icon | SF Symbol, .font(.system(size: 40)), .foregroundStyle(.tertiary) |
| Title | .body.weight(.medium), 1 line, centered |
| Description | .detailText(), max 2 lines, centered, .multilineTextAlignment(.center) |
| Action button | Optional; .buttonStyle(.borderedProminent) for primary, .borderedStyle for secondary |
| Spacing | Spacing.lg between icon and title; Spacing.sm between title and description; Spacing.md before button |
| Screen | Components Used | Nav Pattern |
|---|---|---|
| Menu bar popover (main) | Dashboard header, status strip, summary values, 365-day heatmap, segmented finance filters, dense account rows, inline selected account drill-down, footer actions | One scroll surface; row selection expands drill-down in place; Cmd+R refreshes and Cmd+N adds account |
| Account rows | Compact account/card rows with balance, utilization/status, sync freshness, pending count, and chevron affordance | Click row to expand the selected account details inline |
| Selected account panel | Connection badge, balance metrics, pending/inflow/outflow/sync pills, recent transactions, reconnect/refresh actions | Inline recovery actions for stale or degraded items |
| Spending activity | GitHub-style 365-day grid with month labels, Spend/Net toggle, intensity legend, and total header | Hover cells for day-level transaction count plus spend or net cashflow |
| Legacy detail views | AccountsView, TransactionsView, SpendingView, CreditView, StatusView remain available as implementation surfaces and screenshot/reference components | Prefer dashboard-first entry unless adding a focused detail surface |
| Settings | 4-tab TabView: General, Accounts, Notifications, About (480×380) | TabView |
| Onboarding | Demo/Sandbox/Production choice with local-storage disclosure before Plaid Link | Mode choice, Back, Check Connection |
Inventory for T006: surfaces that still risk feeling tab-heavy or card-heavy. Keep this inventory about visual structure only. Do not record real balances, account masks, institution names, transaction names, item IDs, tokens, or local absolute paths when adding screenshot or review evidence.
| Surface | Code reference | Current pattern | Risk | Recommended follow-up |
|---|---|---|---|---|
| Dashboard summary stack | DashboardSummaryCards, MetricCard, and BalanceCompositionStrip in Sources/PlaidBar/Views/MainPopover.swift |
Several rounded panels appear after the heatmap and before account rows | Reads like stacked dashboard cards instead of one compact menu-bar instrument | Flatten at least one summary group into separator-backed inline metrics before adding more dashboard sections |
| Selected account detail | SelectedAccountPanel, AccountSignalPill, recovery detail, and recent activity in Sources/PlaidBar/Views/MainPopover.swift |
Inline drill-in uses an outer panel plus nested inset pills and a recovery panel | Clearest nested-card pattern in the main popover when an account is selected | Keep the inline drill-in, but reduce nested panel treatment to status color, separators, and compact rows |
| Local insights | LocalInsightsCard and InsightMetricPill in Sources/PlaidBar/Views/MainPopover.swift |
Optional local-only AI/status content is presented as a card with nested metric pills | Can feel like product/marketing chrome if it competes with financial rows | Prefer a compact disclosure or status row unless local insights become the selected detail focus |
| Status and readiness | DashboardStatusReadinessPanel and DashboardEmptyAccountState in Sources/PlaidBar/Views/MainPopover.swift |
Recovery and empty states use prominent rounded panels | Appropriate when degraded, but card-heavy if shown alongside several other panels | Keep panels exceptional for action-needed states; avoid duplicating status panels in normal healthy dashboard flow |
| Legacy/detail surfaces | AccountsView, TransactionsView, SpendingView, CreditView, and StatusView |
Older detail views include segmented controls, forms, grids, and diagnostic tiles | Tab-heavy if promoted back to first-level popover navigation | Treat as drill-ins or reference/screenshot surfaces; keep the main popover dashboard-first |
| Settings | SettingsView |
4-tab macOS settings control plane | Tab-heavy by design, but outside the primary popover dashboard | Leave as settings unless a future settings-specific audit scopes a flatter layout |
| Onboarding/setup | SetupView |
Demo/Sandbox/Production choices, preflight rows, and local-storage disclosure use multiple callout blocks | First-run flow can feel card-heavy and marketing-like if choices duplicate each other | Keep boundary explanations, but consolidate duplicate choice surfaces before adding more setup panels |
Checklist for contributors:
- Tokens first: Use existing
Spacing,Typography, and semantic color tokens. Never hardcode values. - Document anatomy: List every visual element with its token reference.
- States table: Minimum: default, loading, error, empty, hover. Add domain-specific states as needed.
- Accessibility: Add VoiceOver label to the Accessibility section. Ensure no color-only indicators.
- Dark mode: Verify component renders correctly in both appearances. Add to dark mode testing checklist.
- Code reference: Note the SwiftUI file where the component lives.
- Use
Swift Chartsframework (not custom drawing) - Follow shared chart behavior:
.spring()animation,accessibilityReduceMotionrespect - Add chart colors to
SpendingCategoryif category-based, or define semantic tokens if not - Document empty state and error state in the Charts table above
- Add VoiceOver summary string (pattern: "{chart type}. {key insight}.")
- Add case to
SpendingCategoryenum inSpendingCategory.swift - Define
colorHex(light mode) — choose a hue not adjacent to existing categories on the color wheel - Define
icon— use filled SF Symbol consistent with existing category icons - Optionally add
colorHexDarkif the light-mode hex has <3:1 contrast on dark backgrounds
- Add a new
@Stateproperty toTransactionsViewfor the filter value - Add a
@Bindingparameter toFilterChipsView - Add a new
Menublock inFilterChipsView.bodyfollowing the chip pattern (Menu > Button items > chipLabel) - Update
activeFilterCountcomputed property to include the new filter - Add filtering logic in
TransactionsView.filteredTransactions - Add clear logic in the "Clear all" button action
- Add a new case to
RecurringFrequencyenum inRecurringTransaction.swift - Provide
displayName,iconName,estimatedDays, andmonthlyMultiplier - Add the median interval range in
RecurringDetector.classifyFrequency - Badge rendering in
RecurringRowis automatic (usesfrequency.displayName)
- Add a new
@Stateproperty of optional type for the item to detail - Attach
.sheet(item:)modifier to the parent view - Build the detail view following
TransactionDetailViewpattern:NavigationStack>Form> sections withLabeledContent - Add a "Done" toolbar button calling
dismiss() - Apply
.presentationSizing(.fitted)for content-appropriate sheet size - Add
.accessibilityElement(children: .contain)to the root
PlaidBar runs on macOS, where dark mode usage is ~60%. All tokens must work in both appearances.
| Token | Light Mode | Dark Mode | Adapts Automatically? |
|---|---|---|---|
income (.green) |
System green | System green (lighter) | Yes — SwiftUI semantic |
expense (.primary) |
Label primary | Label primary (white) | Yes — SwiftUI semantic |
creditDebt (.red) |
System red | System red (lighter) | Yes — SwiftUI semantic |
warning (.orange) |
System orange | System orange (lighter) | Yes — SwiftUI semantic |
pending (.orange) |
System orange | System orange (lighter) | Yes — SwiftUI semantic |
.secondary (detail text) |
Gray | Light gray | Yes — SwiftUI semantic |
| Chart hex colors | Fixed hex values | Same hex values | No — requires manual dark variants |
brand (.blue) |
System blue | System blue (lighter) | Yes — SwiftUI semantic |
brandSecondary (.orange) |
System orange | System orange (lighter) | Yes — SwiftUI semantic |
recurring (.indigo) |
System indigo | System indigo (lighter) | Yes — SwiftUI semantic |
sparkline (.blue) |
System blue | System blue (lighter) | Yes — SwiftUI semantic |
These SpendingCategory.colorHex values have <3:1 contrast ratio against dark background (#1E1E1E):
| Category | Hex | Contrast vs Dark BG | Recommended Dark Variant |
|---|---|---|---|
personalCare |
#FFEAA7 |
~2.5:1 | #F0D890 (desaturate) |
homeImprovement |
#F7DC6F |
~2.3:1 | #E8CD60 (darken) |
transferOut |
#D5DBDB |
~2.8:1 | #B8C0C0 (darken) |
other |
#BDC3C7 |
~2.6:1 | #A0A8AC (darken) |
income |
#82E0AA |
~2.9:1 | #6FCC98 (darken slightly) |
SpendingCategory.colorHex uses fixed hex values that were designed for light backgrounds. For dark mode:
- Current behavior: Hex colors render as-is on dark backgrounds. Most are vibrant enough to work, but some (e.g., light yellows) lose contrast.
- Recommended fix: Add a
colorHexDarkproperty toSpendingCategorywith adjusted values for dark backgrounds, selected via@Environment(\.colorScheme). - Interim: Existing palette is acceptable for most categories. 5 categories (see Problem Colors above) fall below 3:1 contrast on dark backgrounds — a
colorHexDarkproperty is the recommended fix.
- All semantic tokens readable on
.backgroundin both appearances - Chart donut labels readable over colored segments in both appearances
- Utilization progress bar colors distinguishable in dark mode
- Empty state SF Symbols render correctly (use
.primaryforeground, not hardcoded color) - Pending badge (
.orange+.background) maintains contrast in dark mode
All text/background combinations must meet WCAG AA (4.5:1 for body text, 3:1 for large text).
| Combination | Light Mode | Dark Mode | Passes AA? |
|---|---|---|---|
.primary on .background |
~15:1 | ~15:1 | Yes |
.secondary on .background |
~5.5:1 | ~5.5:1 | Yes |
.green on .background |
~3.5:1 | ~4:1 | Large text only — pair with secondary cue |
.red on .background |
~4.5:1 | ~4.8:1 | Yes |
.orange on .background |
~3.2:1 | ~3.5:1 | Large text only — pair with secondary cue |
Every element that uses color to convey meaning must have a secondary, non-color indicator:
| Element | Color Cue | Secondary Cue |
|---|---|---|
| Utilization level | Green → Yellow → Orange → Red | Icon changes: checkmark.circle → exclamationmark.triangle → xmark.octagon |
| Income transaction | .green amount |
+ prefix on amount |
| Expense transaction | .primary amount |
No prefix (default) — distinguishable by absence of + |
| Pending transaction | .orange badge |
"Pending" text label on badge |
| Credit utilization bar | Color fill | Percentage text label always visible |
| Chart segments | Category color | Category name in legend; inner label at >10% |
| Recurring frequency | Indigo badge color | Text label on badge: "Weekly" / "Monthly" / "Annual" etc. |
| Spending delta direction | Red (increase) / Green (decrease) | Arrow icon: arrow.up.right / arrow.down.right + signed amount text |
| Transaction status (detail) | Green dot (posted) / Orange dot (pending) | "Posted" / "Pending" text label beside dot |
| Active filter chips | Accent background | Chip text changes from placeholder ("Category") to selected value ("Food & Drink") |
| Component | VoiceOver Announcement |
|---|---|
| AccountRow | "{institution} {account name}, balance {amount}" |
| CreditCardRow | "{card name}, {balance} of {limit}, {percent} utilization, {status}" where status = "good" / "warning" / "high" |
| TransactionRow | "{merchant}, {amount}, {category}, {date}" + "pending" if applicable |
| Menu bar icon | "PlaidBar, net balance {amount}" |
| Utilization gauge | "Credit utilization {percent}, {status level}" |
| Chart (donut) | "Spending by category. Largest: {category} at {percent}" |
| Refresh button | "Refresh accounts" + "Last updated {time}" as hint |
| FilterChipsView | "{N} filters active" or "Transaction filters" (when none active) |
| RecurringRow | "{merchant}, {frequency}, {amount}" |
| RecurringView (header) | "Estimated monthly recurring cost: {amount}" |
| TransactionDetailView | Container with combined children: merchant, amount, category, date, account, status |
| SpendingComparison | "Spending {increased/decreased} by {amount}, {percent} {more/less} than last period" |
- All chart animations respect
@Environment(\.accessibilityReduceMotion) - When reduce-motion is on: charts render immediately without animation; transitions use
.opacityinstead of.slideor.spring - Default animation:
.spring(response: 0.3, dampingFraction: 0.8)for chart entry;.easeInOut(duration: 0.2)for view transitions