- 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
- 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
- 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
Previously darker(by: 0.5) subtracted 50 absolute lightness points.
Now it scales relative to current position: 0.5 means 'move halfway
toward the extreme'. More intuitive for humans — 20% darker always
means 20% of the remaining range toward black.
Remove the block and flat appearance systems entirely, keeping only the
four standard border-based appearances (line, rounded, doubleLine, heavy).
Remove BorderStyle.ascii preset. Clean up all stale references in doc
comments, DocC articles, and README.
- Remove Appearance.flat/.block and BlockPalette protocol
- Remove surface color tokens (surfaceBackground, surfaceHeaderBackground, elevatedBackground)
- Remove BorderStyle.block, .ascii, and related statics
- Remove flat/block rendering paths from all views
- Simplify BorderRenderer, BorderModifier, ContainerView, Menu, Button, StatusBar, AppHeader
- Fix BorderStyle doc examples (add missing right padding)
- Update DocC: PaletteReference, ThemingGuide, AppearanceAndColors, RenderCycle, TUIkit.md
- Update README palette references to SystemPalette
- Delete FlatThemePage from example app
- Remove related tests (526 tests / 84 suites passing)
Centralized NotificationService with .notificationHost() modifier for
rendering stacked, auto-dismissing notifications in the top-right corner.
Notifications use a single style with theme border colors — severity
differentiation belongs to Alerts, not notifications.
- NotificationService with static accessor and environment key
- NotificationHostModifier with fade-in/out animation and vertical stacking
- NotificationTiming for opacity interpolation and word-wrap
- Box(lines:) convenience init with BufferView for pre-styled content
- LifecycleManager.resetAppearance(token:) for re-triggering animations
- 17 tests covering service, timing, word-wrap, and rendering
Document the EquatableView/RenderCache system in the DocC article:
- How .equatable() works (5-step cache lookup)
- Cache invalidation triggers (State change, environment change)
- When to use / not use .equatable() (decision guide with tables)
- Which types support Equatable conformance
- Debug logging via TUIKIT_DEBUG_RENDER=1
- Code example using FeatureBox from the example app
Also updates existing sections:
- Correct 'no subtree memoization' claim in 'What Is NOT Diffed'
- Add EquatableView to Renderable lists (Direct Rendering, View-Level Modifiers)
Extract static, state-free subtrees into standalone View structs to
prepare for .equatable() memoization in Phase 5:
- FeatureBox: extracted from MainMenuPage's private featureBox() method
- ContainerTypesRow: Card/Box/Panel examples from ContainersPage
- SettingsAndAlignmentRow: settings panel + alignment demos
ButtonsPage left as-is — nearly all sections depend on @State clickCount
through Button actions, making decomposition ineffective for memoization.
Add RenderCache.Stats for cache performance tracking:
- Hit/miss/store/clear counters with hit rate calculation
- Per-frame delta via Stats.delta(since:) for meaningful frame-level logging
- TUIKIT_DEBUG_RENDER=1 env var enables stderr logging (zero cost when off)
- logDebug uses @autoclosure to avoid string allocation in production
Review fixes applied:
- F1: logFrameStats() now logs per-frame delta, not cumulative totals
- F2: clearAll() no longer reads entries.count unconditionally
- F3: Remove unnecessary Sendable from Stats (main-thread only type)
- F5: reset() now also clears stats and frame snapshot
- F7: Move EnvironmentSnapshot to RenderLoop.swift (private, where it belongs)
- F8: Build snapshot from EnvironmentValues, not from ThemeManager directly
Equatable views can opt into render caching with .equatable(). On cache hit,
the entire subtree is skipped and the cached FrameBuffer returned directly.
Cache is cleared on every @State change; GC removes stale entries per frame.