32 KiB
Phase 4: Full-App Design Consistency - Research
Researched: 2026-03-17 Domain: React/TypeScript UI polish — pattern application, i18n completeness, skeleton loading states, auth page redesign Confidence: HIGH (all findings from direct codebase inspection — no external library uncertainty)
<user_constraints>
User Constraints (from CONTEXT.md)
Locked Decisions
Auth pages (Login & Register):
- Solid muted background color behind the centered card (not plain white, not gradient)
- Card accent styling: Claude's discretion on whether top border, shadow, or ring treatment
- App icon/logo above the title text for brand presence (icon asset or emoji/Lucide placeholder)
- OAuth buttons (Google, GitHub) get provider SVG icons next to text labels
- Pages remain standalone centered layout (outside AppLayout sidebar)
BudgetDetail category sections:
- Migrate to semantic color tokens (
--color-on-budget,--color-over-budget) replacing hardcodedtext-green-600/text-red-600 - Adopt direction-aware diff logic from Phase 3: spending types over when actual > budgeted, income under-earned when actual < budgeted
- Visual style upgrade: left-border accent + badge chips to match dashboard CategorySection appearance
- Collapsible behavior vs always-expanded: Claude's discretion based on editing context
- Tier badges (Fixed/Variable/One-off): Claude's discretion on keep vs remove
- Overall totals box: Claude's discretion on whether to use StatCards or keep as styled box
Category group headers (Categories, Template, QuickAdd pages):
- Group header styling upgrade: Claude's discretion on matching full dashboard CategorySection style (left-border card) vs enhanced dot style (larger dot, bolder label)
- Template group totals placement (header badge vs table footer): Claude's discretion
- BudgetList enrichment (card per budget vs table): Claude's discretion
- Settings card structure (single vs multiple cards): Claude's discretion
Page descriptions & polish:
- Page descriptions via PageShell description prop: Claude's discretion per-page on whether subtitle adds value
- Empty states: Claude's discretion on whether to add icon/illustration treatment or keep text-only
- Loading states: Add skeleton placeholders for all pages (replacing current
return nullloading states) - i18n: Locale-aware month formatting using
Intl.DateTimeFormatwith user's locale (e.g., "Marz 2026" in German) - All hardcoded English strings (month names, "Month"/"Year" labels) must get i18n keys in both en.json and de.json
Claude's Discretion
- Auth card accent treatment (top border vs shadow vs ring)
- BudgetDetail: collapsible sections vs visual-style-only (always expanded)
- BudgetDetail: keep or remove tier badges
- BudgetDetail: overall totals as StatCards vs styled box
- CRUD page group headers: dashboard-style cards vs enhanced dots
- Template: group totals in header vs table footer
- BudgetList: card layout vs table layout
- Settings: single card vs multiple cards
- Per-page description text decisions
- Empty state visual treatment level
- Skeleton component designs for each page type
Deferred Ideas (OUT OF SCOPE)
None — discussion stayed within phase scope. </user_constraints>
<phase_requirements>
Phase Requirements
| ID | Description | Research Support |
|---|---|---|
| UI-DESIGN-01 | All 9 pages use PageShell with consistent typography, card style, and color token usage | PageShell already exists in shared/PageShell.tsx — 7 of 9 pages need it wired in; DashboardPage already uses it |
| UI-AUTH-01 | Login and Register pages have refreshed visual design matching dashboard card/color patterns | Both pages use plain bg-background — need bg-muted background + card accent treatment + app logo/icon |
| UI-CATEGORIES-01 | Categories page group headers upgraded to match design system | CategoriesPage uses plain dot+label headers — upgrade to left-border card or enhanced dot style |
| UI-TEMPLATE-01 | Template page group headers upgraded and totals displayed | TemplatePage uses same plain dot+label headers as Categories |
| UI-BUDGETS-01 | BudgetDetail displays category groups with color-accented cards and semantic diff tokens | BudgetDetailPage uses hardcoded text-green-600/text-red-600 + plain dot headers + no semantic tokens |
| UI-QUICKADD-01 | Quick Add page uses PageShell with consistent styling | QuickAddPage has no group headers (flat list) — primarily needs PageShell + possible restructure |
| UI-SETTINGS-01 | Settings page uses PageShell with consistent styling | SettingsPage has no PageShell, has a Card already but redundant h1+CardTitle |
| UI-RESPONSIVE-01 | Navigating between any two pages produces no jarring visual discontinuity | All pages need consistent gap/spacing, same PageShell header heights, same font sizing |
| </phase_requirements> |
Summary
Phase 4 is a pure polish and pattern-application phase — no new features, no backend changes. The design system (OKLCH color tokens, semantic status tokens, CategorySection component, PageShell) is fully established in Phases 1–3. The work is applying it uniformly to all 9 pages.
The current state has a clear divide: DashboardPage is the polished reference, and all other authenticated pages are functional-but-unstyled first-drafts. Seven pages have inline <h1> headings instead of PageShell. Six pages return null while loading instead of showing skeletons. BudgetDetailPage has hardcoded Tailwind color classes (text-green-600, text-red-600) that bypass the established semantic token system. Auth pages have a plain bg-background root div where the design spec calls for bg-muted.
The favicon.svg in /public/ is a real stylized lightning-bolt SVG with the app's purple brand color (#863bff) — this is the logo asset to use above the auth card title. No additional icon asset is needed.
There is no test infrastructure in this project (no test files, no test framework configured). nyquist_validation is enabled in config.json, so this section must be addressed, but with a note that all validation is manual/visual for a UI-only phase.
Primary recommendation: Treat this phase as 9 small, sequential page upgrades. Apply PageShell + skeleton + i18n cleanup as a checklist across each page. Use direct codebase inspection — not external research — as the source of truth.
Standard Stack
Core (already installed — no new dependencies needed)
| Library | Version | Purpose | Why Standard |
|---|---|---|---|
| React | 19.2.4 | Component rendering | Project foundation |
| react-i18next | 16.5.8 | i18n translation hook useTranslation |
Already in use project-wide |
| Tailwind CSS | 4.2.1 | Utility classes | Project styling system |
| shadcn/ui primitives | (radix-ui 1.4.3) | Card, Badge, Skeleton, Button, etc. | Already installed and used |
| lucide-react | 0.577.0 | Icons (including logo placeholder) | Already in use project-wide |
Supporting
| Library | Version | Purpose | When to Use |
|---|---|---|---|
Intl.DateTimeFormat |
Native browser API | Locale-aware month/year formatting | Replace hardcoded English month arrays in BudgetListPage and BudgetDetailPage |
Alternatives Considered
| Instead of | Could Use | Tradeoff |
|---|---|---|
Intl.DateTimeFormat |
date-fns or dayjs | No new dependency needed — native API does exactly what's required (month+year locale formatting) |
Lucide Zap icon for auth logo |
Custom SVG import from /public/favicon.svg |
The favicon.svg is a real brand asset — using an <img src="/favicon.svg"> is simpler and more authentic than a Lucide icon |
Installation: No new packages needed.
Architecture Patterns
Recommended Project Structure
No structural changes. All new/modified files fit within the existing layout:
src/
├── components/
│ ├── shared/
│ │ └── PageShell.tsx # Already exists — use as-is
│ └── dashboard/
│ ├── CategorySection.tsx # Reuse in BudgetDetailPage
│ └── DashboardSkeleton.tsx # Reference for new skeletons
├── pages/
│ ├── LoginPage.tsx # Redesign auth card
│ ├── RegisterPage.tsx # Redesign auth card
│ ├── BudgetDetailPage.tsx # Upgrade group headers + diff tokens
│ ├── BudgetListPage.tsx # Add PageShell + i18n month names
│ ├── CategoriesPage.tsx # Add PageShell + header upgrade
│ ├── TemplatePage.tsx # Add PageShell + header upgrade
│ ├── QuickAddPage.tsx # Add PageShell
│ └── SettingsPage.tsx # Wrap with PageShell, fix duplication
└── i18n/
├── en.json # Add month/year i18n keys, page descriptions
└── de.json # German equivalents
Pattern 1: PageShell Adoption (7 pages)
What: Replace each page's inline <div> + <h1> + action button header with <PageShell title={t("...")} action={<Button>}>
When to use: Every authenticated page (all pages inside AppLayout)
Current pattern to replace:
// Before — every CRUD page looks like this:
<div>
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-semibold">{t("categories.title")}</h1>
<Button onClick={openCreate} size="sm">...</Button>
</div>
{/* content */}
</div>
Target pattern:
// After — consistent with DashboardPage
import { PageShell } from "@/components/shared/PageShell"
return (
<PageShell
title={t("categories.title")}
description={t("categories.description")} // optional
action={<Button onClick={openCreate} size="sm">...</Button>}
>
{/* content */}
</PageShell>
)
Note: SettingsPage already has a Card inside — the redundant <h1> heading above the card should be removed when wrapping with PageShell. The CardTitle inside can become the section header.
Pattern 2: Skeleton Loading States (6 pages)
What: Replace if (loading) return null with a page-appropriate skeleton
Current state: 6 pages use return null as loading state:
- CategoriesPage —
if (loading) return null - TemplatePage —
if (loading) return null - BudgetListPage —
if (loading) return null - BudgetDetailPage —
if (loading) return null - QuickAddPage —
if (loading) return null - SettingsPage —
if (loading) return null
DashboardSkeleton as pattern reference:
// Source: src/components/dashboard/DashboardSkeleton.tsx
// Pattern: Skeleton primitive wrapped in Card layout to mirror real content shape
import { Skeleton } from "@/components/ui/skeleton"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
// Table page skeleton (Categories, Template, BudgetDetail, QuickAdd):
function TablePageSkeleton() {
return (
<div className="space-y-4">
{/* Mimics group header shape */}
<div className="flex items-center gap-3 rounded-md border-l-4 border-muted bg-card px-4 py-3">
<Skeleton className="h-4 w-32" />
</div>
{/* Mimics table rows */}
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-4 px-4 py-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="ml-auto h-4 w-20" />
</div>
))}
</div>
)
}
Rule of Hooks compliance: Skeletons must be returned AFTER all hooks have been called. The existing pages already follow this — return null always appears after all useState/useEffect/derived-state code.
Pattern 3: Auth Page Redesign
What: Upgrade Login and Register from plain bg-background to brand-presence auth layout
Current state:
// LoginPage.tsx (line 35) — plain white background
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl">{t("app.title")}</CardTitle>
</CardHeader>
Target pattern:
// Muted background + logo above title + card accent
<div className="flex min-h-screen items-center justify-center bg-muted/60 p-4">
<Card className="w-full max-w-sm border-t-4 border-t-primary">
<CardHeader className="text-center">
<img src="/favicon.svg" alt="SimpleFinanceDash" className="mx-auto mb-3 size-10" />
<CardTitle className="text-2xl">{t("app.title")}</CardTitle>
</CardHeader>
OAuth provider icons: Add SVG inline icons for Google and GitHub next to button text labels. Standard approach is a small inline SVG (16x16) or use a well-known path. Both Google G and GitHub Octocat have canonical simple SVG marks.
Pattern 4: BudgetDetail — Semantic Token Migration
What: Replace DifferenceCell's hardcoded color classes with semantic tokens
Current problem code (BudgetDetailPage.tsx lines 169–173):
const color =
diff > 0
? "text-green-600 dark:text-green-400"
: diff < 0
? "text-red-600 dark:text-red-400"
: "text-muted-foreground"
Correct pattern (matching CategorySection):
// Use the same tokens established in Phase 1
import { cn } from "@/lib/utils"
// isOver uses same direction-aware logic as CategorySection
const isOver = isSpendingType(type) ? actual > budgeted : actual < budgeted
const colorClass = isOver ? "text-over-budget" : diff !== 0 ? "text-on-budget" : "text-muted-foreground"
Note on text-on-budget vs text-muted-foreground: The CategorySection uses text-on-budget for non-over items in the header but text-muted-foreground for non-over item rows in the table body. For consistency, replicate that exact distinction.
Pattern 5: Group Header Upgrade (CategoriesPage, TemplatePage, BudgetDetailPage)
Current state: All three CRUD pages use the same small-dot pattern:
<div className="mb-2 flex items-center gap-2">
<div className="size-3 rounded-full" style={{ backgroundColor: categoryColors[type] }} />
<h2 className="text-sm font-medium text-muted-foreground">
{t(`categories.types.${type}`)}
</h2>
</div>
Recommended upgrade (enhanced dot — not full CategorySection card): For CRUD pages, a full left-border card with collapse is excessive (editing context favors always-expanded). Use a larger dot with bolder label for visual consistency without the overhead:
<div className="mb-2 flex items-center gap-3 border-l-4 bg-muted/30 px-3 py-2 rounded-sm"
style={{ borderLeftColor: categoryColors[type] }}>
<span className="text-sm font-semibold">{t(`categories.types.${type}`)}</span>
</div>
This matches the left-border accent visual language without the collapsible trigger complexity. CRUD pages are editing interfaces — always-expanded is correct UX.
Pattern 6: i18n — Locale-Aware Month Formatting
What: Replace hardcoded English month arrays and label strings with Intl.DateTimeFormat
Current problem in BudgetListPage.tsx (lines 36–49):
// Hardcoded English month labels
const MONTHS = [
{ value: 1, label: "January" },
// ... 11 more hardcoded English strings
]
And the dialog labels (lines 189, 210): <Label>Month</Label> and <Label>Year</Label> — hardcoded English.
Current problem in BudgetDetailPage.tsx (line 279):
return date.toLocaleDateString("en", { month: "long", year: "numeric" })
// Hardcoded "en" locale — always English regardless of user's language setting
Correct pattern:
// Use the i18n hook to get the active locale
const { i18n } = useTranslation()
const locale = i18n.language // "en" or "de"
// Locale-aware month name generation
const monthOptions = Array.from({ length: 12 }, (_, i) => ({
value: i + 1,
label: new Intl.DateTimeFormat(locale, { month: "long" }).format(new Date(2000, i, 1)),
}))
// Locale-aware budget heading
function budgetHeading(startDate: string, locale: string): string {
const [year, month] = startDate.split("-").map(Number)
return new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" })
.format(new Date(year ?? 0, (month ?? 1) - 1, 1))
}
New i18n keys needed for month dialog labels:
// en.json additions
"budgets": {
"month": "Month",
"year": "Year"
}
// de.json equivalents
"budgets": {
"month": "Monat",
"year": "Jahr"
}
Anti-Patterns to Avoid
- Returning
nullduring loading: Every page currently doesif (loading) return null— replace all with skeleton components. This is the most visible UX gap. - Hardcoded locale string
"en"intoLocaleDateString: BudgetDetailPage line 279 and BudgetListPage'sbudgetLabelhelper both force English formatting. Must usei18n.languageinstead. - Inline
<h1>+ action div: 7 pages duplicate the exact pattern that PageShell was built to replace. Don't leave any of these after this phase. - Hardcoded
text-green-600/text-red-600: BudgetDetailPageDifferenceCellcomponent bypasses the semantic token system established in Phase 1. This breaks dark mode and design consistency. - Double heading in SettingsPage: SettingsPage has both
<h1 className="mb-6 text-2xl font-semibold">and<CardTitle>both showing "Settings" — wrap with PageShell and remove the redundanth1.
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| Locale-aware month names | Custom MONTHS array with translations |
Intl.DateTimeFormat |
Already returns localized month names in any locale; zero maintenance |
| Loading placeholder UI | Custom spinners or CSS animations | Skeleton from ui/skeleton.tsx |
Already installed, same design language as DashboardSkeleton |
| Auth page logo | New SVG asset or Lucide icon | /public/favicon.svg via <img> |
Brand asset already exists, consistent with browser tab favicon |
| Direction-aware diff logic | New computation function | Extract from CategorySection.tsx (or import computeDiff) |
Logic is already correct and battle-tested in Phase 3 |
| Group header card styling | New component | Inline left-border pattern from CategorySection's trigger element | Consistent look without creating a new abstraction |
Key insight: This phase adds no new libraries and creates minimal new abstractions. Almost everything needed is already in the codebase.
Common Pitfalls
Pitfall 1: Rules of Hooks — return null to Skeleton Migration
What goes wrong: Moving if (loading) return null to return a Skeleton component without checking that all hooks come before the condition.
Why it happens: React requires all hooks to be called unconditionally on every render. If return null is currently AFTER all hooks, swapping to return <Skeleton> is safe. But if any hook was accidentally placed after the loading check, switching breaks the rules.
How to avoid: Verify each page's hook ordering before replacing. In this codebase, all 6 pages that use return null have their hooks before the check (confirmed by code inspection). Safe to swap directly.
Warning signs: TypeScript/eslint react-hooks/rules-of-hooks lint error.
Pitfall 2: i18n.language vs navigator.language
What goes wrong: Using navigator.language for locale instead of i18n.language, causing month names to display in the system locale rather than the user's chosen app locale.
Why it happens: Both are "the user's language" but they represent different things — system preference vs app preference.
How to avoid: Always use i18n.language from useTranslation() for Intl.DateTimeFormat locale argument. The user's locale preference is stored in their Supabase profile and applied via i18n.changeLanguage() in SettingsPage.
Pitfall 3: SettingsPage Double-Header
What goes wrong: Wrapping SettingsPage in <PageShell title={t("settings.title")}> without removing the existing <h1 className="mb-6 text-2xl font-semibold">, producing two "Settings" headings.
Why it happens: SettingsPage is the only page that already has a Card structure — it's tempting to just prepend PageShell and leave existing content.
How to avoid: Remove the <h1> on line 67 of SettingsPage when adding PageShell.
Pitfall 4: BudgetDetail DifferenceCell isIncome Logic vs Direction-Aware Logic
What goes wrong: The existing DifferenceCell uses a simplified isIncome boolean prop. Upgrading to the full direction-aware logic from Phase 3 must be consistent — saving and investment types should behave like income (under-earned = over-budget), not like expenses.
Why it happens: The existing code only checks isIncome (type === "income"), missing saving/investment types.
How to avoid: Use the same SPENDING_TYPES: CategoryType[] = ["bill", "variable_expense", "debt"] pattern from CategorySection.tsx. Any type NOT in this array uses the income/saving logic.
Warning signs: Savings showing red when you've saved MORE than budgeted.
Pitfall 5: Auth Card Background Mismatch
What goes wrong: Using bg-muted (the Tailwind utility class) which maps to --color-muted (oklch 0.95) on top of bg-background (oklch 0.98) — the contrast is very subtle. If the wrong token is used, the intended visual separation disappears.
Why it happens: The muted background needs enough contrast to make the white card "float."
How to avoid: Use bg-muted/60 or bg-secondary instead. --color-secondary is oklch 0.93 vs card white oklch 1.0 — clearer separation. Or use bg-muted with a subtle shadow on the card.
Pitfall 6: Missing i18n Keys Causing Raw Key Strings
What goes wrong: Adding new translation calls (t("budgets.month")) before adding the key to both en.json and de.json — causes the raw key string to render on screen.
Why it happens: It's easy to forget de.json when en.json is the primary authoring language.
How to avoid: Always update both files atomically in the same task. The phase success criterion explicitly requires no raw i18n key strings in German locale.
Code Examples
Verified patterns from existing codebase:
PageShell API (src/components/shared/PageShell.tsx)
// PageShell signature — already final, no changes needed to the component itself
interface PageShellProps {
title: string
description?: string // optional subtitle below title
action?: React.ReactNode // CTA slot (buttons, etc.)
children: React.ReactNode
}
// Usage (from DashboardPage.tsx — the reference implementation):
<PageShell
title={t("dashboard.title")}
action={<MonthNavigator availableMonths={availableMonths} t={t} />}
>
{/* page content */}
</PageShell>
Skeleton Primitive (src/components/ui/skeleton.tsx)
// Available for import in all page skeletons
import { Skeleton } from "@/components/ui/skeleton"
// Example: table row skeleton (for CategoriesPage, TemplatePage, etc.)
function TableRowSkeleton() {
return (
<div className="flex items-center gap-4 px-4 py-2.5 border-b border-border">
<Skeleton className="h-4 w-36" />
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-auto h-7 w-7 rounded-md" />
</div>
)
}
Group Header Upgrade Pattern
// Upgrade from plain dot to left-border accent header
// Before (all CRUD pages):
<div className="mb-2 flex items-center gap-2">
<div className="size-3 rounded-full" style={{ backgroundColor: categoryColors[type] }} />
<h2 className="text-sm font-medium text-muted-foreground">
{t(`categories.types.${type}`)}
</h2>
</div>
// After (enhanced dot — keeps always-expanded for editing context):
<div
className="mb-2 flex items-center gap-3 rounded-sm border-l-4 bg-muted/30 px-3 py-2"
style={{ borderLeftColor: categoryColors[type] }}
>
<span className="text-sm font-semibold">{t(`categories.types.${type}`)}</span>
</div>
Semantic Token Migration (BudgetDetailPage)
// Before (hardcoded Tailwind colors — bypasses design tokens):
const color =
diff > 0 ? "text-green-600 dark:text-green-400"
: diff < 0 ? "text-red-600 dark:text-red-400"
: "text-muted-foreground"
// After (semantic tokens — consistent with CategorySection):
// SPENDING_TYPES same as in CategorySection.tsx
const SPENDING_TYPES: CategoryType[] = ["bill", "variable_expense", "debt"]
function isOver(type: CategoryType, budgeted: number, actual: number): boolean {
return SPENDING_TYPES.includes(type) ? actual > budgeted : actual < budgeted
}
// In render:
const over = isOver(type, item.budgeted_amount, item.actual_amount)
const colorClass = cn(
"text-right tabular-nums",
over ? "text-over-budget" : diff !== 0 ? "text-on-budget" : "text-muted-foreground"
)
Locale-Aware Month Name (replaces hardcoded MONTHS array)
// In BudgetListPage — replaces the 12-item MONTHS constant:
const { i18n } = useTranslation()
const locale = i18n.language // "en" | "de"
const monthItems = useMemo(
() =>
Array.from({ length: 12 }, (_, i) => ({
value: i + 1,
label: new Intl.DateTimeFormat(locale, { month: "long" }).format(
new Date(2000, i, 1)
),
})),
[locale]
)
// In BudgetDetailPage and BudgetListPage — replaces hardcoded "en" locale:
function budgetHeading(startDate: string, locale: string): string {
const [year, month] = startDate.split("-").map(Number)
return new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(
new Date(year ?? 0, (month ?? 1) - 1, 1)
)
}
Auth Page Redesign Structure
// LoginPage / RegisterPage root structure
<div className="flex min-h-screen items-center justify-center bg-muted/60 p-4">
<Card className="w-full max-w-sm border-t-4 border-t-primary shadow-lg">
<CardHeader className="text-center pb-4">
{/* App logo from public/favicon.svg */}
<img
src="/favicon.svg"
alt="SimpleFinanceDash"
className="mx-auto mb-3 size-10"
aria-hidden
/>
<CardTitle className="text-2xl">{t("app.title")}</CardTitle>
<p className="text-sm text-muted-foreground">{t("auth.loginSubtitle")}</p>
</CardHeader>
{/* ... existing form content ... */}
</Card>
</div>
State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
| No loading state (return null) | Skeleton components | Phase 4 | Users see content-shaped placeholders instead of blank pages |
| Hardcoded color classes | Semantic CSS tokens | Phase 1 (dashboard), Phase 4 extends to BudgetDetail | Dark mode support, single-source-of-truth for status colors |
| Hardcoded "en" locale | i18n.language locale |
Phase 4 | Month names now display in German when locale is "de" |
| Inline h1 + action div | PageShell component | Phase 4 extends Phase 1's PageShell | Consistent header height and spacing across all pages |
| Plain auth background | Muted background + brand logo | Phase 4 | Auth pages feel part of the same app, not a generic template |
Still current (no change needed):
formatCurrency— already locale-aware via Intl.NumberFormat (no changes needed)categoryColors/categoryLabelsin palette.ts — complete and correct- AppLayout sidebar — no changes needed, routes unchanged
collapsible-open/collapsible-closeanimations — complete and correct
Open Questions
-
BudgetDetail: Keep or remove TierBadge?
- What we know: TierBadge shows Fixed/Variable/One-off on each line item. This metadata is useful for planning but adds visual noise when tracking actuals.
- What's unclear: Whether the "editing actuals" context of BudgetDetailPage makes the tier less useful than in TemplatePage.
- Recommendation: Remove tier column from BudgetDetailPage to reduce visual noise and align with the CategorySection display style (which shows no tier). Keep tier in TemplatePage since it's a planning interface.
-
BudgetDetail: Collapsible or always-expanded?
- What we know: BudgetDetailPage is an editing interface where users click inline cells to edit actual amounts. Collapsing sections would require an extra click before editing.
- What's unclear: Whether the always-expanded view with full left-border card headers is sufficient, or whether the visual match to the dashboard collapsible style is more important.
- Recommendation: Always-expanded with left-border headers. The visual upgrade (left-border cards, semantic tokens, badge chips) delivers the design consistency without the UX cost of collapsing an editing interface.
-
BudgetDetail: StatCards or styled box for overall totals?
- What we know: The current "overall totals" is a
rounded-md border p-4div with a 3-column grid. - What's unclear: Whether StatCard's Card+CardHeader+CardContent structure adds meaningful value over the existing styled box.
- Recommendation: Keep styled box but upgrade to semantic tokens for the difference color. StatCards are designed for KPI highlight panels (like SummaryStrip) — a dense summary row inside a detail page fits better as a styled section.
- What we know: The current "overall totals" is a
Validation Architecture
nyquist_validationis enabled in.planning/config.json. However, this phase is 100% visual UI polish — no new logic, no new data flows, no new API calls. There are no automated tests in this project and none of the changes are unit-testable in the traditional sense.
Test Framework
| Property | Value |
|---|---|
| Framework | None — no test framework configured |
| Config file | None |
| Quick run command | bun run build (TypeScript compile + Vite build — catches type errors) |
| Full suite command | bun run build && bun run lint |
Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|---|---|---|---|---|
| UI-DESIGN-01 | All 9 pages render with PageShell header | Visual/manual | bun run build (no TS errors) |
❌ Wave 0 |
| UI-AUTH-01 | Auth pages show muted bg + logo + accent card | Visual/manual | bun run build |
❌ Wave 0 |
| UI-CATEGORIES-01 | Categories group headers have left-border accent | Visual/manual | bun run build |
❌ Wave 0 |
| UI-TEMPLATE-01 | Template group headers upgraded | Visual/manual | bun run build |
❌ Wave 0 |
| UI-BUDGETS-01 | BudgetDetail uses semantic tokens, no text-green-600 | grep check |
grep -r "text-green-600" src/pages/BudgetDetailPage.tsx || echo "CLEAN" |
❌ Wave 0 |
| UI-QUICKADD-01 | QuickAdd page renders PageShell | Visual/manual | bun run build |
❌ Wave 0 |
| UI-SETTINGS-01 | Settings page uses PageShell, no double heading | Visual/manual | bun run build |
❌ Wave 0 |
| UI-RESPONSIVE-01 | No visual discontinuity between pages | Visual/manual | Manual browser navigation | ❌ Wave 0 |
Sampling Rate
- Per task commit:
bun run build— TypeScript compile validates no type regressions - Per wave merge:
bun run build && bun run lint - Phase gate: Manual browser review of all 9 pages in English and German locale before
/gsd:verify-work
Wave 0 Gaps
- No test files to create — this phase has no unit-testable logic
- Recommend a manual checklist in VERIFY.md covering: all 9 pages load without null flash, German locale shows no raw keys, BudgetDetail shows no text-green-600/text-red-600 classes in DevTools
Sources
Primary (HIGH confidence)
- Direct codebase inspection of all 9 page files — source of all findings
src/index.css— confirmed all OKLCH tokens, semantic status tokens, animation tokenssrc/i18n/en.jsonandde.json— confirmed missing keys (month, year, page descriptions)src/components/shared/PageShell.tsx— confirmed interface and implementationsrc/components/dashboard/CategorySection.tsx— reference pattern for group headerssrc/components/dashboard/DashboardSkeleton.tsx— reference pattern for skeletonssrc/lib/palette.ts— confirmedcategoryColorsCSS variable mappackage.json— confirmed no test framework is installed
Secondary (MEDIUM confidence)
- MDN Web Docs pattern:
Intl.DateTimeFormatfor locale-aware month names — standard browser API, zero risk
Tertiary (LOW confidence)
- None
Metadata
Confidence breakdown:
- Standard stack: HIGH — no new libraries, all from direct package.json inspection
- Architecture: HIGH — all patterns derived from existing codebase, not external research
- Pitfalls: HIGH — all identified from actual code in the repo (specific file + line references)
- i18n patterns: HIGH — Intl.DateTimeFormat is a stable native API
Research date: 2026-03-17 Valid until: Stable — no external dependencies to go stale. Re-verify only if major packages are upgraded.