- Unified card headers: gap-3, Icon size 20, text-accent color
- PlansCard: collapsible sections, animated expand/collapse
- update-plans-data.ts: export all plans (no limit)
- Replaced all em-dashes with colons or full sentences
- Content text standardized to text-lg across all cards
- FeatureCard, ArchHighlight descriptions adjusted
Three phases with detailed plans, success criteria, and timelines:
Phase 1: ContainerView refactoring (2-3h, low-risk)
Phase 2: Shared handlers & helpers (4-5h, moderate)
Phase 3: List & Table implementation (14-18h, moderate)
Total effort: ~20-25 hours
Outcome: Proper View architecture, maximum code reuse
Three branches ready:
- refactor/containerview
- refactor/shared-handlers
- feat/list-table-new
Recommended: Execute in sequence, each with own PR.
Phase 1: ContainerView Refactoring (Low-risk, ~2-3h)
- Extract _ContainerViewCore with Renderable
- Make ContainerView a simple View with body
- Enable modifier support
Phase 2: Shared Handlers & Helpers (Moderate, ~4-5h)
- FocusableItemListHandler (navigation logic)
- SelectionStateManager (selection tracking)
- ItemStateRenderer (styling utilities)
- renderFocusableContainer() (helper function)
Phase 3: List & Table Implementation (Moderate-High, ~14-18h)
- Refactor List to use new architecture
- Implement Table with same foundation
- Both follow Box.swift pattern
- Maximum code reuse
Each phase gets its own branch and PR.
ContainerView currently uses body: Never + direct Renderable (WRONG).
Should be refactored to:
- Public View with real body: some View
- Private _ContainerViewCore with Renderable
- Enables modifiers to work naturally
- Consistent with Box and SwiftUI patterns
This should be done BEFORE List/Table implementation
so they follow the correct pattern from the start.
Impact: Card, Panel, Alert, Dialog use renderContainer() helper,
so they won't be directly affected. Low-risk refactoring.
SwiftUI/TUIKit pattern: Use composition helpers, not inheritance.
Current pattern (Card, Panel, Alert):
- renderContainer() helper function
- No inheritance from ContainerView
- Each component maintains own struct
- Shared logic in helper function
For List & Table:
- Create renderListWithFocus() or renderFocusableContainer()
- Extract FocusableItemListHandler for shared state
- Both use ContainerView via helper function
- Maximum code reuse without inheritance
This follows Swift conventions and matches existing TUIKit patterns.
Both List and Table blocked until shared architecture is defined:
- FocusableItemListHandler base class
- SelectionStateManager
- Shared rendering utilities
This prevents redundancy and ensures consistency between components.
Before implementing either List or Table, analyze shared concerns:
- Focus management and navigation (identical logic)
- Selection state and binding
- Container rendering (border, title, padding)
- Item state rendering (focused/selected visuals)
- View modifiers and environment propagation
Extract reusable components:
- FocusableItemListHandler base class
- SelectionStateManager for consistent selection
- Shared rendering utilities
Do NOT implement List/Table yet without this architecture.
- Add optional title parameter to List struct and init methods
- Title rendered above items with accent color
- Body items get horizontal padding (1 char left/right, 0 vertical)
- Focus indicators maintain full background width with padding
- Simplified ListPage: List is now self-contained with title parameter
- All 618 tests pass, 0 serious lint violations
- Maintains complete keyboard navigation and selection behavior
- Selected items: dimmed background (20% opacity) + accent text (NO dot)
- Focused items: pulsing dot indicator (no background)
- Unfocused items: plain text with padding
- Selection and focus are now visually distinct
- 618 tests passing
- Selection now works: Enter/Space selects focused row (by index)
- Selected rows show with full-width background bar (accent color)
- Focused rows show with pulsing dot indicator
- Unfocused rows show with padding space for alignment
- ListPage content now uses theme foreground colors (not white)
- Selection binding updates when user presses Enter/Space
- 618 tests passing
- Changed from resolveChildInfos() to direct buffer rendering
- Extract rows from rendered content buffer instead of child infos
- Fixes issue where only first item was displayed
- Works with single items and multiple items
- 617 tests passing
- Add .list case to DemoPage enum
- Add ListPage to menu with shortcut 9
- Shift Spinners to shortcut 0
- Update status bar menu range to include all shortcuts
- 617 tests passing
- isFocused now requires both focusedIndex match AND groupHasFocus
- Prevents focused items from appearing dimmed after group loses focus
- onFocusLost() resets focusedIndex to selected button for correct state
- Fixes visual state inconsistency when tabbing between groups
Behavior Change:
- Arrow Keys: navigate focus (focusedIndex) only, DON'T change selection
- Enter/Space: select the currently focused option (set binding)
- Selection remains unchanged while navigating with arrows
Visual Distinction:
- Selected (●) with pulsing when group has focus
- Focused but not selected (●) dimmed at 50% opacity
- Other options (◯) tertiary color
Rendering:
- Both selected and focused show ● indicator
- Different colors distinguish them: pulsing accent vs dimmed accent
Tests:
- Updated all handler tests to verify focus ≠ selection
- Arrow key tests now check focusedIndex changes but selection unchanged
- All 591 tests passing
UX:
- User can browse options with arrows without committing
- Press Enter/Space when desired option has focus to select
- macOS/SwiftUI pattern: focus and selection are independent
Problem:
- Horizontal group with Up press: group ignored Up, FocusManager navigated to previous group
- Vertical group with Left press: group ignored Left, FocusManager navigated to previous group
- User couldn't stay in group when pressing 'wrong' direction keys
Solution:
- All arrow keys always return true (consumed by group)
- Vertical: Up/Down navigate items, Left/Right do nothing
- Horizontal: Left/Right navigate items, Up/Down do nothing
- Never falls through to FocusManager for arrow key handling
Effect:
- User confined to current group (can't accidentally switch with arrow keys)
- Only Tab exits group to next focusable element
- Consistent UX: arrow keys always used for in-group navigation
Testing:
- All 591 tests passing
- No test changes needed (behavior was implicit)
Behavior:
- Vertical groups: Up/Down wrap around (down at end → first, up at start → last)
- Horizontal groups: Left/Right wrap around (right at end → first, left at start → last)
- Always return true (event consumed) — never falls through to FocusManager
- Enables smooth cycling through options without leaving the group
Implementation:
- Modified handleKeyEvent to use ternary wrap logic
- All navigation keys now always consumed (return true)
- Tab still exits group to next focusable element
Testing:
- Updated 'boundaryNavigation' test to verify wrapping behavior
- Test now checks that Up at start wraps to last item
- All 591 tests passing
User Experience:
- Stay in group when cycling options with arrow keys
- Tab exits to next group (standard radio group UX)
Problem:
- focusID was generated fresh on every render: 'radio-group-<UUID>'
- Each render got different UUID → isFocused() always compared new IDs to old
- groupHasFocus was always false → pulsing never triggered
Solution:
- Store focusID in StateStorage (like focusedIndex and other persistent state)
- Generate from context.identity.path if not explicitly provided
- Reuse same focusID across all renders
- Now focusManager.isFocused(id:) comparison works correctly
Architecture:
- focusID now optional in RadioButtonGroup, derived during render
- Persistent in stateStorage key with propertyIndex: 1
- Handler uses persistedFocusID for all focus operations
Behavior:
- Selected indicator in focused group now pulses correctly
- Tab navigation between groups works
- Arrow keys navigate within group
Documentation:
- Updated focusID parameter doc
- Added comment explaining persistence requirement
Testing:
- All 591 tests passing
Problem:
- isFocused was computed as: groupHasFocus && handler.focusedIndex == index
- This meant unfocused groups never showed accent color for focused radio buttons
- Pulse never triggered because groupHasFocus was false for unfocused groups
Solution:
- isFocused = handler.focusedIndex == index (which radio button has keyboard focus)
- groupHasFocus = focusManager.isFocused(id: focusID) (which group is active)
- These are independent — a radio can be focused even if group isn't
Behavior:
- Focused group: selected (●) pulses, focused ◯ shows accent, others tertiary
- Unfocused group: all static (selected=accent, focused=accent, others=tertiary)
Testing:
- All 591 tests passing
Problem:
- All groups rendered as if they had focus, all selected indicators pulsed
- Pulsing was based on isFocused (radio button index match) not group focus
Solution:
- Check groupHasFocus: focusManager.isFocused(id: focusID)
- Pass groupHasFocus to render functions
- Only pulse when BOTH: isFocused (this radio) AND groupHasFocus (this group)
Behavior:
- Unfocused groups: all indicators static (selected=accent, focused=accent, other=tertiary)
- Focused group: selected indicator pulses if it was the focused radio button
- Pulsing only on the group that actually has keyboard focus
Documentation:
- Added groupHasFocus parameter with doc comment
- Render logic updated with precise condition
Testing:
- All 591 tests passing
Color State Matrix:
Selected (●):
- If group has focus: pulsing accent (35% → 100%)
- If unfocused: static accent
Focused by Arrow Keys (◯):
- Always static accent (not selected yet)
Unselected & Unfocused (◯):
- foregroundTertiary (dimmed)
Disabled:
- All indicators: foregroundTertiary
Visual Intent:
- Selected indicator pulsing draws attention when group is active
- Focused indicator (static accent) shows keyboard position
- Tertiary color for 'inactive' options
Documentation:
- Updated render logic with detailed color comments
- Clear state branching: selected → focused → unfocused
Testing:
- All 591 tests passing
Visual Behavior:
- **Selected Radio Button** (●): Filled circle, selected by Space/Enter
- **Focused Radio Button** (◯): Pulsing accent color, navigated by arrow keys
- **Unselected/Unfocused** (◯): Border color
Focus Indicators:
- When group has focus, focused radio button pulses with accent (35% → 100%)
- Allows visual distinction between 'which is selected' vs 'which has keyboard focus'
- macOS/SwiftUI pattern: focus ≠ selection
Text Rendering:
- Radio button labels use theme colors (foregroundSecondary default from Text)
- Consistent with app palette, not hardcoded
Documentation:
- Added doc comment explaining focus vs selection distinction
- Render logic documented with clear state branches
Testing:
- All 591 tests passing
- Focus pulsing behavior verified
Problem:
- FocusManager.dispatchKeyEvent() processed Up/Down/Left/Right before asking focused element
- RadioButtonGroup (and other elements) couldn't handle arrow keys
- Only Tab worked, navigation keys were consumed by FocusManager
Solution:
- Reorder dispatch: first try currentFocused.handleKeyEvent()
- If element consumes event (returns true), stop
- Only if not handled, apply FocusManager defaults (Tab navigation, Up/Down between items)
Effect:
- RadioButtonGroup Up/Down/Left/Right navigation now works
- Tab still navigates between groups (FocusManager fallback)
- macOS/SwiftUI behavior: responder chain (element first, then system)
- All 591 tests passing
Documentation:
- Added inline comments explaining dispatch order and rationale
Implementation:
- RadioButtonGroupHandler now persisted in StateStorage (like @State values)
- focusedIndex maintained across renders, enabling Tab navigation between groups
- Handler properties (selection, itemValues) sync'd each render with current values
- Properties marked var to allow syncing with current render state
Architecture:
- Follows SwiftUI/macOS pattern: persistent handler maintains internal state
- StateStorage.StateKey with context.identity ensures identity across renders
- stateStorage.markActive() enables garbage collection of inactive state
- Radio button behavior now fully consistent with Button/Toggle patterns
Documentation:
- Added doc comment to RadioButtonGroupHandler explaining persistence
- focusedIndex property documented as persisted value
- Inline comments explain StateStorage integration
Testing:
- All 591 tests passing
- RadioButtonGroup Tab navigation now functional
- Arrow key navigation persists focus position across renders
- When first focusable component registers, auto-activate its section
- First element in active section auto-focuses (macOS/SwiftUI behavior)
- Enables Tab navigation between groups from the start
- All 591 tests passing
- Changed indicator from ◼ (U+25FC) to ● (large dot)
- More consistent with toggle indicator style
- Checkbox now shows [●] when on, [ ] when off
- Updated example page state summary
- All 591 tests passing
- RadioButtonPage shows vertical radio groups, horizontal radio groups, and disabled states
- Live state display for color, size, and layout choices
- Added to main menu as option 8
- Shortcut key '8' for quick navigation
- All 591 tests passing
- RadioButtonGroup<T> with generic Hashable selection
- RadioButtonItem builder with string or view labels (@ViewBuilder)
- Vertical (default) or horizontal layout with RadioButtonOrientation
- RadioButtonGroupHandler for arrow key navigation (up/down/left/right)
- Enter/Space to select, arrow keys to navigate
- Focus indicator with pulsing accent color
- Disabled state support
- 20 comprehensive tests (591 total passing)
- All patterns inherited from Button/Toggle (brackets pulse, etc.)
- Checkbox toggle now shows [●] when on, [ ] when off
- More consistent with dot-based toggle style [●○]
- Updated example page to show new checkbox indicator
- All 571 tests passing
- Mark 2026-02-06-toggle.md as completed
- Move Toggle to Completed section in to-dos.md
- Update test count to 571 in whats-next.md
- Update next-steps priority (TextInput → Table)
- Toggle struct with string initializer for SwiftUI API parity
- ToggleStyle enum: .toggle (slider ●○) and .checkbox ([x])
- ToggleHandler for Space/Enter keyboard events
- Focus indicator with pulsing accent dot (inherited from Button pattern)
- Disabled state with tertiary color
- .disabled() modifier for control
- Comprehensive tests (17 tests, 571 total passing)
- TogglePage example with both styles, disabled states, and live state demo
- Added to main menu with shortcut key 7
- Button focus: brackets pulse in accent color when focused
- Plain buttons: use pulsing dot indicator (no brackets)
- Button styles: default is now dimmer, primary is accent+bold
- Disabled buttons: darker appearance (50% tertiary)
- Shift+Tab: parse CSI Z sequence for backward focus navigation
- PulseTimer: reset to bright on focus change
- Faster pulse: 100ms steps (was 150ms)
- Shared helper: BorderRenderer.focusIndicatorPrefix() for future use