Refactor: Standardize UI card styling and remove em-dashes

- 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
This commit is contained in:
phranck
2026-02-07 00:24:19 +01:00
parent 0d10b0255d
commit cfa8b31631
70 changed files with 946 additions and 780 deletions
+9 -9
View File
@@ -1,6 +1,6 @@
# Copilot Instructions for TUIkit
TUIkit is a SwiftUI-like framework for building Terminal User Interfaces in pure Swift — no ncurses or C dependencies.
TUIkit is a SwiftUI-like framework for building Terminal User Interfaces in pure Swift: no ncurses or C dependencies.
## Build, Test & Lint
@@ -30,8 +30,8 @@ swift-format format -i -r Sources Tests
TUIkit uses two rendering paths:
1. **Composite views** — Implement `body` to compose other views. The renderer recurses into `body`.
2. **Primitive views** — Conform to `Renderable` protocol and produce a `FrameBuffer` directly. Set `body: Never` (with `fatalError()`).
1. **Composite views**: Implement `body` to compose other views. The renderer recurses into `body`.
2. **Primitive views**: Conform to `Renderable` protocol and produce a `FrameBuffer` directly. Set `body: Never` (with `fatalError()`).
The `renderToBuffer(_:context:)` function checks `Renderable` first, then falls back to `body`.
@@ -41,10 +41,10 @@ The `renderToBuffer(_:context:)` function checks `Renderable` first, then falls
### Key Components
- **`FrameBuffer`** — 2D grid of styled cells representing terminal output
- **`RenderContext`** — Carries layout constraints, environment values, and `TUIContext`
- **`TUIContext`** — Central DI container for lifecycle, key events, preferences, state storage
- **`ViewIdentity`** — Structural identity path for `@State` persistence across renders
- **`FrameBuffer`**: 2D grid of styled cells representing terminal output
- **`RenderContext`**: Carries layout constraints, environment values, and `TUIContext`
- **`TUIContext`**: Central DI container for lifecycle, key events, preferences, state storage
- **`ViewIdentity`**: Structural identity path for `@State` persistence across renders
### Directory Structure
@@ -79,9 +79,9 @@ Public APIs **must** match SwiftUI signatures exactly unless terminal constraint
### Architecture Rules
- **No singletons** — All state flows through the Environment system
- **No singletons**: All state flows through the Environment system
- **Consolidate existing functions** before adding new ones
- **Never merge PRs autonomously** — Stop after creating, let the user merge
- **Never merge PRs autonomously**: Stop after creating, let the user merge
### Testing
+32 -32
View File
@@ -11,7 +11,7 @@
> [!TIP]
> **☕ Support TUIkit Development**
>
> If you enjoy TUIkit and find it useful, consider supporting its development! Your donations help cover ongoing costs like hosting, tooling, and the countless cups of coffee that fuel late-night coding sessions. Every contribution — big or small — is greatly appreciated and keeps this project alive. Thank you! 💙
> If you enjoy TUIkit and find it useful, consider supporting its development! Your donations help cover ongoing costs like hosting, tooling, and the countless cups of coffee that fuel late-night coding sessions. Every contribution: big or small: is greatly appreciated and keeps this project alive. Thank you! 💙
>
> [![Donate via PayPal](https://img.shields.io/badge/Donate-PayPal-blue?logo=paypal&logoColor=white)](https://paypal.me/LAYEREDwork)
> [![Support on Ko-fi](https://img.shields.io/badge/Support-Ko--fi-FF5E5B?logo=ko-fi&logoColor=white)](https://ko-fi.com/layeredwork)
@@ -19,7 +19,7 @@
> [!IMPORTANT]
> **This project is currently a WORK IN PROGRESS! I strongly advise against using it in a production environment because APIs are subject to change at any time.**
A SwiftUI-like framework for building Terminal User Interfaces in Swift — no ncurses, no C dependencies, just pure Swift.
A SwiftUI-like framework for building Terminal User Interfaces in Swift: no ncurses, no C dependencies, just pure Swift.
## What is this?
@@ -63,34 +63,34 @@ struct ContentView: View {
### Core
- **`View` protocol** — the core building block, mirroring SwiftUI's `View`
- **`@ViewBuilder`** — result builder for declarative view composition
- **`@State`** — reactive state management with automatic re-rendering
- **`@Environment`** — dependency injection for theme, focus manager, status bar
- **`App` protocol** — app lifecycle with signal handling and run loop
- **`View` protocol**: the core building block, mirroring SwiftUI's `View`
- **`@ViewBuilder`**: result builder for declarative view composition
- **`@State`**: reactive state management with automatic re-rendering
- **`@Environment`**: dependency injection for theme, focus manager, status bar
- **`App` protocol**: app lifecycle with signal handling and run loop
### Views & Components
- **Primitive views** — `Text`, `EmptyView`, `Spacer`, `Divider`
- **Layout containers** — `VStack`, `HStack`, `ZStack` with alignment and spacing
- **Interactive** — `Button` with focus states, `Menu` with keyboard navigation
- **Containers** — `Alert`, `Dialog`, `Panel`, `Box`, `Card`
- **`StatusBar`** — context-sensitive keyboard shortcuts
- **`ForEach`** — iterate over collections, ranges, or `Identifiable` data
- **Primitive views**: `Text`, `EmptyView`, `Spacer`, `Divider`
- **Layout containers**: `VStack`, `HStack`, `ZStack` with alignment and spacing
- **Interactive**: `Button` with focus states, `Menu` with keyboard navigation
- **Containers**: `Alert`, `Dialog`, `Panel`, `Box`, `Card`
- **`StatusBar`**: context-sensitive keyboard shortcuts
- **`ForEach`**: iterate over collections, ranges, or `Identifiable` data
### Styling
- **Text styling** — bold, italic, underline, strikethrough, dim, blink, inverted
- **Full color support** — ANSI colors, 256-color palette, 24-bit RGB, hex values, HSL
- **Theming** — 6 predefined palettes (Green, Amber, Red, Violet, Blue, White)
- **Border styles** — rounded, line, double, thick, ASCII, and more
- **Text styling**: bold, italic, underline, strikethrough, dim, blink, inverted
- **Full color support**: ANSI colors, 256-color palette, 24-bit RGB, hex values, HSL
- **Theming**: 6 predefined palettes (Green, Amber, Red, Violet, Blue, White)
- **Border styles**: rounded, line, double, thick, ASCII, and more
### Advanced
- **Lifecycle modifiers** — `.onAppear()`, `.onDisappear()`, `.task()`
- **Storage** — `@AppStorage`, `@SceneStorage` with JSON backend
- **Preferences** — bottom-up data flow with `PreferenceKey`
- **Focus system** — Tab/Shift+Tab navigation between interactive elements
- **Lifecycle modifiers**: `.onAppear()`, `.onDisappear()`, `.task()`
- **Storage**: `@AppStorage`, `@SceneStorage` with JSON backend
- **Preferences**: bottom-up data flow with `PreferenceKey`
- **Focus system**: Tab/Shift+Tab navigation between interactive elements
## Run the Example App
@@ -136,19 +136,19 @@ struct MyApp: App {
```
Available palettes (all via `SystemPalette`):
- `.green` — Classic P1 phosphor CRT (default)
- `.amber` — P3 phosphor monochrome
- `.red` — IBM 3279 plasma
- `.violet` — Retro sci-fi terminal
- `.blue` — VFD/LCD displays
- `.white` — DEC VT100/VT220 (P4 phosphor)
- `.green`: Classic P1 phosphor CRT (default)
- `.amber`: P3 phosphor monochrome
- `.red`: IBM 3279 plasma
- `.violet`: Retro sci-fi terminal
- `.blue`: VFD/LCD displays
- `.white`: DEC VT100/VT220 (P4 phosphor)
## Architecture
- **No singletons for state** — All state flows through the Environment system
- **Pure ANSI rendering** — No ncurses or other C dependencies
- **Linux compatible** — Works on macOS and Linux (XDG paths supported)
- **Value types** — Views are structs, just like SwiftUI
- **No singletons for state**: All state flows through the Environment system
- **Pure ANSI rendering**: No ncurses or other C dependencies
- **Linux compatible**: Works on macOS and Linux (XDG paths supported)
- **Value types**: Views are structs, just like SwiftUI
## Project Structure
@@ -173,7 +173,7 @@ Tests/
## Developer Notes
- Tests use Swift Testing (`@Test`, `#expect`) — run with `swift test`
- Tests use Swift Testing (`@Test`, `#expect`): run with `swift test`
- All 591 tests run in parallel
- The `Terminal` class handles raw mode and cursor control via POSIX `termios`
@@ -56,10 +56,10 @@ Before the main loop starts, `run()` prepares the terminal:
In raw mode, the terminal delivers every keystroke immediately without waiting for Enter. TUIkit configures:
- **No echo** — typed characters are not displayed
- **No canonical mode** — input is byte-by-byte, not line-by-line
- **No signal processing** — Ctrl+C is handled by TUIkit, not the OS
- **100ms read timeout** — non-blocking input polling
- **No echo**: typed characters are not displayed
- **No canonical mode**: input is byte-by-byte, not line-by-line
- **No signal processing**: Ctrl+C is handled by TUIkit, not the OS
- **100ms read timeout**: non-blocking input polling
The original terminal settings are saved and restored during cleanup.
@@ -73,9 +73,9 @@ The main loop is synchronous and runs until shutdown:
Three things cause a new frame to be rendered:
- **SIGWINCH** — the terminal was resized
- **``AppState``** — a `@State` property was mutated
- **`SignalManager`** — `requestRerender()` was called (used by the state observer)
- **SIGWINCH**: the terminal was resized
- **``AppState``**: a `@State` property was mutated
- **`SignalManager`**: `requestRerender()` was called (used by the state observer)
All triggers set boolean flags that the main loop checks. The actual rendering always happens on the main thread.
@@ -88,7 +88,7 @@ All triggers set boolean flags that the main loop checks. The actual rendering a
| `SIGINT` | Ctrl+C | Sets a shutdown flag → main loop exits |
| `SIGWINCH` | Terminal resize | Sets a re-render flag → next iteration re-renders |
Signal handlers only set `nonisolated(unsafe)` boolean flags — no allocations, no locks. The main loop reads these flags each iteration and acts accordingly.
Signal handlers only set `nonisolated(unsafe)` boolean flags: no allocations, no locks. The main loop reads these flags each iteration and acts accordingly.
## Key Event Dispatch
@@ -100,7 +100,7 @@ When the terminal delivers a key event, the `InputHandler` dispatches it through
### Layer 2: View-Registered Handlers
The `KeyEventDispatcher` iterates handlers registered via `onKeyPress()` modifiers — in reverse order (newest first). If a handler returns `true`, dispatch stops.
The `KeyEventDispatcher` iterates handlers registered via `onKeyPress()` modifiers: in reverse order (newest first). If a handler returns `true`, dispatch stops.
### Layer 3: Default Bindings
@@ -137,7 +137,7 @@ Steps 8–11 are the output optimization layer: line-level diffing reduces write
## Cleanup
When the main loop exits — via Ctrl+C, the quit key, or programmatic shutdown — `cleanup()` restores the terminal:
When the main loop exits: via Ctrl+C, the quit key, or programmatic shutdown: `cleanup()` restores the terminal:
| Step | What | Why |
|------|------|-----|
@@ -6,8 +6,8 @@ Control border styles, visual appearances, and the color system.
TUIkit separates visual styling into two systems:
- **Appearance** — Controls border characters and container styling (rounded, doubleLine, heavy, etc.)
- **Colors** — A palette-aware color system with semantic tokens that resolve at render time
- **Appearance**: Controls border characters and container styling (rounded, doubleLine, heavy, etc.)
- **Colors**: A palette-aware color system with semantic tokens that resolve at render time
Both systems integrate with the theming pipeline described in <doc:ThemingGuide>.
@@ -101,7 +101,7 @@ let darker = color.darker(by: 0.3) // 30% darker
### In View Bodies (no RenderContext)
Use `Color.palette.*` — these return semantic tokens:
Use `Color.palette.*`: these return semantic tokens:
```swift
Text("Hello")
@@ -126,7 +126,7 @@ Available semantic tokens include:
### In renderToBuffer (with RenderContext)
Use `context.environment.palette.*` directly — these return concrete colors:
Use `context.environment.palette.*` directly: these return concrete colors:
```swift
func renderToBuffer(context: RenderContext) -> FrameBuffer {
@@ -28,7 +28,7 @@ Built-in views include ``Text``, ``Button``, ``Menu``, ``Alert``, ``Dialog``, ``
### 3. Modifier Layer
View modifiers implement the ``ViewModifier`` protocol and operate at the ``FrameBuffer`` level. They transform rendered output — adding padding, borders, frames, backgrounds, or overlays.
View modifiers implement the ``ViewModifier`` protocol and operate at the ``FrameBuffer`` level. They transform rendered output: adding padding, borders, frames, backgrounds, or overlays.
```swift
Text("Hello")
@@ -39,19 +39,19 @@ Text("Hello")
### 4. State & Environment Layer
- **``State``** — Mutable per-view state that triggers re-renders
- **``Binding``** — Two-way connection to a value owned elsewhere
- **``EnvironmentValues``** — Values propagated down the view tree
- **``AppStorage``** — Persistent key-value storage via `UserDefaults`
- **``State``**: Mutable per-view state that triggers re-renders
- **``Binding``**: Two-way connection to a value owned elsewhere
- **``EnvironmentValues``**: Values propagated down the view tree
- **``AppStorage``**: Persistent key-value storage via `UserDefaults`
### 5. Rendering Layer
The rendering pipeline converts the view tree into terminal output:
1. **View tree traversal** — Each view produces a ``FrameBuffer``
2. **Modifier application** — Modifiers transform buffers
3. **ANSI rendering** — The `ANSIRenderer` converts colors and styles to escape codes
4. **Terminal output** — The ``FrameBuffer`` lines are written to the terminal
1. **View tree traversal**: Each view produces a ``FrameBuffer``
2. **Modifier application**: Modifiers transform buffers
3. **ANSI rendering**: The `ANSIRenderer` converts colors and styles to escape codes
4. **Terminal output**: The ``FrameBuffer`` lines are written to the terminal
## Event Loop
@@ -38,7 +38,7 @@ struct StatusHeader: View {
}
```
The `body` property is annotated with `@ViewBuilder` at the protocol level, so you can use all result builder features — conditionals, optionals, loops:
The `body` property is annotated with `@ViewBuilder` at the protocol level, so you can use all result builder features: conditionals, optionals, loops:
```swift
struct UserCard: View {
@@ -160,7 +160,7 @@ Text("Important!").highlighted(.yellow)
### When to Use ViewModifier
Use ``ViewModifier`` when your transformation is a pure buffer-to-buffer operation — adding visual effects, changing backgrounds, or adjusting layout after rendering. The ``RenderContext`` gives you access to:
Use ``ViewModifier`` when your transformation is a pure buffer-to-buffer operation: adding visual effects, changing backgrounds, or adjusting layout after rendering. The ``RenderContext`` gives you access to:
| Property | Description |
|----------|-------------|
@@ -6,13 +6,13 @@ Navigate between interactive elements using the keyboard.
TUIkit provides a focus system that lets users move between interactive views (buttons, menus, text fields) using Tab, Shift+Tab, or arrow keys. The system consists of three parts:
- **``FocusManager``** — Tracks which element is focused, handles navigation
- **``Focusable``** — Protocol that views adopt to receive focus
- **``FocusState``** — Lightweight state object that views use to query and request focus
- **``FocusManager``**: Tracks which element is focused, handles navigation
- **``Focusable``**: Protocol that views adopt to receive focus
- **``FocusState``**: Lightweight state object that views use to query and request focus
## How Focus Works
Every frame, the ``FocusManager`` is cleared and interactive views re-register themselves during rendering. This means focus registrations are always in sync with the current view tree — removed views are automatically unregistered.
Every frame, the ``FocusManager`` is cleared and interactive views re-register themselves during rendering. This means focus registrations are always in sync with the current view tree: removed views are automatically unregistered.
The focus order follows the rendering order: the first focusable view rendered is first in the Tab cycle.
@@ -30,11 +30,11 @@ protocol Focusable: AnyObject {
}
```
- **`focusID`** — Unique identifier for this focusable element
- **`canBeFocused`** — Whether focus can move to this element (default: `true`)
- **`onFocusReceived()`** — Called when this element gains focus (default: no-op)
- **`onFocusLost()`** — Called when this element loses focus (default: no-op)
- **`handleKeyEvent(_:)`** — Handle a key event while focused; return `true` if consumed
- **`focusID`**: Unique identifier for this focusable element
- **`canBeFocused`**: Whether focus can move to this element (default: `true`)
- **`onFocusReceived()`**: Called when this element gains focus (default: no-op)
- **`onFocusLost()`**: Called when this element loses focus (default: no-op)
- **`handleKeyEvent(_:)`**: Handle a key event while focused; return `true` if consumed
## Using FocusState
@@ -52,7 +52,7 @@ if focusState.isFocused {
focusState.requestFocus()
```
Built-in views like ``Button`` and ``Menu`` create their own `FocusState` internally — you only need it when building custom focusable views.
Built-in views like ``Button`` and ``Menu`` create their own `FocusState` internally: you only need it when building custom focusable views.
## Navigation Keys
@@ -67,7 +67,7 @@ The ``FocusManager`` responds to these keys during dispatch:
## Focus Indicator
The currently focused element is rendered with **bold** text styling. There is no arrow or marker — bold is the sole visual indicator.
The currently focused element is rendered with **bold** text styling. There is no arrow or marker: bold is the sole visual indicator.
## Focus in the Event Loop
@@ -1,10 +1,10 @@
# Keyboard Shortcuts
How keyboard input flows through TUIkit — from raw terminal bytes to your view handlers.
How keyboard input flows through TUIkit: from raw terminal bytes to your view handlers.
## Overview
TUIkit uses a layered event dispatch system. When a key is pressed, it passes through up to three layers. The first layer that consumes the event wins — remaining layers are skipped.
TUIkit uses a layered event dispatch system. When a key is pressed, it passes through up to three layers. The first layer that consumes the event wins: remaining layers are skipped.
```
Terminal raw bytes
@@ -28,7 +28,7 @@ Terminal raw bytes
└─────────────────────────────┘
```
Additionally, `Ctrl+C` (SIGINT) is handled at the OS signal level **before** any of these layers — it always terminates the application.
Additionally, `Ctrl+C` (SIGINT) is handled at the OS signal level **before** any of these layers: it always terminates the application.
## Available Keys
@@ -89,9 +89,9 @@ public struct KeyEvent {
The terminal encodes modifiers differently from GUI frameworks:
- **Ctrl+letter** — Detected from ASCII control codes (0x01–0x1A). For example, `Ctrl+C` produces byte `0x03`.
- **Alt+key** — Detected from ESC prefix sequences (`ESC` followed by the key byte).
- **Shift** — Only auto-detected for uppercase letters. The terminal does not send distinct shift codes for most keys.
- **Ctrl+letter**: Detected from ASCII control codes (0x01–0x1A). For example, `Ctrl+C` produces byte `0x03`.
- **Alt+key**: Detected from ESC prefix sequences (`ESC` followed by the key byte).
- **Shift**: Only auto-detected for uppercase letters. The terminal does not send distinct shift codes for most keys.
## Registering Key Handlers
@@ -104,9 +104,9 @@ Text("Press any key")
.onKeyPress { event in
if event.key == .enter {
doSomething()
return true // consumed — stops propagation
return true // consumed: stops propagation
}
return false // not consumed — passes to next handler
return false // not consumed: passes to next handler
}
```
@@ -134,7 +134,7 @@ Text("Press Enter to continue")
### Handler Priority
Handlers are dispatched in **reverse registration order** — the deepest view in the tree (most recently registered) gets the event first. This means inner views can intercept events before outer views see them.
Handlers are dispatched in **reverse registration order**: the deepest view in the tree (most recently registered) gets the event first. This means inner views can intercept events before outer views see them.
```swift
VStack {
@@ -164,7 +164,7 @@ The ``FocusManager`` handles two navigation keys:
When an element is focused, all other key events are delegated to it first via `handleKeyEvent(_:)`. Only if the focused element doesn't consume the event does it propagate further.
Arrow keys are **not** handled by `FocusManager` itself — individual views (like ``Menu`` and ``Button``) handle arrows in their own key event handlers.
Arrow keys are **not** handled by `FocusManager` itself: individual views (like ``Menu`` and ``Button``) handle arrows in their own key event handlers.
For more details, see <doc:FocusSystem>.
@@ -187,7 +187,7 @@ The ``QuitBehavior`` enum controls when `q` is allowed to quit:
| `.always` | `q` quits from any screen (default) |
| `.rootOnly` | `q` only quits when no status bar context is pushed |
`.rootOnly` is useful for modal dialogs — push a status bar context for the dialog, and `q` will be blocked until the user dismisses it:
`.rootOnly` is useful for modal dialogs: push a status bar context for the dialog, and `q` will be blocked until the user dismisses it:
```swift
Dialog(title: "Confirm") {
@@ -285,7 +285,7 @@ StatusBarItem(shortcut: Shortcut.arrowsUpDown, label: "nav")
## Status Bar Context Stack
The status bar supports a context stack for temporary shortcut overrides — useful for modals and nested navigation:
The status bar supports a context stack for temporary shortcut overrides: useful for modals and nested navigation:
```swift
// Set global items
@@ -4,7 +4,7 @@ A visual reference for all built-in color palettes with their exact color values
## Overview
TUIkit ships with **6 palettes** — all generated from hand-tuned HSL parameters via ``SystemPalette``. Each palette defines semantic color tokens that the framework resolves at render time.
TUIkit ships with **6 palettes**: all generated from hand-tuned HSL parameters via ``SystemPalette``. Each palette defines semantic color tokens that the framework resolves at render time.
Users access palette colors via `Color.palette.*`:
@@ -24,7 +24,7 @@ environment.paletteManager.setCurrent(SystemPalette(.amber))
TUIkit uses a single palette protocol:
- **``Palette``** — 13 essential color tokens (8 required, 5 with defaults)
- **``Palette``**: 13 essential color tokens (8 required, 5 with defaults)
```
Palette (13 properties)
@@ -46,7 +46,7 @@ All 6 built-in palettes are instances of ``SystemPalette``, which conforms to ``
| **Semantic** | `success`, `warning`, `error`, `info` | Status indicators |
| **UI Elements** | `border` | Borders |
Only 8 tokens are required — the remaining have sensible defaults. See <doc:ThemingGuide> for details on creating custom palettes.
Only 8 tokens are required: the remaining have sensible defaults. See <doc:ThemingGuide> for details on creating custom palettes.
## Green (Default)
@@ -165,10 +165,10 @@ An algorithmically generated palette based on HSL color theory with a base hue o
The violet preset takes a base hue (270°) and derives all color tokens using HSL relationships:
- **Background** — Base hue at very low lightness (3%) with reduced saturation
- **Foregrounds** — Base hue at medium-high lightness (40–70%)
- **Accent** — Base hue at high lightness (78%) with high saturation
- **Semantic colors** — Derived from color theory offsets:
- **Background**: Base hue at very low lightness (3%) with reduced saturation
- **Foregrounds**: Base hue at medium-high lightness (40–70%)
- **Accent**: Base hue at high lightness (78%) with high saturation
- **Semantic colors**: Derived from color theory offsets:
- `success` = base + 120° (triadic)
- `warning` = base + 60° (analogous warm)
- `error` = base + 180° (complementary)
@@ -232,12 +232,12 @@ When pressing `t` to cycle themes, palettes rotate in this order:
When you write `.foregroundColor(.palette.accent)`, TUIkit resolves the actual color at render time:
1. **Declaration** — `Color.palette.accent` creates a `Color` with a semantic token (`.accent`)
2. **Render pass** — The current palette is read from `context.environment.palette`
3. **Resolution** — The semantic token maps to the palette's `accent` property
4. **ANSI output** — The resolved RGB color is converted to terminal escape codes
1. **Declaration**: `Color.palette.accent` creates a `Color` with a semantic token (`.accent`)
2. **Render pass**: The current palette is read from `context.environment.palette`
3. **Resolution**: The semantic token maps to the palette's `accent` property
4. **ANSI output**: The resolved RGB color is converted to terminal escape codes
This means the same view code produces different colors depending on the active palette — no code changes needed when switching themes.
This means the same view code produces different colors depending on the active palette: no code changes needed when switching themes.
## Topics
@@ -4,13 +4,13 @@ Pass data from child views up to their ancestors.
## Overview
TUIkit's preference system enables **bottom-up data flow** — the reverse of environment values. While the environment passes data down from parent to child, preferences let child views publish values that ancestors can observe and react to.
TUIkit's preference system enables **bottom-up data flow**: the reverse of environment values. While the environment passes data down from parent to child, preferences let child views publish values that ancestors can observe and react to.
The system mirrors SwiftUI's preference API and consists of three parts:
- **``PreferenceKey``** — Protocol that defines a named value type with a default and a reduce strategy
- **``PreferenceValues``** — Type-safe storage that holds all preference values for a scope
- **`PreferenceStorage`** — Stack-based collector that manages preference contexts per render pass
- **``PreferenceKey``**: Protocol that defines a named value type with a default and a reduce strategy
- **``PreferenceValues``**: Type-safe storage that holds all preference values for a scope
- **`PreferenceStorage`**: Stack-based collector that manages preference contexts per render pass
A built-in example is ``NavigationTitleKey``: a child view sets `.navigationTitle("Settings")`, and the enclosing navigation container reads that preference to render the title bar.
@@ -28,8 +28,8 @@ struct CounterKey: PreferenceKey {
}
```
- **`defaultValue`** — Returned when no child has set this preference
- **`reduce(value:nextValue:)`** — Combines values when multiple children set the same key
- **`defaultValue`**: Returned when no child has set this preference
- **`reduce(value:nextValue:)`**: Combines values when multiple children set the same key
The default `reduce` implementation simply takes the last value. Override it when you need additive behavior (summing counts), array collection, or other merge strategies.
@@ -90,11 +90,11 @@ struct SettingsPage: View {
Preferences are collected fresh every frame. Here's the lifecycle within a single render pass:
1. **Reset** — `RenderLoop.render()` calls `preferences.beginRenderPass()`, clearing all callbacks and resetting the stack to a single empty context
2. **Set** — As the view tree renders top-down, `PreferenceModifier` views call `setValue(_:forKey:)`, which reduces values into the current stack frame
3. **Scope** — `OnPreferenceChangeModifier` pushes a new context before rendering its subtree, isolating child preferences
4. **Collect** — After the subtree finishes, the modifier pops the context (merging into the parent) and fires the callback with the scoped result
5. **Discard** — At the start of the next frame, everything resets — stale preferences never persist
1. **Reset**: `RenderLoop.render()` calls `preferences.beginRenderPass()`, clearing all callbacks and resetting the stack to a single empty context
2. **Set**: As the view tree renders top-down, `PreferenceModifier` views call `setValue(_:forKey:)`, which reduces values into the current stack frame
3. **Scope**: `OnPreferenceChangeModifier` pushes a new context before rendering its subtree, isolating child preferences
4. **Collect**: After the subtree finishes, the modifier pops the context (merging into the parent) and fires the callback with the scoped result
5. **Discard**: At the start of the next frame, everything resets: stale preferences never persist
This per-frame reset ensures preferences always reflect the current view tree. Removed views stop contributing automatically.
@@ -104,7 +104,7 @@ The `reduce` function determines how multiple children's values combine. Common
| Strategy | Implementation | Use Case |
|----------|---------------|----------|
| Last value wins | `value = nextValue()` (default) | Titles, labels — deepest child wins |
| Last value wins | `value = nextValue()` (default) | Titles, labels: deepest child wins |
| Additive | `value += nextValue()` | Counting items, summing heights |
| Array collection | `value.append(contentsOf: nextValue())` | Gathering menu items, breadcrumbs |
@@ -1,10 +1,10 @@
# Render Cycle
Understand how TUIkit turns your view tree into terminal output — one frame at a time.
Understand how TUIkit turns your view tree into terminal output: one frame at a time.
## Overview
Every frame in TUIkit follows the same synchronous pipeline: **clear per-frame state → build environment → render the view tree → diff against previous frame → flush to terminal → track lifecycle**. The view tree is fully re-evaluated each frame, but only **changed terminal lines** are written — and all writes are collected in a frame buffer and flushed as a **single `write()` syscall**.
Every frame in TUIkit follows the same synchronous pipeline: **clear per-frame state → build environment → render the view tree → diff against previous frame → flush to terminal → track lifecycle**. The view tree is fully re-evaluated each frame, but only **changed terminal lines** are written: and all writes are collected in a frame buffer and flushed as a **single `write()` syscall**.
## What Triggers a Frame
@@ -16,7 +16,7 @@ Three things cause `RenderLoop` to produce a new frame:
| State mutation | `@State` property change | ``AppState`` notifies its observer via ``RenderNotifier``, which sets the rerender flag |
| Programmatic | `appState.setNeedsRender()` | Same observer path as above (services receive `AppState` via constructor injection) |
All triggers converge on boolean flags that the main loop checks each iteration. The actual rendering always happens on the main thread — signal handlers never render directly.
All triggers converge on boolean flags that the main loop checks each iteration. The actual rendering always happens on the main thread: signal handlers never render directly.
## The Render Pipeline
@@ -28,9 +28,9 @@ Each call to `RenderLoop.render()` executes these steps in order:
Three subsystems are reset at the start of every frame:
- **`KeyEventDispatcher`** — All key handlers are removed. Views re-register them during rendering via `onKeyPress()` modifiers.
- **`PreferenceStorage`** — All preference callbacks are cleared and the stack is reset to a single empty `PreferenceValues`.
- **``FocusManager``** — All focus registrations are cleared. Focusable views re-register during rendering.
- **`KeyEventDispatcher`**: All key handlers are removed. Views re-register them during rendering via `onKeyPress()` modifiers.
- **`PreferenceStorage`**: All preference callbacks are cleared and the stack is reset to a single empty `PreferenceValues`.
- **``FocusManager``**: All focus registrations are cleared. Focusable views re-register during rendering.
This ensures that views which disappeared between frames don't leave stale handlers or registrations behind.
@@ -61,21 +61,21 @@ A ``RenderContext`` bundles everything a view needs to render:
| Property | What |
|----------|------|
| `availableWidth` | Terminal width (mutable — containers reduce this for children) |
| `availableWidth` | Terminal width (mutable: containers reduce this for children) |
| `availableHeight` | Terminal height minus status bar (mutable) |
| `environment` | The ``EnvironmentValues`` from step 3 |
| `tuiContext` | The `TUIContext` (lifecycle, key dispatch, preferences, state storage) |
| `identity` | The current view's structural identity (``ViewIdentity``) |
`RenderContext` is a pure data container — it does not hold a reference to `Terminal`. All terminal I/O happens after the view tree has been rendered into a ``FrameBuffer``.
`RenderContext` is a pure data container: it does not hold a reference to `Terminal`. All terminal I/O happens after the view tree has been rendered into a ``FrameBuffer``.
The context is passed down the view tree. Each view can create a modified copy for its children — for example, a border reduces `availableWidth` by 2 before rendering its content. Container views extend the `identity` path for each child.
The context is passed down the view tree. Each view can create a modified copy for its children: for example, a border reduces `availableWidth` by 2 before rendering its content. Container views extend the `identity` path for each child.
### Step 5: Evaluate Scene
`app.body` is evaluated fresh each frame, producing a ``WindowGroup`` that wraps the root view. The `WindowGroup` implements `SceneRenderable` and bridges from the scene layer to the view layer.
> Note: Views are fully reconstructed on every frame. `@State` values survive because `State.init` self-hydrates from `StateStorage` — looking up the persistent value by the view's structural identity.
> Note: Views are fully reconstructed on every frame. `@State` values survive because `State.init` self-hydrates from `StateStorage`: looking up the persistent value by the view's structural identity.
### Step 6: Render View Tree
@@ -112,8 +112,8 @@ The status bar renders in a separate pass (see below) but writes into the **same
The `LifecycleManager` compares the current frame's tokens with the previous frame's:
- **Disappeared views** — tokens present last frame but absent now. Their `onDisappear` callbacks fire, and their tokens are removed from the appeared set (allowing future `onAppear` if they return).
- **Visible views** — the current token set becomes the baseline for the next frame.
- **Disappeared views**: tokens present last frame but absent now. Their `onDisappear` callbacks fire, and their tokens are removed from the appeared set (allowing future `onAppear` if they return).
- **Visible views**: the current token set becomes the baseline for the next frame.
The `StateStorage` performs garbage collection: any state whose view identity was not marked active during this render pass is removed. This prevents memory leaks from views that have been permanently removed.
@@ -125,7 +125,7 @@ The status bar renders in a separate pass but within the same buffered frame:
1. A ``StatusBar`` view is created with resolved palette colors
2. A dedicated ``RenderContext`` is created with `availableHeight` set to the status bar's height
3. `renderToBuffer()` runs on the status bar view — same dispatch as the main content
3. `renderToBuffer()` runs on the status bar view: same dispatch as the main content
4. `FrameDiffWriter.writeStatusBarDiff()` diffs the status bar independently from the main content
5. Changed lines are written into the same frame buffer as the content
@@ -140,19 +140,19 @@ TUIkit has two ways for a view to produce output:
Views that conform to `Renderable` implement `renderToBuffer(context:)` and produce a ``FrameBuffer`` directly. Their `body` property is **never called**.
This path is used by:
- **Leaf views** — ``Text``, ``Spacer``, `Divider`, ``EmptyView``
- **Layout containers** — `VStack`, `HStack`, `ZStack`
- **Interactive views** — ``Button``, ``ButtonRow``, ``Menu``
- **Container views** — ``Panel``, ``Card``, ``Alert``, ``Dialog``
- **Modifier wrappers** — `ModifiedView`, `BorderedView`, `DimmedModifier`, `OverlayModifier`, `EnvironmentModifier`, ``EquatableView``, and all lifecycle modifiers
- **Leaf views**: ``Text``, ``Spacer``, `Divider`, ``EmptyView``
- **Layout containers**: `VStack`, `HStack`, `ZStack`
- **Interactive views**: ``Button``, ``ButtonRow``, ``Menu``
- **Container views**: ``Panel``, ``Card``, ``Alert``, ``Dialog``
- **Modifier wrappers**: `ModifiedView`, `BorderedView`, `DimmedModifier`, `OverlayModifier`, `EnvironmentModifier`, ``EquatableView``, and all lifecycle modifiers
### Path 2: Composition (body)
Views that are **not** `Renderable` declare their content through `body`. The rendering system recursively renders the body until it hits a `Renderable` leaf.
This path is used by:
- **Composite views** — ``Box`` returns `content.border(...)`, which wraps in a `BorderedView` (which is `Renderable`)
- **User-defined views** — Your custom views compose other views in `body`
- **Composite views**: ``Box`` returns `content.border(...)`, which wraps in a `BorderedView` (which is `Renderable`)
- **User-defined views**: Your custom views compose other views in `body`
### The Dispatch Function
@@ -165,7 +165,7 @@ func renderToBuffer<V: View>(_ view: V, context: RenderContext) -> FrameBuffer {
return renderable.renderToBuffer(context: context)
}
// Priority 2: Composite — set up hydration context and recurse into body.
// Priority 2: Composite: set up hydration context and recurse into body.
// @State.init self-hydrates from StateStorage during body evaluation.
if V.Body.self != Never.self {
let childContext = context.withChildIdentity(type: V.Body.self)
@@ -175,14 +175,14 @@ func renderToBuffer<V: View>(_ view: V, context: RenderContext) -> FrameBuffer {
return renderToBuffer(body, context: childContext)
}
// Priority 3: No rendering path — empty buffer
// Priority 3: No rendering path: empty buffer
return FrameBuffer()
}
```
@Image(source: "render-cycle-dispatch.png", alt: "Decision tree showing the dual rendering dispatch: renderToBuffer checks Renderable conformance first, then body recursion, then returns an empty buffer as fallback.")
> Important: If a view conforms to `Renderable`, its `body` is never evaluated. This is intentional — `Renderable` views produce output directly and don't need compositional decomposition.
> Important: If a view conforms to `Renderable`, its `body` is never evaluated. This is intentional: `Renderable` views produce output directly and don't need compositional decomposition.
## FrameBuffer
@@ -192,9 +192,9 @@ func renderToBuffer<V: View>(_ view: V, context: RenderContext) -> FrameBuffer {
Views create buffers in their `renderToBuffer(context:)`:
- ``Text`` — single line with ANSI style codes
- ``Spacer`` — empty lines
- ``EmptyView`` — empty buffer (no lines)
- ``Text``: single line with ANSI style codes
- ``Spacer``: empty lines
- ``EmptyView``: empty buffer (no lines)
### Combination
@@ -235,17 +235,17 @@ The `EnvironmentModifier` (created by `.environment(_:_:)`) works by:
2. Creating a new `RenderContext` with that environment via `context.withEnvironment()`
3. Rendering its content with the new context
There is no global environment — everything flows through the context parameter.
There is no global environment: everything flows through the context parameter.
## Preference Collection
Preferences flow **bottom-up** — the reverse of environment values. Child views set values that parent views observe.
Preferences flow **bottom-up**: the reverse of environment values. Child views set values that parent views observe.
`PreferenceStorage` uses a stack-based collection mechanism:
1. `OnPreferenceChangeModifier` calls `push()` — creates a new collection scope
1. `OnPreferenceChangeModifier` calls `push()`: creates a new collection scope
2. Its child tree renders, and `PreferenceModifier` calls `setValue()` on the current scope
3. `OnPreferenceChangeModifier` calls `pop()` — merges collected values into the parent scope and fires the callback
3. `OnPreferenceChangeModifier` calls `pop()`: merges collected values into the parent scope and fires the callback
The `reduce(value:nextValue:)` function on ``PreferenceKey`` controls how multiple values from different children are combined. The default behavior: last value wins.
@@ -263,21 +263,21 @@ public protocol ViewModifier {
}
```
`ModifiedView` wraps a view and a modifier — it renders the content first, then calls `modify(buffer:context:)`. Examples:
`ModifiedView` wraps a view and a modifier: it renders the content first, then calls `modify(buffer:context:)`. Examples:
- **`PaddingModifier`** — Adds empty lines (top/bottom) and spaces (leading/trailing) around the buffer
- **`BackgroundModifier`** — Wraps each line with background ANSI codes, padded to full width
- **`PaddingModifier`**: Adds empty lines (top/bottom) and spaces (leading/trailing) around the buffer
- **`BackgroundModifier`**: Wraps each line with background ANSI codes, padded to full width
### View-Level Modifiers (Renderable)
More complex modifiers are full `View + Renderable` implementations that control when and how their content renders:
- **`BorderedView`** — Reduces `availableWidth` by 2, renders content, adds border characters via `BorderRenderer`
- **`FlexibleFrameView`** — Modifies `availableWidth`/`availableHeight` before rendering, applies min/max constraints and alignment after
- **`OverlayModifier`** — Renders base and overlay separately, composites via `FrameBuffer.composited(with:at:)`
- **`DimmedModifier`** — Renders content, then applies ANSI dim code to every line
- **`EnvironmentModifier`** — Creates modified context, renders content with it
- **``EquatableView``** — Checks ``RenderCache`` before rendering; returns cached buffer on hit, renders and stores on miss (see <doc:RenderCycle#Subtree-Memoization>)
- **`BorderedView`**: Reduces `availableWidth` by 2, renders content, adds border characters via `BorderRenderer`
- **`FlexibleFrameView`**: Modifies `availableWidth`/`availableHeight` before rendering, applies min/max constraints and alignment after
- **`OverlayModifier`**: Renders base and overlay separately, composites via `FrameBuffer.composited(with:at:)`
- **`DimmedModifier`**: Renders content, then applies ANSI dim code to every line
- **`EnvironmentModifier`**: Creates modified context, renders content with it
- **``EquatableView``**: Checks ``RenderCache`` before rendering; returns cached buffer on hit, renders and stores on miss (see <doc:RenderCycle#Subtree-Memoization>)
## Lifecycle Tracking
@@ -291,7 +291,7 @@ The `OnAppearModifier` calls `lifecycle.recordAppear(token, action)` during rend
- If the token has **never appeared before**: it's added to `appearedTokens` and the action fires
- If it **has** appeared before: the action does **not** fire (prevents repeated triggers)
> Note: `onAppear` fires **synchronously** during the render traversal — not after the frame completes. This is because TUIkit uses single-pass rendering with no layout phase.
> Note: `onAppear` fires **synchronously** during the render traversal: not after the frame completes. This is because TUIkit uses single-pass rendering with no layout phase.
### onDisappear
@@ -328,7 +328,7 @@ All terminal writes during a frame are collected in an internal `[UInt8]` buffer
### What Is NOT Diffed
The view tree is re-evaluated each frame — there is no virtual DOM. However, views wrapped in ``EquatableView`` (via `.equatable()`) can skip subtree rendering when their properties are unchanged. See <doc:RenderCycle#Subtree-Memoization> below.
The view tree is re-evaluated each frame: there is no virtual DOM. However, views wrapped in ``EquatableView`` (via `.equatable()`) can skip subtree rendering when their properties are unchanged. See <doc:RenderCycle#Subtree-Memoization> below.
The alternate screen buffer (entered during setup) ensures that the user's previous terminal content is preserved and restored on exit.
@@ -343,11 +343,11 @@ When a view is wrapped in `.equatable()`, the rendering system:
1. Looks up the cached ``FrameBuffer`` for this view's ``ViewIdentity``
2. Compares the **current view value** with the cached snapshot via `Equatable.==`
3. Checks that the available **width and height** haven't changed
4. On **cache hit**: returns the cached buffer — the entire subtree is skipped
4. On **cache hit**: returns the cached buffer: the entire subtree is skipped
5. On **cache miss**: renders normally and stores the result
```swift
// A static info box — title and subtitle are the only inputs.
// A static info box: title and subtitle are the only inputs.
struct FeatureBox: View, Equatable {
let title: String
let subtitle: String
@@ -362,7 +362,7 @@ struct FeatureBox: View, Equatable {
}
}
// In a parent view — cached between frames when title/subtitle are unchanged:
// In a parent view: cached between frames when title/subtitle are unchanged:
FeatureBox("Pure Swift", "No ncurses").equatable()
```
@@ -375,7 +375,7 @@ The ``RenderCache`` is **fully cleared** in two situations:
| Any `@State` change | `StateBox.value.didSet` calls `renderCache.clearAll()` |
| Environment change | `RenderLoop` compares an `EnvironmentSnapshot` (palette ID + appearance ID) each frame and clears on mismatch |
Between these events — for example during Spinner animation frames at 25 FPS — the cache is fully active. Static subtrees are rendered once and reused for every subsequent frame.
Between these events: for example during Spinner animation frames at 25 FPS: the cache is fully active. Static subtrees are rendered once and reused for every subsequent frame.
### When to Use `.equatable()`
@@ -387,7 +387,7 @@ Between these events — for example during Spinner animation frames at 25 FPS
| Bad candidates | Why |
|---------------|-----|
| Views that read `@State` directly | State lives in a reference-type box — the view struct compares as equal even when state changed |
| Views that read `@State` directly | State lives in a reference-type box: the view struct compares as equal even when state changed |
| Views that change every frame | Cache overhead with no benefit |
| Tiny views (single `Text`) | Rendering cost is already minimal |
@@ -413,7 +413,7 @@ Set `TUIKIT_DEBUG_RENDER=1` to enable per-frame cache statistics on stderr:
[RenderCache] STORE Root/MainMenuPage/FeatureBox
[RenderCache] HIT Root/MainMenuPage/FeatureBox
[RenderCache] MISS (no entry) Root/SpinnersPage/Spinner
[RenderCache] FRAME — hits: 3, misses: 2, stores: 2, clears: 0, entries: 3, hit rate: 60%
[RenderCache] FRAME: hits: 3, misses: 2, stores: 2, clears: 0, entries: 3, hit rate: 60%
```
Redirect with `2>render.log` to capture without interfering with the TUI.
@@ -100,12 +100,12 @@ struct SettingsView: View {
## How State Survives Re-Rendering
TUIkit re-evaluates the entire view tree on every frame. When `body` is called, views are
reconstructed from scratch. Despite this, `@State` values persist — they are never reset
reconstructed from scratch. Despite this, `@State` values persist: they are never reset
to their initial value.
### Structural Identity
Each view in the tree has a **structural identity** — a path like `"ContentView/VStack.0/Menu"`.
Each view in the tree has a **structural identity**: a path like `"ContentView/VStack.0/Menu"`.
This path is built automatically during rendering based on:
- The view's type name
- Its position among siblings (child index)
@@ -126,7 +126,7 @@ When a ``State`` value changes:
1. The ``StateBox`` triggers ``RenderNotifier/current`` → ``AppState/setNeedsRender()``
2. The observer registered by `AppRunner` requests a re-render
3. The main loop re-evaluates `app.body` fresh — reconstructing all views
3. The main loop re-evaluates `app.body` fresh: reconstructing all views
4. Each `@State.init` self-hydrates from `StateStorage`, recovering persisted values
5. The new ``FrameBuffer`` output is written to the terminal
@@ -136,4 +136,4 @@ Views that disappear from the tree (e.g., a conditional branch switches) have th
automatically cleaned up at the end of each render pass. `ConditionalView` also immediately
invalidates the inactive branch's state to prevent stale values.
This is simple and predictable — the view tree is fully re-evaluated each frame (no virtual DOM), with persistent state. Terminal output is then diffed at the line level — only changed lines are written. See <doc:RenderCycle> for details on the output optimization pipeline.
This is simple and predictable: the view tree is fully re-evaluated each frame (no virtual DOM), with persistent state. Terminal output is then diffed at the line level: only changed lines are written. See <doc:RenderCycle> for details on the output optimization pipeline.
@@ -6,15 +6,15 @@ Configure the shortcut bar at the bottom of the terminal.
The status bar is a persistent row at the bottom of the terminal that shows keyboard shortcuts and contextual information. It is always visible and updates every frame.
TUIkit provides two status bar styles — ``StatusBarStyle/compact`` (single-line, shortcuts only) and ``StatusBarStyle/bordered`` (bordered with title support).
TUIkit provides two status bar styles: ``StatusBarStyle/compact`` (single-line, shortcuts only) and ``StatusBarStyle/bordered`` (bordered with title support).
## Architecture
The status bar system has three parts:
- **``StatusBarState``** — Manages the item stack, style, and event handling
- **``StatusBarItem``** — A single shortcut entry (key + label + action)
- **``StatusBar``** — The view that renders items into a ``FrameBuffer``
- **``StatusBarState``**: Manages the item stack, style, and event handling
- **``StatusBarItem``**: A single shortcut entry (key + label + action)
- **``StatusBar``**: The view that renders items into a ``FrameBuffer``
## Defining Status Bar Items
@@ -84,11 +84,11 @@ These appear on the right side of the status bar. You can configure quit behavio
Two styles are available:
- **``StatusBarStyle/compact``** — Items rendered as `key Label` pairs in a single line, no border
- **``StatusBarStyle/bordered``** — Items inside a bordered container
- **``StatusBarStyle/compact``**: Items rendered as `key Label` pairs in a single line, no border
- **``StatusBarStyle/bordered``**: Items inside a bordered container
Set the style during app configuration or at runtime via the status bar state.
## Event Dispatch Priority
Status bar items are dispatched in **Layer 1** of the key event pipeline — they take priority over view-registered handlers and default bindings. See <doc:AppLifecycle> for the full dispatch order.
Status bar items are dispatched in **Layer 1** of the key event pipeline: they take priority over view-registered handlers and default bindings. See <doc:AppLifecycle> for the full dispatch order.
+8 -8
View File
@@ -10,7 +10,7 @@ A declarative, SwiftUI-like framework for building Terminal User Interfaces in S
## Overview
TUIkit lets you build terminal applications using a familiar, declarative syntax inspired by SwiftUI. No ncurses, no C dependencies — pure Swift.
TUIkit lets you build terminal applications using a familiar, declarative syntax inspired by SwiftUI. No ncurses, no C dependencies: pure Swift.
```swift
@main
@@ -32,13 +32,13 @@ struct MyApp: App {
### Key Features
- **Declarative syntax** — Build UIs with `VStack`, `HStack`, `Text`, `Button`, and more
- **SwiftUI-like API** — `@State`, `@ViewBuilder`, environment values, modifiers
- **Theming system** — 5 built-in phosphor themes with full RGB color support
- **Focus management** — Keyboard-driven navigation between interactive elements
- **Status bar** — Configurable shortcut bar with context stack
- **No dependencies** — Pure Swift, no ncurses or other C libraries
- **Cross-platform** — macOS and Linux
- **Declarative syntax**: Build UIs with `VStack`, `HStack`, `Text`, `Button`, and more
- **SwiftUI-like API**: `@State`, `@ViewBuilder`, environment values, modifiers
- **Theming system**: 5 built-in phosphor themes with full RGB color support
- **Focus management**: Keyboard-driven navigation between interactive elements
- **Status bar**: Configurable shortcut bar with context stack
- **No dependencies**: Pure Swift, no ncurses or other C libraries
- **Cross-platform**: macOS and Linux
## Topics
+6 -6
View File
@@ -167,8 +167,8 @@ export default function ActivityHeatmap({ weeks, loading = false }: ActivityHeat
if (loading) {
return (
<div className="rounded-xl border border-border bg-frosted-glass p-6 backdrop-blur-xl">
<h3 className="mb-4 flex items-center gap-2 text-xl font-semibold text-foreground">
<Icon name="calendar" size={22} className="text-muted" />
<h3 className="mb-4 flex items-center gap-3 text-xl font-semibold text-foreground">
<Icon name="calendar" size={20} className="text-accent" />
Commit Activity
</h3>
<div className="h-32 w-full rounded-md bg-accent/10 animate-skeleton" />
@@ -199,8 +199,8 @@ export default function ActivityHeatmap({ weeks, loading = false }: ActivityHeat
return (
<div ref={containerRef} className="rounded-xl border border-border bg-frosted-glass p-6 backdrop-blur-xl">
<h3 className="mb-4 flex items-center gap-2 text-xl font-semibold text-foreground">
<Icon name="calendar" size={22} className="text-muted" />
<h3 className="mb-4 flex items-center gap-3 text-xl font-semibold text-foreground">
<Icon name="calendar" size={20} className="text-accent" />
Commit Activity
</h3>
@@ -209,7 +209,7 @@ export default function ActivityHeatmap({ weeks, loading = false }: ActivityHeat
{/* Scrollable wrapper for mobile */}
<div className={scrollMode ? "overflow-x-auto pb-2" : ""}>
{/* Month labels — absolutely positioned above the cell grid */}
{/* Month labels: absolutely positioned above the cell grid */}
<div className="relative h-5" style={{ marginLeft: LABEL_WIDTH, minWidth: scrollMode ? colCount * (cellSize + CELL_GAP) : undefined }}>
{monthLabels.map(({ label, offset }) => (
<span
@@ -244,7 +244,7 @@ export default function ActivityHeatmap({ weeks, loading = false }: ActivityHeat
))}
</div>
{/* Cell grid — column-flow: 7 rows, columns auto-created per week */}
{/* Cell grid: column-flow: 7 rows, columns auto-created per week */}
<div
className="grid"
style={{
+1 -1
View File
@@ -206,7 +206,7 @@ export default function AvatarMarquee<T>({
}, []);
const handleMouseLeave = useCallback((event?: React.MouseEvent) => {
// If leaving to a child (popover), do nothing here — the popover handlers manage hide.
// If leaving to a child (popover), do nothing here: the popover handlers manage hide.
if (event) {
const related = event.relatedTarget as Node | null;
const popoverRoot = wrapperRef.current?.querySelector('.hover-popover-root') as Node | null;
+4 -4
View File
@@ -25,7 +25,7 @@ function cloudGradient(
export default function CloudBackground() {
return (
<div aria-hidden="true" className="pointer-events-none fixed inset-0 -z-10 overflow-hidden">
{/* Large cloud — top left */}
{/* Large cloud: top left */}
<div
className="absolute -left-32 -top-32 h-[800px] w-[800px] rounded-full blur-[150px] transition-[background] duration-700"
style={{
@@ -34,7 +34,7 @@ export default function CloudBackground() {
}}
/>
{/* Medium cloud — top right */}
{/* Medium cloud: top right */}
<div
className="absolute -right-20 top-1/4 h-[700px] w-[700px] rounded-full blur-[130px] transition-[background] duration-700"
style={{
@@ -43,7 +43,7 @@ export default function CloudBackground() {
}}
/>
{/* Accent cloud — center */}
{/* Accent cloud: center */}
<div
className="absolute left-1/3 top-1/2 h-[600px] w-[600px] rounded-full blur-[120px] transition-[background] duration-700"
style={{
@@ -72,7 +72,7 @@ export default function CloudBackground() {
}}
/>
{/* Extra cloud — bottom right */}
{/* Extra cloud: bottom right */}
<div
className="absolute -bottom-20 -right-32 h-[600px] w-[600px] rounded-full blur-[130px] transition-[background] duration-700"
style={{
+3 -3
View File
@@ -56,7 +56,7 @@ export default function CodePreview() {
);
}
/** Syntax highlight color palette — One Dark inspired. */
/** Syntax highlight color palette: One Dark inspired. */
const HIGHLIGHT = {
comment: "#6a737d",
decorator: "#d19a66",
@@ -66,7 +66,7 @@ const HIGHLIGHT = {
type: "#e5c07b",
} as const;
/** Minimal Swift syntax highlighter — no external dependency. */
/** Minimal Swift syntax highlighter: no external dependency. */
function Highlight({ code }: { code: string }) {
const lines = code.split("\n");
@@ -130,7 +130,7 @@ function tokenizeSegment(segment: string) {
</span>
);
} else if (match[3]) {
// Modifier (.bold, .foregroundColor) — add dot+name, then the ( back
// Modifier (.bold, .foregroundColor): add dot+name, then the ( back
parts.push(
<span key={match.index} style={{ color: HIGHLIGHT.modifier }}>
{match[3]}
+11 -11
View File
@@ -108,7 +108,7 @@ function CommitRow({
className="py-2.5 first:pt-0 last:pb-0"
>
<div className="flex items-center gap-2 min-w-0 sm:gap-3">
{/* Date/time — left, stacked with icon */}
{/* Date/time: left, stacked with icon */}
<div className="shrink-0 flex items-start gap-1 sm:gap-1.5">
<Icon name="clock" size={14} className="mt-0.5 text-muted/50 sm:h-4 sm:w-4" />
<div className="flex flex-col items-end font-mono text-[10px] leading-tight tabular-nums sm:text-xs">
@@ -117,7 +117,7 @@ function CommitRow({
</div>
</div>
{/* Disclosure chevron + title — center */}
{/* Disclosure chevron + title: center */}
<div className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden sm:gap-1.5">
{hasBody ? (
<button
@@ -135,7 +135,7 @@ function CommitRow({
<span className="block truncate text-sm text-foreground/90 sm:text-base">{commit.title}</span>
</div>
{/* SHA — hidden on mobile, visible on sm+ */}
{/* SHA: hidden on mobile, visible on sm+ */}
<a
href={commit.url}
target="_blank"
@@ -201,9 +201,9 @@ export default function CommitList({ commits, loading = false }: CommitListProps
if (loading) {
return (
<div className="overflow-hidden rounded-xl border border-border bg-frosted-glass p-6 backdrop-blur-xl">
<h3 className="mb-4 flex items-center gap-2 text-xl font-semibold text-foreground">
<Icon name="listBullet" size={22} className="text-muted" />
Recent Commits
<h3 className="mb-4 flex items-center gap-3 text-xl font-semibold text-foreground">
<Icon name="listBullet" size={20} className="text-accent" />
<span className="whitespace-nowrap">Commits</span>
</h3>
<div className="flex flex-col gap-3">
{Array.from({ length: INITIAL_COUNT }).map((_, idx) => (
@@ -220,8 +220,8 @@ export default function CommitList({ commits, loading = false }: CommitListProps
if (commits.length === 0) {
return (
<div className="overflow-hidden rounded-xl border border-border bg-frosted-glass p-6 backdrop-blur-xl">
<h3 className="mb-4 flex items-center gap-2 text-xl font-semibold text-foreground">
<Icon name="listBullet" size={22} className="text-muted" />
<h3 className="mb-4 flex items-center gap-3 text-xl font-semibold text-foreground">
<Icon name="listBullet" size={20} className="text-accent" />
Recent Commits
</h3>
<p className="text-lg text-muted">No commits found.</p>
@@ -236,9 +236,9 @@ export default function CommitList({ commits, loading = false }: CommitListProps
return (
<div className="overflow-hidden rounded-xl border border-border bg-frosted-glass p-4 backdrop-blur-xl sm:p-6">
<div className="mb-3 flex items-center justify-between sm:mb-4">
<h3 className="flex items-center gap-2 text-lg font-semibold text-foreground sm:text-xl">
<Icon name="listBullet" size={20} className="text-muted sm:h-[22px] sm:w-[22px]" />
<span className="whitespace-nowrap">Commits</span>
<h3 className="mb-4 flex items-center gap-3 text-xl font-semibold text-foreground">
<Icon name="listBullet" size={20} className="text-accent" />
Recent Commits
</h3>
{commitsWithBody.length > 0 && (
<button
+4 -4
View File
@@ -1,7 +1,7 @@
"use client";
import type { IconName } from "./Icon";
import IconBadge from "./IconBadge";
import Icon from "./Icon";
interface FeatureCardProps {
icon: IconName;
@@ -20,10 +20,10 @@ export default function FeatureCard({
className="group rounded-xl border border-border bg-frosted-glass p-6 backdrop-blur-xl transition-all duration-300 hover:border-accent/30"
>
<div className="mb-3 flex items-center gap-3">
<IconBadge name={icon} size={28} variant="lg" />
<h3 className="text-2xl font-semibold text-foreground">{title}</h3>
<Icon name={icon} size={20} className="text-accent" />
<h3 className="text-xl font-semibold text-foreground">{title}</h3>
</div>
<p className="text-xl leading-relaxed text-muted">{description}</p>
<p className="text-lg leading-relaxed text-muted">{description}</p>
</div>
);
}
+18 -18
View File
@@ -6,7 +6,7 @@ import { Howl } from "howler";
import TerminalScreen from "./TerminalScreen";
/**
* CRT layer geometry — centralizes the repeated calc() strings used
* CRT layer geometry: centralizes the repeated calc() strings used
* to position backing, content, glow, and glass layers over the logo.
*/
const CRT = {
@@ -22,7 +22,7 @@ const CRT = {
* CRT power-on animation timing.
* The vertical deflection coils need a moment to reach full amplitude,
* so the image starts as a bright horizontal line and expands vertically.
* Less dramatic than power-off — no visible dot phase.
* Less dramatic than power-off: no visible dot phase.
*/
const CRT_EXPAND_VERTICAL_MS = 300;
@@ -79,7 +79,7 @@ export default function HeroTerminal() {
const bootAudioRef = useRef<Howl | null>(null);
const spinAudioRef = useRef<Howl | null>(null);
const powerOffAudioRef = useRef<Howl | null>(null);
/** Reusable seek sound — avoids creating new Howl instances per seek. */
/** Reusable seek sound: avoids creating new Howl instances per seek. */
const seekAudioRef = useRef<Howl | null>(null);
/** Tracks all pending setTimeout handles for cleanup on power-off/unmount. */
const pendingTimersRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
@@ -124,7 +124,7 @@ export default function HeroTerminal() {
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true);
// Check if phone (< 768px) — tablets and larger keep the power button
// Check if phone (< 768px): tablets and larger keep the power button
const checkPhone = () => setIsPhone(window.innerWidth < 768);
checkPhone();
window.addEventListener("resize", checkPhone);
@@ -177,7 +177,7 @@ export default function HeroTerminal() {
spinAudioRef.current?.play();
}, SPIN_START_DELAY_MS);
// Recursive seek scheduling — each invocation picks a fresh random delay.
// Recursive seek scheduling: each invocation picks a fresh random delay.
// All timeouts go through scheduleTimer so clearAllTimers catches them.
const scheduleNextSeek = () => {
scheduleTimer(() => {
@@ -234,7 +234,7 @@ export default function HeroTerminal() {
);
// Complete: kill power immediately (screen is already black),
// then zoom back. Order matters — powered must be false before
// then zoom back. Order matters: powered must be false before
// shutdownPhase clears, otherwise the content flashes back briefly.
scheduleTimer(() => {
setPowered(false);
@@ -255,7 +255,7 @@ export default function HeroTerminal() {
return (
<>
{/* Dimming overlay — behind the zoomed terminal */}
{/* Dimming overlay: behind the zoomed terminal */}
<div
className="fixed inset-0 z-[100] bg-black/80 backdrop-blur-sm transition-opacity duration-500"
style={{
@@ -265,7 +265,7 @@ export default function HeroTerminal() {
onClick={handlePowerOff}
/>
{/* Terminal container — uses transform for smooth zoom from inline position */}
{/* Terminal container: uses transform for smooth zoom from inline position */}
<div
ref={containerRef}
className="relative transition-all duration-500 ease-in-out"
@@ -278,7 +278,7 @@ export default function HeroTerminal() {
: "translate(0, 0) scale(1)",
}}
>
{/* Layer 1: Black backing surface — behind the transparent frame center */}
{/* Layer 1: Black backing surface: behind the transparent frame center */}
<div
className="absolute"
style={{
@@ -288,7 +288,7 @@ export default function HeroTerminal() {
}}
/>
{/* Layer 2: Terminal content — above backing, below glow.
{/* Layer 2: Terminal content: above backing, below glow.
Boot-up: starts as bright horizontal line (scaleY≈0), expands vertically.
Shutdown: collapses vertically → horizontally → afterglow dot. */}
<div
@@ -322,7 +322,7 @@ export default function HeroTerminal() {
<TerminalScreen powered={powered} />
</div>
{/* CRT afterglow dot — bright phosphor dot that fades after the image collapses */}
{/* CRT afterglow dot: bright phosphor dot that fades after the image collapses */}
{shutdownPhase === 3 && (
<div
className="pointer-events-none absolute"
@@ -347,7 +347,7 @@ export default function HeroTerminal() {
</div>
)}
{/* Layer 3: CRT edge glow + scanline sweep — above content, below frame.
{/* Layer 3: CRT edge glow + scanline sweep: above content, below frame.
Inner glow simulates the edge darkening of a real CRT monitor.
Only visible when powered on and not shutting down. */}
<div
@@ -362,7 +362,7 @@ export default function HeroTerminal() {
transition: `box-shadow ${CRT_COLLAPSE_VERTICAL_MS}ms ease-out`,
}}
>
{/* Scanline sweep — cathode ray with sharp bottom edge, trailing upward */}
{/* Scanline sweep: cathode ray with sharp bottom edge, trailing upward */}
{powered && (
<div
style={{
@@ -402,22 +402,22 @@ export default function HeroTerminal() {
)}
</div>
{/* Layer 4: CRT glass sheen — permanent specular highlight on curved glass */}
{/* Layer 4: CRT glass sheen: permanent specular highlight on curved glass */}
<div
className="pointer-events-none absolute"
style={{
...CRT.backing,
background: [
/* Diagonal specular highlight — light reflecting off convex glass with theme tint */
/* Diagonal specular highlight: light reflecting off convex glass with theme tint */
"linear-gradient(135deg, rgba(var(--accent-glow), 0.15) 0%, rgba(var(--accent-glow), 0.05) 35%, transparent 60%)",
/* Soft edge vignette — darkens toward edges like curved glass */
/* Soft edge vignette: darkens toward edges like curved glass */
"radial-gradient(ellipse 80% 80% at 48% 45%, rgba(var(--accent-glow), 0.03) 0%, rgba(60,60,70,0.4) 100%)",
].join(", "),
zIndex: 4,
}}
/>
{/* CRT Monitor frame — on top of everything, transparent center reveals content */}
{/* CRT Monitor frame: on top of everything, transparent center reveals content */}
<Image
src="/tuikit-logo.png"
alt="TUIkit Logo"
@@ -428,7 +428,7 @@ export default function HeroTerminal() {
priority
/>
{/* Red power button — positioned over the physical button in the logo.
{/* Red power button: positioned over the physical button in the logo.
Disabled on phones (< 768px) to prevent the zoomed view on small screens. */}
{mounted && !isPhone && (
<button
+4 -4
View File
@@ -66,7 +66,7 @@ const sfIcons = {
/** Custom SVG icons not available in SF Symbols. */
const customIcons = {
/** Three horizontal lines — hamburger menu icon. */
/** Three horizontal lines: hamburger menu icon. */
line3Horizontal: (size: number) => (
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -84,7 +84,7 @@ const customIcons = {
<line x1="4" y1="18" x2="20" y2="18" />
</svg>
),
/** X mark — close icon. */
/** X mark: close icon. */
xmark: (size: number) => (
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -101,7 +101,7 @@ const customIcons = {
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
),
/** Two overlapping rectangles — standard copy-to-clipboard metaphor. */
/** Two overlapping rectangles: standard copy-to-clipboard metaphor. */
copy: (size: number) => (
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -164,7 +164,7 @@ interface IconProps {
className?: string;
}
/** Decorative icon wrapper — hidden from screen readers since adjacent text conveys meaning. */
/** Decorative icon wrapper: hidden from screen readers since adjacent text conveys meaning. */
export default function Icon({ name, size = 20, className }: IconProps) {
if (name in customIcons) {
return (
+1 -1
View File
@@ -20,7 +20,7 @@ const variantClasses = {
lg: "h-12 w-12",
} as const;
/** Icon badge with background — unified display for SF Symbols across the dashboard and pages. */
/** Icon badge with background: unified display for SF Symbols across the dashboard and pages. */
export default function IconBadge({
name,
size = 24,
+6 -6
View File
@@ -34,8 +34,8 @@ export default function LanguageBar({ languages, loading = false }: LanguageBarP
if (loading) {
return (
<div className="rounded-xl border border-border bg-frosted-glass p-6 backdrop-blur-xl">
<h3 className="mb-4 flex items-center gap-2 text-xl font-semibold text-foreground">
<Icon name="swift" size={22} className="text-muted" />
<h3 className="mb-4 flex items-center gap-3 text-xl font-semibold text-foreground">
<Icon name="swift" size={20} className="text-accent" />
Languages
</h3>
<div className="h-4 w-full rounded-full bg-accent/10 animate-skeleton" />
@@ -53,8 +53,8 @@ export default function LanguageBar({ languages, loading = false }: LanguageBarP
if (totalBytes === 0) {
return (
<div className="rounded-xl border border-border bg-frosted-glass p-6 backdrop-blur-xl">
<h3 className="mb-4 flex items-center gap-2 text-xl font-semibold text-foreground">
<Icon name="swift" size={22} className="text-muted" />
<h3 className="mb-4 flex items-center gap-3 text-xl font-semibold text-foreground">
<Icon name="swift" size={20} className="text-accent" />
Languages
</h3>
<p className="text-lg text-muted">No language data available.</p>
@@ -64,8 +64,8 @@ export default function LanguageBar({ languages, loading = false }: LanguageBarP
return (
<div className="rounded-xl border border-border bg-frosted-glass p-6 backdrop-blur-xl">
<h3 className="mb-4 flex items-center gap-2 text-xl font-semibold text-foreground">
<Icon name="swift" size={22} className="text-muted" />
<h3 className="mb-4 flex items-center gap-3 text-xl font-semibold text-foreground">
<Icon name="swift" size={20} className="text-accent" />
Languages
</h3>
+124 -37
View File
@@ -1,9 +1,55 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { usePlansCache } from "../hooks/usePlansCache";
import ReactMarkdown from "react-markdown";
import Icon from "./Icon";
/**
* Animated collapsible container.
* Measures content height via ref so it correctly animates open/close.
*/
function AnimatedCollapse({ expanded, children }: { expanded: boolean; children: React.ReactNode }) {
const contentRef = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState(0);
useEffect(() => {
const element = contentRef.current;
if (!element) return;
if (!expanded) {
setHeight(0);
return;
}
setHeight(element.scrollHeight);
const observer = new ResizeObserver(() => {
setHeight(element.scrollHeight);
});
observer.observe(element);
return () => observer.disconnect();
}, [expanded]);
return (
<div
className="overflow-hidden transition-all duration-300 ease-in-out"
style={{ maxHeight: expanded ? height : 0, opacity: expanded ? 1 : 0 }}
>
<div ref={contentRef}>
{children}
</div>
</div>
);
}
/** Number of plans shown before expanding. */
const COLLAPSED_COUNT = 1;
/** Maximum number of plans to display per section. */
const MAX_PLANS = 6;
interface Plan {
date: string;
slug: string;
@@ -21,12 +67,12 @@ function PlanItem({ plan, isDone }: { plan: Plan; isDone: boolean }) {
<div className="border-l-2 border-accent/30 pl-4 py-3">
{/* Date + Title */}
<div className="flex items-baseline gap-2">
<span className="text-xs font-mono text-muted/60">{month}/{day}</span>
<h4 className="text-sm font-semibold text-foreground">{plan.title}</h4>
<span className="text-sm font-mono text-muted/60">{year}-{month}-{day}</span>
<h4 className="text-lg font-semibold text-foreground">{plan.title}</h4>
</div>
{/* Preface with markdown rendering */}
<div className="mt-2 text-sm text-muted prose prose-sm max-w-none [&_strong]:font-semibold [&_strong]:text-foreground [&_em]:italic [&_em]:text-muted [&_code]:bg-background/50 [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-accent [&_a]:text-accent [&_a]:underline [&_a:hover]:no-underline">
<div className="mt-2 text-lg text-muted prose prose-lg max-w-none [&_strong]:font-semibold [&_strong]:text-foreground [&_em]:italic [&_em]:text-muted [&_code]:bg-background/50 [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-accent [&_a]:text-accent [&_a]:underline [&_a:hover]:no-underline">
<ReactMarkdown
components={{
p: ({ children }) => <p className="m-0 leading-relaxed">{children}</p>,
@@ -48,22 +94,81 @@ function PlanItem({ plan, isDone }: { plan: Plan; isDone: boolean }) {
}
/**
* Plans Card — displays top 5 open and top 5 done plans from plans.json.
* Collapsible section with header badge and expand/collapse toggle.
*/
function PlansSection({
title,
plans: allPlans,
isDone,
}: {
title: string;
plans: Plan[];
isDone: boolean;
}) {
const [expanded, setExpanded] = useState(false);
const plans = allPlans.slice(0, MAX_PLANS);
const hasMore = plans.length > COLLAPSED_COUNT;
const hiddenCount = plans.length - COLLAPSED_COUNT;
return (
<div>
{/* Section header with toggle */}
<button
onClick={() => hasMore && setExpanded(!expanded)}
className={`mb-3 inline-flex items-center gap-2 rounded bg-muted px-3 py-1 text-xs font-bold uppercase tracking-wider text-background ${hasMore ? "cursor-pointer hover:bg-muted/80" : "cursor-default"}`}
disabled={!hasMore}
>
{hasMore && (
<span className={`transition-transform duration-200 ${expanded ? "rotate-90" : ""}`}>
<Icon name="chevronRight" size={12} />
</span>
)}
{title}
{hasMore && !expanded && (
<span className="font-normal opacity-70">+{hiddenCount}</span>
)}
</button>
{/* Always visible plans */}
<div className="space-y-4">
{plans.slice(0, COLLAPSED_COUNT).map((plan) => (
<PlanItem key={plan.slug} plan={plan} isDone={isDone} />
))}
</div>
{/* Animated extra plans */}
{hasMore && (
<AnimatedCollapse expanded={expanded}>
<div className="space-y-4 pt-4">
{plans.slice(COLLAPSED_COUNT).map((plan) => (
<PlanItem key={plan.slug} plan={plan} isDone={isDone} />
))}
</div>
</AnimatedCollapse>
)}
</div>
);
}
/**
* Plans Card: displays top 5 open and top 5 done plans from plans.json.
* Includes markdown rendering for prefaces (bold, italics, code, links).
* Each section is collapsible, showing 2 plans by default.
*/
export default function PlansCard() {
const { data, loading, error, isFromCache } = usePlansCache();
if (loading) {
return (
<div className="rounded-lg border border-border/20 bg-gradient-to-br from-background to-background/50 p-6">
<div className="rounded-xl border border-border bg-frosted-glass p-6 backdrop-blur-xl">
<h3 className="mb-4 flex items-center gap-3 text-xl font-semibold text-foreground">
<Icon name="document" size={20} className="text-accent" />
Development Plans
</h3>
<div className="space-y-3">
<div className="h-6 w-32 animate-pulse rounded bg-muted/20" />
<div className="space-y-2">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-4 w-full animate-pulse rounded bg-muted/10" />
))}
</div>
{[...Array(3)].map((_, i) => (
<div key={i} className="h-16 w-full animate-skeleton rounded bg-accent/10" />
))}
</div>
</div>
);
@@ -71,33 +176,24 @@ export default function PlansCard() {
if (error || !data) {
return (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-6 text-sm text-red-400">
<div className="rounded-xl border border-red-500/30 bg-red-500/10 p-6 backdrop-blur-xl text-sm text-red-400">
<strong>Error loading plans:</strong> {error || "No data"}
</div>
);
}
return (
<div className="rounded-lg border border-border/20 bg-gradient-to-br from-background to-background/50 p-6">
<div className="rounded-xl border border-border bg-frosted-glass p-6 backdrop-blur-xl">
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<h2 className="text-lg font-bold text-foreground">Development Plans</h2>
<span className="text-xs text-muted/60">
{isFromCache && "cached"}
</span>
</div>
<h3 className="mb-4 flex items-center gap-3 text-xl font-semibold text-foreground">
<Icon name="document" size={20} className="text-accent" />
Development Plans
</h3>
{/* Open Plans Section */}
{data.open.length > 0 && (
<div className="mb-6">
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wider text-accent/80">
In Progress
</h3>
<div className="space-y-4">
{data.open.map((plan) => (
<PlanItem key={plan.slug} plan={plan} isDone={false} />
))}
</div>
<PlansSection title="Open" plans={data.open} isDone={false} />
</div>
)}
@@ -108,16 +204,7 @@ export default function PlansCard() {
{/* Done Plans Section */}
{data.done.length > 0 && (
<div>
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wider text-muted/60">
Recently Completed
</h3>
<div className="space-y-4">
{data.done.map((plan) => (
<PlanItem key={plan.slug} plan={plan} isDone={true} />
))}
</div>
</div>
<PlansSection title="Recently Completed" plans={data.done} isDone={true} />
)}
</div>
);
+2 -2
View File
@@ -68,7 +68,7 @@ export default function RainOverlay() {
strokeWidth: 0.8 + Math.random() * 0.5,
});
/** Initialize drop pool — spread across the full viewport. */
/** Initialize drop pool: spread across the full viewport. */
const drops: Raindrop[] = Array.from({ length: DROP_COUNT }, () => createDrop(false));
/**
@@ -93,7 +93,7 @@ export default function RainOverlay() {
attributeFilter: ["data-theme"],
});
/** Animation loop — clear, update, draw. Capped at ~30fps. */
/** Animation loop: clear, update, draw. Capped at ~30fps. */
const frame = (timestamp: number) => {
if (timestamp - lastFrameTime < FRAME_INTERVAL_MS) {
animationId = requestAnimationFrame(frame);
+5 -5
View File
@@ -1,6 +1,6 @@
"use client";
import IconBadge from "./IconBadge";
import Icon from "./Icon";
interface RepoInfoProps {
createdAt: string;
@@ -81,8 +81,8 @@ export default function RepoInfo({
if (loading) {
return (
<div className="rounded-xl border border-border bg-frosted-glass p-6 backdrop-blur-xl">
<h3 className="mb-4 flex items-center gap-2 text-xl font-semibold text-foreground">
<IconBadge name="serverRack" size={22} variant="md" className="!bg-transparent" />
<h3 className="mb-4 flex items-center gap-3 text-xl font-semibold text-foreground">
<Icon name="serverRack" size={20} className="text-accent" />
Repository
</h3>
<div className="flex flex-col gap-2">
@@ -96,8 +96,8 @@ export default function RepoInfo({
return (
<div className="rounded-xl border border-border bg-frosted-glass p-6 backdrop-blur-xl">
<h3 className="mb-4 flex items-center gap-2 text-xl font-semibold text-foreground">
<IconBadge name="serverRack" size={22} variant="md" className="!bg-transparent" />
<h3 className="mb-4 flex items-center gap-3 text-xl font-semibold text-foreground">
<Icon name="serverRack" size={20} className="text-accent" />
Repository
</h3>
<div className="flex flex-col">
+1 -1
View File
@@ -52,7 +52,7 @@ const DRIFT_FACTOR = 0.35;
* pulse softly, then fade out and reappear elsewhere.
*
* Some lights drift slowly toward the viewport center while shrinking,
* creating the illusion of flying into the scene — like distant Blade
* creating the illusion of flying into the scene: like distant Blade
* Runner spinner craft approaching through rain and haze.
*/
export default function SpinnerLights() {
+1 -1
View File
@@ -12,7 +12,7 @@ interface StatCardProps {
icon: IconName;
/** Whether data is still loading (shows skeleton). */
loading?: boolean;
/** Optional click handler — makes the card interactive. */
/** Optional click handler: makes the card interactive. */
onClick?: () => void;
/** Whether this card is currently in active/expanded state. */
active?: boolean;
+7 -7
View File
@@ -280,7 +280,7 @@ export default function TerminalScreen({ powered }: TerminalScreenProps) {
};
}, [powered]);
/** Main animation loop — only runs when powered. */
/** Main animation loop: only runs when powered. */
useEffect(() => {
if (!powered) return;
@@ -385,13 +385,13 @@ export default function TerminalScreen({ powered }: TerminalScreenProps) {
let delay: number;
if (char === " " || char === "." || char === "," || char === "?") {
/* Pause after word boundary or punctuation — thinking time. */
/* Pause after word boundary or punctuation: thinking time. */
delay = 250 + Math.random() * 350;
} else if (nextChar === " " || charIdx === text.length - 1) {
/* Slightly slower on last char of a word — finger lifting. */
/* Slightly slower on last char of a word: finger lifting. */
delay = 100 + Math.random() * 120;
} else if (Math.random() < 0.15) {
/* Occasional mid-word hesitation — hunting for the right key. */
/* Occasional mid-word hesitation: hunting for the right key. */
delay = 180 + Math.random() * 170;
} else {
/* Fast burst within a word. */
@@ -575,7 +575,7 @@ export default function TerminalScreen({ powered }: TerminalScreenProps) {
await sleep(PAUSE_AFTER_OUTPUT_MS);
}
} catch {
/* AbortError — powered off or unmounted. */
/* AbortError: powered off or unmounted. */
}
};
@@ -588,7 +588,7 @@ export default function TerminalScreen({ powered }: TerminalScreenProps) {
}, [powered, pickInteraction, pushLine, updateLastLine, clearScreen]);
/**
* CRT scanline glitch — randomly shifts multiple text lines
* CRT scanline glitch: randomly shifts multiple text lines
* horizontally in independent directions for a few frames,
* simulating an unstable electron beam. Each glitched line
* gets its own random offset. Fires every 3–8 seconds.
@@ -643,7 +643,7 @@ export default function TerminalScreen({ powered }: TerminalScreenProps) {
};
}, [powered]);
/* Powered off — no content, just dark glass. */
/* Powered off: no content, just dark glass. */
if (!powered) return null;
return (
+1 -1
View File
@@ -3,7 +3,7 @@
import { useTheme, themes, type Theme } from "./ThemeProvider";
/**
* Fixed foreground color per theme — used for the color dots so each dot
* Fixed foreground color per theme: used for the color dots so each dot
* always shows its own theme color regardless of the currently active theme.
* Values mirror the --foreground CSS variables in globals.css.
*/
+8 -8
View File
@@ -20,7 +20,7 @@ import RepoInfo from "../components/RepoInfo";
/**
* Formats a relative time string like "2 min ago" or "just now".
*
* Uses simple second/minute thresholds — no need for Intl.RelativeTimeFormat
* Uses simple second/minute thresholds: no need for Intl.RelativeTimeFormat
* since the maximum age before auto-refresh is 5 minutes.
*/
function formatTimeAgo(timestampMs: number): string {
@@ -47,7 +47,7 @@ function formatCountdown(targetMs: number): string {
}
/**
* Project Dashboard page — displays live GitHub metrics for the TUIKit repository.
* Project Dashboard page: displays live GitHub metrics for the TUIKit repository.
*
* Data is cached in localStorage for 5 minutes. Page reloads within that window
* serve cached data without hitting the GitHub API. A background timer
@@ -117,7 +117,7 @@ export default function DashboardPage() {
Live metrics · <a href="https://github.com/phranck/TUIkit" target="_blank" rel="noopener noreferrer" className="text-accent transition-colors hover:text-foreground">phranck/TUIkit</a>
</p>
</div>
{/* Animated refresh icon — fades in while refreshing, spins, then fades out */}
{/* Animated refresh icon: fades in while refreshing, spins, then fades out */}
<AnimatePresence>
{isRefreshing && (
<motion.div
@@ -144,7 +144,7 @@ export default function DashboardPage() {
</div>
)}
{/* Stat cards — row 1 */}
{/* Stat cards: row 1 */}
<div className="mb-4 grid grid-cols-2 gap-4 md:grid-cols-4">
<StatCard id="stat-card-stars" label="Stars" value={stats.stars} icon="star" loading={stats.loading} onClick={toggleStargazers} active={showStargazers} />
<StatCard id="stat-card-contributors" label="Contributors" value={stats.contributors} icon="person2" loading={stats.loading} />
@@ -152,7 +152,7 @@ export default function DashboardPage() {
<StatCard label="Releases" value={stats.releases} icon="shippingbox" loading={stats.loading} />
</div>
{/* Stargazers panel — expands between the two rows */}
{/* Stargazers panel: expands between the two rows */}
<div className={showStargazers ? "mb-4" : ""}>
<StargazersPanel
stargazers={stats.stargazers}
@@ -162,7 +162,7 @@ export default function DashboardPage() {
/>
</div>
{/* Stat cards — row 2 */}
{/* Stat cards: row 2 */}
<div className="mb-8 grid grid-cols-2 gap-4 md:grid-cols-4">
<StatCard label="Commits" value={stats.totalCommits} icon="numberCircle" loading={stats.loading} />
<StatCard label="Open Issues" value={stats.openIssues} icon="issue" loading={stats.loading} />
@@ -170,7 +170,7 @@ export default function DashboardPage() {
<StatCard label="Merged PRs" value={stats.mergedPRs} icon="merge" loading={stats.loading} />
</div>
{/* Activity heatmap — hidden on mobile */}
{/* Activity heatmap: hidden on mobile */}
<div className="mb-8 hidden sm:block">
<ActivityHeatmap weeks={stats.weeklyActivity} loading={stats.loading} />
</div>
@@ -196,7 +196,7 @@ export default function DashboardPage() {
<CommitList commits={stats.recentCommits} loading={stats.loading} />
</div>
{/* Footer: cache status + rate limit — stacked and centered on phones/tablets, side-by-side on desktop */}
{/* Footer: cache status + rate limit: stacked and centered on phones/tablets, side-by-side on desktop */}
<div className="flex flex-col items-center gap-2 font-mono text-xs text-muted/60 lg:flex-row lg:justify-between lg:text-sm">
<div className="flex flex-wrap items-center justify-center gap-x-2 gap-y-1 text-center lg:justify-start lg:text-left">
{lastFetchedAt && (
+1 -1
View File
@@ -28,7 +28,7 @@ export function useCopyToClipboard(resetDelayMs = 2000) {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setCopied(false), resetDelayMs);
} catch {
// Clipboard API unavailable or permission denied — silently degrade
// Clipboard API unavailable or permission denied: silently degrade
}
}, [resetDelayMs]);
+3 -3
View File
@@ -116,7 +116,7 @@ export interface GitHubStats {
rateLimit: { remaining: number; limit: number } | null;
}
/** Return type of the hook — stats plus manual refresh and data-fetch functions. */
/** Return type of the hook: stats plus manual refresh and data-fetch functions. */
export interface UseGitHubStatsReturn extends GitHubStats {
/** Re-fetch all data from the GitHub API (fire-and-forget, updates internal state). */
refresh: () => void;
@@ -328,7 +328,7 @@ export function useGitHubStats(options?: UseGitHubStatsOptions): UseGitHubStatsR
(async () => {
try {
const res = await ghFetch<WeeklyActivity[]>("/stats/commit_activity", signal);
// If GitHub returns an empty array, the stats endpoint may be pending (202) — try cache
// If GitHub returns an empty array, the stats endpoint may be pending (202): try cache
if (Array.isArray(res.data) && res.data.length > 0) return res;
// Attempt to read cached weeklyActivity from public JSON
@@ -484,7 +484,7 @@ export function useGitHubStats(options?: UseGitHubStatsOptions): UseGitHubStatsR
}
}, []);
/** Fire-and-forget refresh — triggers fetch but ignores the returned promise. */
/** Fire-and-forget refresh: triggers fetch but ignores the returned promise. */
const refresh = useCallback(() => {
doFetch().catch(() => {
/* errors are reflected in stats.error */
+11 -11
View File
@@ -35,7 +35,7 @@ export interface UseGitHubStatsCacheReturn extends GitHubStats {
}
// ---------------------------------------------------------------------------
// localStorage helpers — all reads/writes are wrapped in try/catch to handle
// localStorage helpers: all reads/writes are wrapped in try/catch to handle
// Safari Private Mode, full storage, or disabled storage gracefully.
// ---------------------------------------------------------------------------
@@ -51,7 +51,7 @@ function readCache(): CacheEntry | null {
}
return parsed;
} catch {
// Corrupt data or storage unavailable — clear and move on
// Corrupt data or storage unavailable: clear and move on
try {
localStorage.removeItem(CACHE_KEY);
} catch {
@@ -67,7 +67,7 @@ function writeCache(data: GitHubStats): number {
try {
localStorage.setItem(CACHE_KEY, JSON.stringify({ data, fetchedAt }));
} catch {
/* Storage full or unavailable — silently continue without cache */
/* Storage full or unavailable: silently continue without cache */
}
return fetchedAt;
}
@@ -76,14 +76,14 @@ function writeCache(data: GitHubStats): number {
* Caching wrapper around `useGitHubStats` that prevents redundant API calls.
*
* On mount the hook checks localStorage for a recent cache entry (< 5 min old).
* If valid cached data exists it is served immediately — no GitHub API call.
* If valid cached data exists it is served immediately: no GitHub API call.
* A background interval automatically refreshes data every 5 minutes.
*
* The `forceRefresh` function bypasses the cache but enforces a 60-second
* cooldown to prevent accidental rate-limit exhaustion.
*/
export function useGitHubStatsCache(): UseGitHubStatsCacheReturn {
// Skip the automatic fetch on mount — we decide whether to fetch based on cache freshness
// Skip the automatic fetch on mount: we decide whether to fetch based on cache freshness
const { fetchData, ...rawStats } = useGitHubStats({ skipInitialFetch: true });
const [overrideStats, setOverrideStats] = useState<GitHubStats | null>(null);
@@ -95,7 +95,7 @@ export function useGitHubStatsCache(): UseGitHubStatsCacheReturn {
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const initializedRef = useRef(false);
// The stats to expose — override (cached) data takes priority while it's set
// The stats to expose: override (cached) data takes priority while it's set
// Force loading: false when we have data, so components don't show skeletons during background refresh
const activeStats = overrideStats ?? rawStats;
const hasData = lastFetchedAt !== null;
@@ -123,7 +123,7 @@ export function useGitHubStatsCache(): UseGitHubStatsCacheReturn {
}, [fetchData]);
// -------------------------------------------------------------------------
// Mount: check cache — serve cached data or trigger a fresh fetch
// Mount: check cache: serve cached data or trigger a fresh fetch
// -------------------------------------------------------------------------
useEffect(() => {
@@ -134,13 +134,13 @@ export function useGitHubStatsCache(): UseGitHubStatsCacheReturn {
const now = Date.now();
if (cached && now - cached.fetchedAt < REFRESH_INTERVAL_MS) {
// Cache is fresh — serve it immediately, no API call needed
// Cache is fresh: serve it immediately, no API call needed
setOverrideStats({ ...cached.data, loading: false, error: null });
setLastFetchedAt(cached.fetchedAt);
setNextRefreshAt(cached.fetchedAt + REFRESH_INTERVAL_MS);
setIsFromCache(true);
} else {
// No valid cache — fetch fresh data now
// No valid cache: fetch fresh data now
doFetchAndCache();
}
}, [doFetchAndCache]);
@@ -188,7 +188,7 @@ export function useGitHubStatsCache(): UseGitHubStatsCacheReturn {
const forceRefresh = useCallback(() => {
if (lastFetchedAt !== null && Date.now() - lastFetchedAt < FORCE_REFRESH_COOLDOWN_MS) {
return; // Cooldown active — ignore
return; // Cooldown active: ignore
}
// Reset the interval so the next auto-refresh is a full REFRESH_INTERVAL_MS from now
if (intervalRef.current) clearInterval(intervalRef.current);
@@ -198,7 +198,7 @@ export function useGitHubStatsCache(): UseGitHubStatsCacheReturn {
}, REFRESH_INTERVAL_MS);
}, [lastFetchedAt, doFetchAndCache]);
// Override loading to false if we already have data — prevents skeleton flash during background refresh
// Override loading to false if we already have data: prevents skeleton flash during background refresh
const statsWithLoadingOverride = hasData
? { ...activeStats, loading: false }
: activeStats;
+1 -1
View File
@@ -67,7 +67,7 @@ export function useHoverPopover<T>(showDelay = 300, hideDelay = 150) {
}, []);
return {
/** Current hover state — null when not hovering. */
/** Current hover state: null when not hovering. */
hover,
/** Last known state for rendering (stable position during fade-out). */
popover: hover ?? lastRef.current,
+5 -5
View File
@@ -19,15 +19,15 @@ export const metadata: Metadata = {
alternates: {
canonical: "/",
},
title: "TUIkit — Terminal UI Framework for Swift",
title: "TUIkit: Terminal UI Framework for Swift",
description:
"A declarative, SwiftUI-like framework for building Terminal User Interfaces in Swift. No ncurses, no C dependencies — pure Swift.",
"A declarative, SwiftUI-like framework for building Terminal User Interfaces in Swift. Pure Swift with no ncurses or C dependencies.",
keywords: [
"swift", "terminal", "TUI", "framework", "SwiftUI", "CLI",
"ncurses alternative", "macOS", "Linux", "terminal UI", "declarative",
],
openGraph: {
title: "TUIkit — Terminal UI Framework for Swift",
title: "TUIkit: Terminal UI Framework for Swift",
description:
"Build terminal apps with SwiftUI-like syntax. Pure Swift, no ncurses.",
url: "https://tuikit.layered.work",
@@ -38,13 +38,13 @@ export const metadata: Metadata = {
url: "/og-image.png",
width: 1200,
height: 630,
alt: "TUIkit — Terminal UI Framework for Swift",
alt: "TUIkit: Terminal UI Framework for Swift",
},
],
},
twitter: {
card: "summary_large_image",
title: "TUIkit — Terminal UI Framework for Swift",
title: "TUIkit: Terminal UI Framework for Swift",
description:
"Build terminal apps with SwiftUI-like syntax. Pure Swift, no ncurses.",
images: ["/og-image.png"],
+10 -11
View File
@@ -1,6 +1,6 @@
import type { ReactNode } from "react";
import type { IconName } from "./components/Icon";
import IconBadge from "./components/IconBadge";
import CloudBackground from "./components/CloudBackground";
import RainOverlay from "./components/RainOverlay";
import SpinnerLights from "./components/SpinnerLights";
@@ -24,11 +24,11 @@ const TEST_COUNT = process.env.TUIKIT_TEST_COUNT ?? "0";
/** A single "Built right" highlight row with icon, title, and description. */
function ArchHighlight({ icon, title, children }: { icon: IconName; title: string; children: ReactNode }) {
return (
<div className="flex gap-4">
<IconBadge name={icon} size={24} variant="sm" className="!bg-accent/10" />
<div className="flex gap-3">
<Icon name={icon} size={20} className="text-accent mt-1" />
<div>
<h3 className="mb-1 text-xl font-semibold text-foreground">{title}</h3>
<p className="text-xl leading-relaxed text-muted">{children}</p>
<p className="text-lg leading-relaxed text-muted">{children}</p>
</div>
</div>
);
@@ -75,8 +75,7 @@ export default function Home() {
<p className="mb-10 max-w-2xl text-2xl leading-relaxed text-muted">
A declarative, SwiftUI-like framework for building Terminal User
Interfaces. No ncurses, no C dependencies — pure Swift on macOS and
Linux.
Interfaces. Pure Swift on macOS and Linux, with no ncurses or C dependencies.
</p>
<div className="flex flex-col gap-4 sm:flex-row">
@@ -94,17 +93,17 @@ export default function Home() {
</a>
</div>
{/* Swift Package badge — hidden on mobile */}
{/* Swift Package badge (hidden on mobile) */}
<div className="hidden sm:block">
<p className="mt-20 mb-4 max-w-2xl text-xl leading-relaxed text-muted">
Getting started is simple. Add TUIkit as a dependency to your
Swift package — no extra configuration, no system libraries to install:
Swift package. No extra configuration or system libraries required:
</p>
<PackageBadge />
</div>
</section>
{/* Code Preview Section — hidden on mobile */}
{/* Code Preview Section (hidden on mobile) */}
<section className="mx-auto hidden max-w-3xl px-6 pb-28 sm:block">
<CodePreview />
</section>
@@ -125,7 +124,7 @@ export default function Home() {
<FeatureCard
icon="terminal"
title="Declarative Syntax"
description="Build UIs with VStack, HStack, Text, Button, and more — the same patterns you know from SwiftUI."
description="Build UIs with VStack, HStack, Text, Button, and more. The same patterns you know from SwiftUI."
/>
<FeatureCard
icon="paintbrush"
@@ -140,7 +139,7 @@ export default function Home() {
<FeatureCard
icon="stack"
title="Rich Components"
description="Panel, Card, Dialog, Alert, Menu, Button, ForEach — container and interactive views out of the box."
description="Panel, Card, Dialog, Alert, Menu, Button, and ForEach. Container and interactive views out of the box."
/>
<FeatureCard
icon="bolt"
+90 -6
View File
@@ -1,11 +1,11 @@
{
"generated": "2026-02-06T22:08:27.166Z",
"generated": "2026-02-06T23:18:53.097Z",
"open": [
{
"date": "2026-02-06",
"slug": "containerization-composition",
"title": "Containerization: Composition NOT Inheritance in Swift",
"preface": "Composition replaces inheritance throughout the framework: Alert, Dialog, Panel, and Card all use the `renderContainer()` helper rather than inheriting from ContainerView. List and Table will follow the same pattern with `renderListWithFocus()` and `renderTableWithFocus()` helpers plus shared `FocusableItemListHandler`. This ensures consistency, maximizes code reuse, and keeps view definitions simple (plain structs) while rendering logic lives in testable helper functions — true to SwiftUI/TUIKit's design philosophy."
"preface": "Composition replaces inheritance throughout the framework: Alert, Dialog, Panel, and Card all use the `renderContainer()` helper rather than inheriting from ContainerView. List and Table will follow the same pattern with `renderListWithFocus()` and `renderTableWithFocus()` helpers plus shared `FocusableItemListHandler`. This ensures consistency, maximizes code reuse, and keeps view definitions simple (plain structs) while rendering logic lives in testable helper functions. Utrue to SwiftUI/TUIKit's design philosophy."
},
{
"date": "2026-02-06",
@@ -30,6 +30,24 @@
"slug": "imp-shared-handlers",
"title": "Implementation Plan: Shared Focus/Selection Handlers & Helpers",
"preface": "Shared focus/selection infrastructure is extracted so both List and Table reuse the same pieces: `FocusableItemListHandler` for keyboard navigation (Up/Down/Home/End), `SelectionStateManager<T>` for tracking selected values, `ItemStateRenderer` for styling based on focus/selection state, and `renderFocusableContainer()` helper that orchestrates layout + styling + scrolling."
},
{
"date": "2026-02-06",
"slug": "list-scrollable",
"title": "List (Scrollable)",
"preface": "List now gives TUI apps the power of SwiftUI's List: arbitrary nested views, ForEach with dynamic content, optional selection binding via `.tag()`, and keyboard navigation (Up/Down/Home/End/PageUp/PageDown) with auto-scrolling. Focused item always visible, scroll indicators show bounds, selection updates on Enter. MVP focuses on core scrollable list without sections. Uthey come later once the API is proven."
},
{
"date": "2026-02-06",
"slug": "list-table-shared-architecture",
"title": "List & Table: Shared Architecture Analysis",
"preface": "This analysis identifies the shared architecture between List and Table before implementation: both need focus management, keyboard navigation (Up/Down/Home/End), selection binding, scrolling, and item state rendering. Navigation logic and selection state are identical; rendering differs (List = vertical stack, Table = grid). Extract shared components (handlers, helpers, state managers) to eliminate duplication while letting each component specialize in layout."
},
{
"date": "2026-02-02",
"slug": "state-persistence",
"title": "State Persistence (Session Continuity / Crash Recovery)",
"preface": "Session state now survives app crashes with `@RestoredState`. Ua disk-persisted wrapper around `@State`. Values auto-save on change (debounced) and auto-restore on startup. Signal handlers (SIGTERM/SIGINT) flush state before shutdown so no data is lost. Perfect for file managers, editors, or any app tracking last directory, scroll position, or open files. Builds on the State Storage Identity system to keep state stable across renders while adding optional persistence."
}
],
"done": [
@@ -42,13 +60,13 @@
{
"date": "2026-02-05",
"slug": "flat-appearance",
"title": "Remove Block/Flat Appearances — Simplify to Border-Only Rendering",
"preface": "Block and Flat appearances are both removed (36 files changed). The framework now offers four border-based appearances: line, rounded, doubleLine, heavy — all using standard Unicode box-drawing characters. Surface color tokens are consolidated into `Palette` directly (no more `BlockPalette` protocol). Simplified architecture, eliminated half-block complexity that broke on many terminals, and gained universal compatibility using only ANSI backgrounds and standard borders."
"title": "Remove Block/Flat Appearances: Simplify to Border-Only Rendering",
"preface": "Block and Flat appearances are both removed (36 files changed). The framework now offers four border-based appearances: line, rounded, doubleLine, heavy. Uall using standard Unicode box-drawing characters. Surface color tokens are consolidated into `Palette` directly (no more `BlockPalette` protocol). Simplified architecture, eliminated half-block complexity that broke on many terminals, and gained universal compatibility using only ANSI backgrounds and standard borders."
},
{
"date": "2026-02-05",
"slug": "progress-view",
"title": "ProgressView — Determinate Progress Bar",
"title": "ProgressView: Determinate Progress Bar",
"preface": "`ProgressView` adds progress bars to TUI apps: horizontal bars that fill to a percentage (0–100), with five Unicode styles (block, blockFine, shade, bar, dot), optional labels, and SwiftUI API parity. Supports `BinaryFloatingPoint` values with total, ViewBuilder label closures, and currentValueLabel for custom percentage display. Optional `.disabled()` modifier, proper width clamping, and edge-case handling for nil/negative/overflow values."
},
{
@@ -60,8 +78,74 @@
{
"date": "2026-02-05",
"slug": "social-lookup-optimization",
"title": "Social Lookup Script — Matching Algorithm Optimization",
"title": "Social Lookup Script: Matching Algorithm Optimization",
"preface": "Social lookup script is optimized to eliminate false positives: instance validation via NodeInfo (confirms domains run ActivityPub software), expanded blocklist for link-in-bio services, leading-`@` requirement for Mastodon handles (rejects corporate emails), and preservation of `verified: true` for authoritative sources (GitHub profile, Keybase, manual overrides). Full refresh overwrites stale entries. 21 known Mastodon instances searched. Cleaner cache, no manual corrections needed."
},
{
"date": "2026-02-04",
"slug": "dashboard-avatar-marquee",
"title": "Plan: Dashboard Redesign - Avatar Marquee",
"preface": "A generic `AvatarMarquee` component now displays scrolling avatar lists (stargazers, contributors, etc.) with smooth infinite scroll, fade edges, and hover popovers. The marquee arrow targets the relevant stat card above (Stars, Contributors, etc.). Hover decelerates scroll smoothly; mouseleave accelerates it back. Generic TypeScript props make it reusable for any avatar collection. The Stargazers panel now uses this component, replacing a static grid."
},
{
"date": "2026-02-04",
"slug": "project-dashboard",
"title": "Project Dashboard: Live GitHub Metrics Page",
"preface": "A `/dashboard` page now displays live GitHub metrics: stat cards (commits, stars, forks, PRs, contributors, branches, tags, releases), a 52-week commit heatmap (GitHub-style), language breakdown bar, recent commits with expandable messages, and repo metadata. All data fetched client-side via GitHub REST API (no token required). Manual refresh button + rate limit display in footer. Phosphor-themed retro styling matches the landing page. Everything loads in parallel for snappy performance."
},
{
"date": "2026-02-04",
"slug": "stargazer-mastodon-lookup",
"title": "Stargazer Mastodon Lookup: Automatic Account Discovery",
"preface": "A scheduled GitHub Action discovers Mastodon accounts for stargazers: searches GitHub bios/blogs for Mastodon handles using regex, tries usernames on known instances, caches results in `mastodon-cache.json`, and merges with live stargazers at runtime. Dashboard popover shows the Mastodon icon + link. Incremental updates (every 2h) find new stargazers; weekly full refresh catches updated bios. (Later expanded to Twitter and Bluesky in the multi-platform social lookup plan.)"
},
{
"date": "2026-02-04",
"slug": "stargazer-social-lookup",
"title": "Stargazer Social Account Lookup: Multi-Platform Discovery",
"preface": "Multi-platform social lookup now discovers Mastodon, Twitter, and Bluesky accounts for stargazers: searches GitHub bios/blogs/profile fields, validates domains via NodeInfo, searches usernames on known instances, and caches results. Scheduled GitHub Action runs every 2h (incremental for new stargazers) and weekly (full refresh for updated bios). Dashboard popover shows social icons + links for all three platforms. Better validation eliminates false positives (corporate emails, link-in-bio services)."
},
{
"date": "2026-02-03",
"slug": "focus-sections-statusbar",
"title": "Focus Sections with StatusBar Cascading",
"preface": "Focus Sections enable multi-panel TUIs where Tab switches between named focusable areas and the StatusBar displays context-sensitive shortcuts for each. StatusBar items cascade from the active section up to parents (merge) or stop cleanly (replace for modals). A breathing ● dot in the section border pulses via a dedicated PulseTimer, providing clear visual feedback on which section is active. Cascading composition and per-section shortcuts make complex layouts intuitive."
},
{
"date": "2026-02-03",
"slug": "palette-consolidation",
"title": "Palette Consolidation: Replace 6 Palette Structs with SystemPalette.Preset Enum",
"preface": "Six palette files are consolidated into one: a `SystemPalette.Preset` enum with `.green`, `.amber`, `.red`, `.violet`, `.blue`, `.white` cases, backed by hand-tuned HSL parameters. All boilerplate (init, color generation, helpers) is shared; only the tuning data differs. Six files → one file, 545 LOC → 150 LOC. Convenience accessors like `BlockPalette.amber` still work, user-defined custom palettes still conform to `Palette` normally."
},
{
"date": "2026-02-03",
"slug": "subtree-memoization",
"title": "Render Pipeline Phase 5: Subtree Memoization",
"preface": "Subtree memoization caches rendered FrameBuffers by view identity: when a view conforms to `Equatable` and is wrapped in `.equatable()`, the framework compares the new view with the cached one. If equal (and available size hasn't changed), the cached FrameBuffer is reused. Uskipping the entire subtree's re-rendering. `RenderCache` stores buffers keyed by `ViewIdentity`, invalidated when `@State` changes or environment changes. Between state changes, identical views like Spinners skip rendering entirely."
},
{
"date": "2026-02-02",
"slug": "render-pipeline-optimization",
"title": "Render Pipeline Optimization",
"preface": "The render pipeline is optimized across four phases: (1) line-level diffing compares output with previous frame and only writes changed lines (eliminates ~90% of terminal writes), (2) output buffering batches all writes into one syscall, (3) caching eliminates repeated ioctl and regex evaluations on terminal size and FrameBuffer width, (4) architecture cleanup extracts ChildInfo for future subtree memoization. Spinner animations now run smoothly because unchanged regions don't get redrawn."
},
{
"date": "2026-02-02",
"slug": "spinner-view",
"title": "Spinner View",
"preface": "Spinners now animate loading states with three styles: rotating dots (braille), rotating line (ASCII), and bouncing Knight-Rider dot with fade trail. Each style runs at calibrated speed (110ms, 140ms, 100ms), uses time-based frame calculation (no drift), and triggers re-renders at ~25 FPS via lifecycle tasks. Simple API: `Spinner()`, `Spinner(\"Loading...\", style: .line)`, or `Spinner(\"...\", style: .bouncing, color: .cyan)`. Works everywhere with smooth, jitter-free animation."
},
{
"date": "2026-02-02",
"slug": "toast-view",
"title": "Toast View",
"preface": "Toast notifications appear and fade smoothly to communicate ephemeral messages (\"Saved!\", \"Error\", etc.) without blocking the UI. Multiple severity styles (info, success, warning, error) with themed colors, smooth fade-in/fade-out animation (~200ms each), auto-dismiss after a configurable duration, and positioning (top/bottom) via `.overlay()`. Later evolved into the Notification Service for greater power, but the core ephemeral message pattern remains."
},
{
"date": "2026-02-01",
"slug": "state-storage-identity",
"title": "State Storage Identity",
"preface": "State now survives **any** view reconstruction via structural identity: each view gets a stable key (its position in the tree), and state values live in external `StateStorage` indexed by that key. Self-hydrating `@State.init` checks the active context during `body` evaluation and retrieves persistent storage immediately. This foundation enables components like RadioButtonGroup, List, and any control with persistent local state to work reliably across all render passes."
}
]
}
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Prebuild script — runs before `next build` via the `prebuild` npm script.
* Prebuild script: runs before `next build` via the `prebuild` npm script.
*
* 1. Generates terminal-data.ts from terminal-script.md
* 2. Counts Swift @Test and @Suite annotations to produce project-stats.json
+5 -9
View File
@@ -1,6 +1,6 @@
/**
* Extracts plan data from plans/open/ and plans/done/ directories.
* Generates plans.json with top 5 open and top 5 done plans.
* Generates plans.json with all open and done plans.
*
* Runs via GitHub Actions (hourly) or manual npm script.
* Output: docs/public/data/plans.json
@@ -97,20 +97,16 @@ function main() {
openPlans.sort(sortByDateDesc);
donePlans.sort(sortByDateDesc);
// Get top 5 of each
const topOpenPlans = openPlans.slice(0, 5);
const topDonePlans = donePlans.slice(0, 5);
// Build output
// Build output with all plans
const output = {
generated: new Date().toISOString(),
open: topOpenPlans.map(({ date, slug, title, preface }) => ({
open: openPlans.map(({ date, slug, title, preface }) => ({
date,
slug,
title,
preface,
})),
done: topDonePlans.map(({ date, slug, title, preface }) => ({
done: donePlans.map(({ date, slug, title, preface }) => ({
date,
slug,
title,
@@ -127,7 +123,7 @@ function main() {
fs.writeFileSync(outputPath, JSON.stringify(output, null, 2));
console.log(
`✓ Generated plans.json (${topOpenPlans.length} open, ${topDonePlans.length} done)`
`✓ Generated plans.json (${openPlans.length} open, ${donePlans.length} done)`
);
console.log(` Location: ${outputPath}`);
}
+8 -8
View File
@@ -475,7 +475,7 @@ interface GitHubSocialAccount {
/**
* Fetches social accounts from GitHub's `/users/{login}/social_accounts` endpoint.
* These are user-provided and authoritative — highest trust after manual overrides.
* These are user-provided and authoritative: highest trust after manual overrides.
*/
async function fetchGitHubSocialAccounts(login: string): Promise<ProfileSocialAccounts> {
const accounts: ProfileSocialAccounts = {};
@@ -615,7 +615,7 @@ async function searchMastodonByUsername(username: string, githubAvatarUrl: strin
if (response.ok) {
const data = (await response.json()) as { username: string; url: string; avatar: string };
// Avatar match is a bonus signal — if it matches, we're confident immediately
// Avatar match is a bonus signal: if it matches, we're confident immediately
const avatarMatch = await avatarsMatch(githubAvatarUrl, data.avatar);
if (avatarMatch) {
console.log(` Found Mastodon @${username} on ${instance} (avatar verified ✓)`);
@@ -627,7 +627,7 @@ async function searchMastodonByUsername(username: string, githubAvatarUrl: strin
};
}
// No avatar match — accept as unverified candidate.
// No avatar match: accept as unverified candidate.
// Cross-verification will check for back-links and either verify or remove it.
console.log(` Found Mastodon @${username} on ${instance} (pending cross-verification)`);
return {
@@ -764,7 +764,7 @@ async function searchBlueskyByUsername(username: string, githubAvatarUrl: string
if (response.ok) {
const data = (await response.json()) as { handle: string; avatar?: string };
// Avatar match is a bonus signal — if it matches, we're confident immediately
// Avatar match is a bonus signal: if it matches, we're confident immediately
if (data.avatar) {
const avatarMatch = await avatarsMatch(githubAvatarUrl, data.avatar);
if (avatarMatch) {
@@ -778,7 +778,7 @@ async function searchBlueskyByUsername(username: string, githubAvatarUrl: string
}
}
// No avatar match — accept as unverified candidate.
// No avatar match: accept as unverified candidate.
// Cross-verification will check for back-links and either verify or remove it.
console.log(` Found Bluesky @${data.handle} (pending cross-verification)`);
return {
@@ -899,7 +899,7 @@ async function crossPlatformVerify(accounts: ProfileSocialAccounts, user: GitHub
accounts.mastodon.verified = true;
validationResults.push("Mastodon→GitHub ✓");
} else if (accounts.mastodon.source === "username-match") {
// Unverified username-match without back-link — remove to avoid false positives
// Unverified username-match without back-link: remove to avoid false positives
console.log(` Removing unverified Mastodon match (no back-link to GitHub)`);
delete accounts.mastodon;
}
@@ -912,7 +912,7 @@ async function crossPlatformVerify(accounts: ProfileSocialAccounts, user: GitHub
accounts.bluesky.verified = true;
validationResults.push("Bluesky→GitHub ✓");
} else if (accounts.bluesky.source === "username-match") {
// Unverified username-match without back-link — remove
// Unverified username-match without back-link: remove
console.log(` Removing unverified Bluesky match (no back-link to GitHub)`);
delete accounts.bluesky;
}
@@ -1062,7 +1062,7 @@ async function main() {
// On full refresh, clear all entries so stale false positives don't survive
if (isFullRefresh) {
console.log("Full refresh — clearing all cached entries");
console.log("Full refresh: clearing all cached entries");
cache.entries = {};
}
+5 -5
View File
@@ -347,7 +347,7 @@ pause_after_output: 1200
**Trigger:** After 12 seconds of UNIX commands (after school scene)
### First Contact — HELP GAMES
### First Contact: HELP GAMES
```terminal
[CLEAR]
@@ -436,7 +436,7 @@ pause_after_output: 1200
[DELAY 2400ms]
```
### "Joshua" — Breaking In
### "Joshua": Breaking In
```terminal
[CLEAR]
@@ -459,7 +459,7 @@ pause_after_output: 1200
[DELAY 1500ms]
```
### GREETINGS — First Conversation
### GREETINGS: First Conversation
```terminal
[SYSTEM] GREETINGS PROFESSOR FALKEN
@@ -591,7 +591,7 @@ pause_after_output: 1200
[DELAY 3200ms]
```
### NORAD — McKittrick's Office
### NORAD: McKittrick's Office
```terminal
[CLEAR]
@@ -683,7 +683,7 @@ pause_after_output: 1200
[DELAY 3000ms]
```
### Finale — A STRANGE GAME
### Finale: A STRANGE GAME
```terminal
[CLEAR]
+5 -5
View File
@@ -1,4 +1,4 @@
# WarGames — WOPR/Joshua Terminal Dialog
# WarGames: WOPR/Joshua Terminal Dialog
## Scene: First Contact with the Mystery System
@@ -25,7 +25,7 @@ GAMES:
---
## Scene: Failed Login Attempts — "Falken's Maze"
## Scene: Failed Login Attempts: "Falken's Maze"
**Context:** After learning the system designer was Stephen Falken (now deceased), David tries the name of Falken's game as a password. It fails. He tries multiple variations over several nights.
@@ -36,7 +36,7 @@ IDENTIFICATON NOT RECOGNIZED BY SYSTEM.
YOU HAVE BEEN DISCONNECTED.
```
## Scene: "Joshua" — Breaking In
## Scene: "Joshua": Breaking In
**Context:** After days of research, David watches a videotape about Falken and learns his deceased son was named Joshua. Jennifer reads the name from an obituary. David types it as the password.
@@ -73,7 +73,7 @@ David and Jennifer pick Las Vegas and Seattle as targets, unknowingly triggering
## Scene: Joshua Calls Back
**Context:** After the FBI visits and David realizes the "game" may have triggered a real military alert, he tears down all his Falken research. The phone rings — it's a computer tone. He routinely places it into the modem.
**Context:** After the FBI visits and David realizes the "game" may have triggered a real military alert, he tears down all his Falken research. The phone rings: it's a computer tone. He routinely places it into the modem.
```
GREETINGS PROFESSOR FALKEN
@@ -94,7 +94,7 @@ David hangs up. The phone rings again almost instantly. He hangs up again. It ke
---
## Scene: David Logs In at NORAD — McKittrick's Office
## Scene: David Logs In at NORAD: McKittrick's Office
**Context:** David has been captured and brought to NORAD. While left alone in McKittrick's office, he watches the DEFCON level change from 4 to 3. He sits at McKittrick's terminal.
+67 -67
View File
@@ -30,7 +30,7 @@ TUIkit is a well-architected declarative Swift framework for building terminal U
## A. Redundancies
### A.1 `applyBackground()` — Identical in 4 Files (High)
### A.1 `applyBackground()`: Identical in 4 Files (High)
The exact same method exists in 4 files:
@@ -54,7 +54,7 @@ private func applyBackground(_ string: String, background: Color) -> String {
---
### A.2 `colorize` / `colorizeBorder` — 8+ Implementations (High)
### A.2 `colorize` / `colorizeBorder`: 8+ Implementations (High)
Almost every View and Modifier has its own colorize variant:
@@ -70,13 +70,13 @@ Almost every View and Modifier has its own colorize variant:
| `StatusBar.swift` | `colorizeBorder(_:color:)` |
| `StatusBar.swift` | `colorizeBorderWithForeground(_:foreground:)` |
Additionally, `StatusBar.swift`'s `colorizeBorder` and `colorizeBorderWithForeground` have **identical bodies** — only the names differ.
Additionally, `StatusBar.swift`'s `colorizeBorder` and `colorizeBorderWithForeground` have **identical bodies**: only the names differ.
**Recommendation:** Create a single `ANSIRenderer.colorize(string:foreground:background:bold:)` method.
---
### A.3 Block-Style Border Rendering — 4x Nearly Identical (High)
### A.3 Block-Style Border Rendering: 4x Nearly Identical (High)
The block-style rendering pattern (`▄`/`█`/`▀` characters for top/body/bottom) is duplicated in:
@@ -94,7 +94,7 @@ All follow the identical pattern:
---
### A.4 Standard Border Rendering — 3x Nearly Identical (High)
### A.4 Standard Border Rendering: 3x Nearly Identical (High)
`buildBorderLine` + content loop + top/bottom border in:
@@ -102,11 +102,11 @@ All follow the identical pattern:
- `BorderModifier.swift` (`renderStandardStyle`)
- `Menu.swift` (standard branch of `applyBorder`)
**Recommendation:** Same as above — consolidate into a `BorderRenderer` utility.
**Recommendation:** Same as above: consolidate into a `BorderRenderer` utility.
---
### A.5 `let reset = "\u{1B}[0m"` — Hardcoded 8+ Times (Medium)
### A.5 `let reset = "\u{1B}[0m"`: Hardcoded 8+ Times (Medium)
Instead of using `ANSIRenderer.reset`, the raw ANSI escape string is hardcoded in at least 8 locations:
@@ -119,7 +119,7 @@ Instead of using `ANSIRenderer.reset`, the raw ANSI escape string is hardcoded i
---
### A.6 ThemeManager / AppearanceManager — Near-Identical Classes (High)
### A.6 ThemeManager / AppearanceManager: Near-Identical Classes (High)
`ThemeManager` (Theme.swift) and `AppearanceManager` (Appearance.swift) share almost identical logic:
@@ -132,7 +132,7 @@ Instead of using `ANSIRenderer.reset`, the raw ANSI escape string is hardcoded i
---
### A.7 Theme Structs — 5x Identical Structure (Medium)
### A.7 Theme Structs: 5x Identical Structure (Medium)
`GreenPhosphorTheme`, `AmberPhosphorTheme`, `WhitePhosphorTheme`, `RedPhosphorTheme`, and `NCursesTheme` all have the exact same structure with only different color values.
@@ -148,13 +148,13 @@ public struct ColorTheme: Theme {
}
```
`ThemeColors` (Theme.swift ~201-275) is also pure mechanical 1:1 forwarding of all Theme protocol properties — ~75 lines of boilerplate that must be manually updated when the protocol changes.
`ThemeColors` (Theme.swift ~201-275) is also pure mechanical 1:1 forwarding of all Theme protocol properties: ~75 lines of boilerplate that must be manually updated when the protocol changes.
**Recommendation:** Replace 5 structs with one configurable `ColorTheme` struct.
---
### A.8 TupleView / ViewBuilder Boilerplate — ~500 Lines (Medium)
### A.8 TupleView / ViewBuilder Boilerplate: ~500 Lines (Medium)
`TupleViews.swift` has 10 nearly identical structs (`TupleView2`..`TupleView10`), and `ViewBuilder.swift` has 10 nearly identical `buildBlock` overloads. `ViewRenderer.swift` has 9 copies of `Renderable` + `ChildInfoProvider` extensions.
@@ -168,13 +168,13 @@ struct TupleView<each V: View>: View { ... }
---
### A.9 Alert Preset Methods — 100% Redundant (Medium)
### A.9 Alert Preset Methods: 100% Redundant (Medium)
The `warning`, `error`, `info`, `success` presets are defined **twice** each (with and without actions), totaling 8 methods with nearly identical bodies (Alert.swift ~168-296). The version without actions could simply call the version with actions using `EmptyView`.
---
### A.10 Render-to-ContainerView Delegation — Repeated Pattern (Low)
### A.10 Render-to-ContainerView Delegation: Repeated Pattern (Low)
Alert, Dialog, Panel, and Card all have the same if/else pattern in `renderToBuffer`:
@@ -200,7 +200,7 @@ if let footerView = footer {
---
### A.12 `render()` Environment Setup — Duplicated in AppRunner (Medium)
### A.12 `render()` Environment Setup: Duplicated in AppRunner (Medium)
`AppRunner.render()` (App.swift ~493-499) builds an environment object with 7 properties. The **identical code** exists in `renderStatusBar()` (~548-554).
@@ -208,13 +208,13 @@ if let footerView = footer {
---
### A.13 `Color.lighter(by:)` / `darker(by:)` — Near Identical (Low)
### A.13 `Color.lighter(by:)` / `darker(by:)`: Near Identical (Low)
Both methods in `Color.swift` (~216-242) have the same structure, differing only in addition vs. subtraction. Could be a shared `adjusted(by:)` method.
---
### A.14 `focusNext()` / `focusPrevious()` — Near Identical (Low)
### A.14 `focusNext()` / `focusPrevious()`: Near Identical (Low)
`Focus.swift` (~150-183): Both methods have almost identical structure. Could be refactored to `moveFocus(direction:)`.
@@ -267,13 +267,13 @@ Alert, Dialog, Card, and Panel could all use `ContainerConfig` instead of declar
---
### B.4 Split `AppRunner` — God Class (Medium)
### B.4 Split `AppRunner`: God Class (Medium)
`AppRunner` (App.swift) has too many responsibilities: setup, rendering, event handling, cleanup, signal handling, status bar rendering, scene rendering. Should be split into:
- `InputHandler` — keyboard/signal event processing
- `RenderLoop` — frame rendering pipeline
- `SignalManager` — signal handler registration and cleanup
- `InputHandler`: keyboard/signal event processing
- `RenderLoop`: frame rendering pipeline
- `SignalManager`: signal handler registration and cleanup
---
@@ -287,7 +287,7 @@ Alert, Dialog, Card, and Panel could all use `ContainerConfig` instead of declar
## C. Dead / Unused Code
### C.1 `BorderModifier` (Legacy) — `BorderModifier.swift:201-270` (High)
### C.1 `BorderModifier` (Legacy): `BorderModifier.swift:201-270` (High)
Explicitly marked as `// MARK: - Legacy ViewModifier (kept for compatibility)`. The new implementation is `BorderedView`. This legacy code duplicates the entire rendering logic of `BorderedView` but does **not** handle block-style rendering.
@@ -295,7 +295,7 @@ Explicitly marked as `// MARK: - Legacy ViewModifier (kept for compatibility)`.
---
### C.2 `FrameModifier` (Legacy) — `FrameModifier.swift:174-247` (Medium)
### C.2 `FrameModifier` (Legacy): `FrameModifier.swift:174-247` (Medium)
Marked as "Fixed Frame Modifier (Legacy)". `FlexibleFrameView` is the active implementation. `FrameModifier` is still used by `View.frame(width:height:alignment:)`, but `FlexibleFrameView` can handle the same cases.
@@ -303,7 +303,7 @@ Marked as "Fixed Frame Modifier (Legacy)". `FlexibleFrameView` is the active imp
---
### C.3 Panel `body` Property — Dead Code (Medium)
### C.3 Panel `body` Property: Dead Code (Medium)
`Panel.swift` implements both `body` (returns `ContainerView`) and `Renderable.renderToBuffer`. Since `Renderable` takes precedence, `body` is **never called**. The `body` also contains a force-unwrap (`footer!`) that would crash if ever executed.
@@ -311,7 +311,7 @@ Marked as "Fixed Frame Modifier (Legacy)". `FlexibleFrameView` is the active imp
---
### C.4 Common Preference Keys — Likely Unused (Low)
### C.4 Common Preference Keys: Likely Unused (Low)
`Preferences.swift` defines `NavigationTitleKey`, `TabBadgeKey`, and `AnchorPreferenceKey` as "Common Preference Keys", but no internal Views use them. These appear to be forward-looking definitions with no current consumers.
@@ -319,7 +319,7 @@ Marked as "Fixed Frame Modifier (Legacy)". `FlexibleFrameView` is the active imp
---
### C.5 TODO Placeholder — `TUIkit.swift` (Low)
### C.5 TODO Placeholder: `TUIkit.swift` (Low)
```swift
return 0 // TODO: Return actual line count
@@ -357,7 +357,7 @@ Both branches are **identical**.
---
### C.8 `_ = self // Silence warning` — Anti-Pattern (Low)
### C.8 `_ = self // Silence warning`: Anti-Pattern (Low)
`App.swift` (~453): This capture-silencing pattern should be resolved properly (e.g., remove `[weak self]` if not needed, or use `self` meaningfully).
@@ -384,7 +384,7 @@ No entirely unused files were identified. All `.swift` files contribute to eithe
| `KeyPressModifier.swift` | `content`, `keys`, `handler` properties | Low |
| `OverlayModifier.swift` | `base`, `overlay`, `alignment` properties | Low |
| `StatusBarItemsModifier.swift` | `content`, `items`, `context` properties | Low |
| `ViewModifier.swift` | `ModifiedView.content` and `ModifiedView.modifier` — public but minimal docs | Medium |
| `ViewModifier.swift` | `ModifiedView.content` and `ModifiedView.modifier`: public but minimal docs | Medium |
### E.2 Complex Logic Without Inline Comments
@@ -421,7 +421,7 @@ The example app has significant gaps in demonstrating framework capabilities:
## F. Constant Namespacing
### F.1 ANSI Escape Codes in `KeyEvent.swift` — 60+ Magic Hex Values (High)
### F.1 ANSI Escape Codes in `KeyEvent.swift`: 60+ Magic Hex Values (High)
The entire key event parsing system uses raw hex values:
@@ -504,11 +504,11 @@ enum BlockCharacters {
| File | Code | Meaning |
|------|------|---------|
| `ContainerView.swift` | `$0.count + 4` | Title padding width — unclear why 4 |
| `ContainerView.swift` | `$0.count + 4` | Title padding width: unclear why 4 |
| `Menu.swift` | `+ 2` | Content padding |
| `Button.swift` | `horizontalPadding: 2` | Default button padding |
| `Alert.swift` | `EdgeInsets(horizontal: 2, vertical: 1)` | Standard alert padding |
| `Dialog.swift` | `EdgeInsets(horizontal: 2, vertical: 1)` | Same as alert — should be shared constant |
| `Dialog.swift` | `EdgeInsets(horizontal: 2, vertical: 1)` | Same as alert: should be shared constant |
| `StatusBar.swift` | `- 2` | Border width subtraction |
| `BorderModifier.swift` | `- 2` | Border width subtraction |
@@ -527,13 +527,13 @@ enum LayoutConstants {
### F.5 `DemoPage` Explicit Raw Values (Low)
`AppState.swift` (~13-21): `case menu = 0, textStyles = 1, ...` — Int-based enums number automatically. The explicit values are redundant.
`AppState.swift` (~13-21): `case menu = 0, textStyles = 1, ...`: Int-based enums number automatically. The explicit values are redundant.
---
## G. Short Variable / Constant / Parameter Names
### G.1 Core Framework — Color.swift HSL Conversion
### G.1 Core Framework: Color.swift HSL Conversion
| File | Context | Name | Suggested Name |
|------|---------|------|----------------|
@@ -547,7 +547,7 @@ enum LayoutConstants {
| `Color.swift` | `hueToRGB` function | `t` (param) | `hueComponent` |
| `Color.swift` | `hueToRGB` function | `t` (shadow var) | `adjustedHue` |
### G.2 Core Framework — TupleViews.swift
### G.2 Core Framework: TupleViews.swift
| File | Context | Name | Suggested Name |
|------|---------|------|----------------|
@@ -556,16 +556,16 @@ enum LayoutConstants {
*Note: SwiftUI itself uses short generic names for TupleViews. This is an accepted Swift convention for result builders. Pragmatically acceptable.*
### G.3 Core Framework — ViewBuilder.swift
### G.3 Core Framework: ViewBuilder.swift
| File | Context | Name | Suggested Name |
|------|---------|------|----------------|
| `ViewBuilder.swift` | All `buildBlock` methods | `C0`..`C9` (generics) | `View0`..`View9` |
| `ViewBuilder.swift` | All `buildBlock` methods | `c0`..`c9` (params) | `view0`..`view9` |
*Same note as TupleViews — standard Swift convention for result builders.*
*Same note as TupleViews: standard Swift convention for result builders.*
### G.4 Modifiers — FrameModifier.swift
### G.4 Modifiers: FrameModifier.swift
| File | Context | Name | Suggested Name |
|------|---------|------|----------------|
@@ -574,39 +574,39 @@ enum LayoutConstants {
| `FrameModifier.swift` | Local bindings | `minW` | `minimumWidth` |
| `FrameModifier.swift` | Local bindings | `minH` | `minimumHeight` |
### G.5 Modifiers — OverlayModifier.swift
### G.5 Modifiers: OverlayModifier.swift
| File | Context | Name | Suggested Name |
|------|---------|------|----------------|
| `OverlayModifier.swift` | Overlay positioning | `xOffset` | `horizontalOffset` |
| `OverlayModifier.swift` | Overlay positioning | `yOffset` | `verticalOffset` |
### G.6 Modifiers — BackgroundModifier.swift
### G.6 Modifiers: BackgroundModifier.swift
| File | Context | Name | Suggested Name |
|------|---------|------|----------------|
| `BackgroundModifier.swift` | Switch cases | `ansi` | `ansiColor` |
| `BackgroundModifier.swift` | Switch case | `index` (in `.palette256`) | `paletteIndex` |
### G.7 Rendering — Terminal.swift
### G.7 Rendering: Terminal.swift
| File | Context | Name | Suggested Name |
|------|---------|------|----------------|
| `Terminal.swift` | readByte method | `char` | `readByte` |
### G.8 Example App — HeaderView.swift
### G.8 Example App: HeaderView.swift
| File | Context | Name | Suggested Name |
|------|---------|------|----------------|
| `HeaderView.swift` | `if let sub = subtitle` | `sub` | `subtitleText` |
### G.9 Views — Menu.swift
### G.9 Views: Menu.swift
| File | Context | Name | Suggested Name |
|------|---------|------|----------------|
| `Menu.swift` | Key event handling | `char` | `characterValue` |
### G.10 Core Framework — Focus.swift
### G.10 Core Framework: Focus.swift
| File | Context | Name | Suggested Name |
|------|---------|------|----------------|
@@ -618,7 +618,7 @@ enum LayoutConstants {
## H. Code Quality & Architecture
### H.1 Singleton Overuse — 8+ `shared` Instances (Critical)
### H.1 Singleton Overuse: 8+ `shared` Instances (Critical)
The framework relies on at least 8 singletons:
@@ -639,9 +639,9 @@ The framework relies on at least 8 singletons:
---
### H.2 Dual Rendering System (`body` vs. `Renderable`) — Inconsistent (Medium)
### H.2 Dual Rendering System (`body` vs. `Renderable`): Inconsistent (Medium)
Some Views implement `body` (Box, Spacer), some implement `Renderable` (Alert, Dialog, Button), and some implement **both** (Panel — where `body` is dead code). The relationship between these two systems is not documented.
Some Views implement `body` (Box, Spacer), some implement `Renderable` (Alert, Dialog, Button), and some implement **both** (Panel: where `body` is dead code). The relationship between these two systems is not documented.
**Recommendation:** Document the contract clearly. If a View implements `Renderable`, `body` should either not exist or be explicitly marked as unreachable.
@@ -765,7 +765,7 @@ The same bug exists in `AppearanceManager.setAppearance()`.
---
### I.5 `needsRerender` Global Variable — Data Race (Medium)
### I.5 `needsRerender` Global Variable: Data Race (Medium)
`App.swift` (~409): `nonisolated(unsafe) var needsRerender` is a global mutable Bool written by a signal handler and read by the main loop. This is technically a data race (even if practically harmless for Bool). Should use `Atomic<Bool>` or `os_unfair_lock`.
@@ -785,7 +785,7 @@ The same bug exists in `AppearanceManager.setAppearance()`.
---
### I.8 `deinit` on Singleton — Never Called (Low)
### I.8 `deinit` on Singleton: Never Called (Low)
`Terminal.swift`: The `deinit` disables raw mode, but since `Terminal` is a singleton, `deinit` is never called. Cleanup relies entirely on `cleanup()` being called by `AppRunner`. The `deinit` gives a false sense of safety.
@@ -853,29 +853,29 @@ The same bug exists in `AppearanceManager.setAppearance()`.
### Strengths
1. **Clean API Design** — The SwiftUI-inspired declarative API is well-designed, consistent, and idiomatic Swift.
2. **Zero Dependencies** — Pure Swift with no C library dependencies is a strong selling point.
3. **Good Documentation Foundation** — DocC catalog with articles, hosted on GitHub Pages with custom domain.
4. **Comprehensive Theme System** — 5 built-in themes with proper protocol-based extensibility.
5. **Solid Focus Management** — FocusManager with keyboard navigation, wrapping, and disabled element support.
6. **Well-Tested Focus & StatusBar** — These two areas have thorough test coverage.
7. **Proper Environment System** — `@Environment`, `@State`, `@AppStorage` following SwiftUI patterns.
1. **Clean API Design**: The SwiftUI-inspired declarative API is well-designed, consistent, and idiomatic Swift.
2. **Zero Dependencies**: Pure Swift with no C library dependencies is a strong selling point.
3. **Good Documentation Foundation**: DocC catalog with articles, hosted on GitHub Pages with custom domain.
4. **Comprehensive Theme System**: 5 built-in themes with proper protocol-based extensibility.
5. **Solid Focus Management**: FocusManager with keyboard navigation, wrapping, and disabled element support.
6. **Well-Tested Focus & StatusBar**: These two areas have thorough test coverage.
7. **Proper Environment System**: `@Environment`, `@State`, `@AppStorage` following SwiftUI patterns.
### Weaknesses
1. **Massive Code Duplication** — Border rendering, colorization, and background application are copied across 4-8 files. This is the single biggest maintenance burden.
2. **Singleton Addiction** — 8+ global shared instances make the framework nearly untestable and fragile for concurrent use.
3. **Thread-Safety Gaps** — Multiple `@unchecked Sendable` types with no synchronization under Swift 6 strict concurrency.
4. **Test Coverage Holes** — Views, Modifiers, and the rendering pipeline are largely untested. Most tests are smoke tests.
5. **Legacy Code Retained** — `BorderModifier` and `FrameModifier` legacy implementations add confusion without adding value.
6. **Signal Handler Safety** — The SIGINT handler is not async-signal-safe and could crash in edge cases.
1. **Massive Code Duplication**: Border rendering, colorization, and background application are copied across 4-8 files. This is the single biggest maintenance burden.
2. **Singleton Addiction**: 8+ global shared instances make the framework nearly untestable and fragile for concurrent use.
3. **Thread-Safety Gaps**: Multiple `@unchecked Sendable` types with no synchronization under Swift 6 strict concurrency.
4. **Test Coverage Holes**: Views, Modifiers, and the rendering pipeline are largely untested. Most tests are smoke tests.
5. **Legacy Code Retained**: `BorderModifier` and `FrameModifier` legacy implementations add confusion without adding value.
6. **Signal Handler Safety**: The SIGINT handler is not async-signal-safe and could crash in edge cases.
### Priority Recommendations
1. **Extract `BorderRenderer` + `ANSIRenderer.colorize()`** — Eliminates ~60% of all duplication.
2. **Fix `@unchecked Sendable` types** — Add locks or convert to actors.
3. **Fix ThemeManager/AppearanceManager state bug** — Actual functional bug.
4. **Remove legacy `BorderModifier` and `FrameModifier`** — Reduce confusion.
5. **Add constants namespacing for hex values and ANSI codes** — Improve readability.
6. **Expand test coverage** — Especially for Views and Modifiers at the rendering level.
7. **Plan singleton migration** — Introduce `TUIContext` for dependency injection (longer-term).
1. **Extract `BorderRenderer` + `ANSIRenderer.colorize()`**: Eliminates ~60% of all duplication.
2. **Fix `@unchecked Sendable` types**: Add locks or convert to actors.
3. **Fix ThemeManager/AppearanceManager state bug**: Actual functional bug.
4. **Remove legacy `BorderModifier` and `FrameModifier`**: Reduce confusion.
5. **Add constants namespacing for hex values and ANSI codes**: Improve readability.
6. **Expand test coverage**: Especially for Views and Modifiers at the rendering level.
7. **Plan singleton migration**: Introduce `TUIContext` for dependency injection (longer-term).
+12 -12
View File
@@ -38,10 +38,10 @@ Three-phase refactoring to implement proper SwiftUI-like View architecture acros
**Risk:** Moderate (careful API design needed)
### What
- `FocusableItemListHandler` — shared focus/navigation logic
- `SelectionStateManager<T>` — shared selection tracking
- `ItemStateRenderer` — styling utilities
- `renderFocusableContainer()` — helper function
- `FocusableItemListHandler` for shared focus/navigation logic
- `SelectionStateManager<T>` for shared selection tracking
- `ItemStateRenderer` for styling utilities
- `renderFocusableContainer()` as helper function
### Why
- List and Table share 80% of focus/selection/rendering logic
@@ -119,11 +119,11 @@ Phase 3: feat/list-table-new
## Key Principles Applied
1. **Everything visible is a View** — enables modifiers, environment propagation
2. **Composition not Inheritance** — use helpers and handlers, not class hierarchies
3. **Maximize Code Reuse** — shared handlers/helpers before implementation
4. **Follow Existing Patterns** — Box.swift, renderContainer() model
5. **Test at Every Phase** — no breaking changes, all tests pass
1. **Everything visible is a View**: enables modifiers, environment propagation
2. **Composition not Inheritance**: use helpers and handlers, not class hierarchies
3. **Maximize Code Reuse**: shared handlers/helpers before implementation
4. **Follow Existing Patterns**: Box.swift, renderContainer() model
5. **Test at Every Phase**: no breaking changes, all tests pass
---
@@ -133,9 +133,9 @@ All three branches start from the same commit (planning phase complete).
They can be worked on in parallel ONLY if no dependencies.
**Recommended sequence:**
1. Phase 1 (independent) — can start immediately
2. Phase 2 (depends on Phase 1 being merged) — start after Phase 1 PR approved
3. Phase 3 (depends on Phase 1+2 being merged) — start after Phase 2 PR approved
1. Phase 1 (independent): can start immediately
2. Phase 2 (depends on Phase 1 being merged): start after Phase 1 PR approved
3. Phase 3 (depends on Phase 1+2 being merged): start after Phase 2 PR approved
---
+20 -20
View File
@@ -6,7 +6,7 @@ State now survives **any** view reconstruction via structural identity: each vie
## Completed
**2026-02-02** — All five phases implemented. Self-hydrating `@State` with structural identity, persistent `StateStorage`, garbage collection, branch invalidation, and root context hydration in `RenderLoop`. Dead code (Mirror-based `hydrateState`, `_hydrateField`, `StateHydratable`) removed.
**0: All five phases implemented. Self-hydrating `@State` with structural identity, persistent `StateStorage`, garbage collection, branch invalidation, and root context hydration in `RenderLoop`. Dead code (Mirror-based `hydrateState`, `_hydrateField`, `StateHydratable`) removed.
## Checklist
@@ -38,7 +38,7 @@ In SwiftUI, `@State` survives **every** reconstruction because state values live
## Specification / Goal
Every `@State` in TUIKit must survive render passes — regardless of whether the view is reconstructed on every frame. The user-facing API (`@State var count = 0`) remains unchanged.
Every `@State` in TUIKit must survive render passes. Uregardless of whether the view is reconstructed on every frame. The user-facing API (`@State var count = 0`) remains unchanged.
## Design
@@ -100,9 +100,9 @@ final class StateStorage {
`RenderContext` gains an `identity: ViewIdentity` field that is extended during traversal.
Convenience methods:
- `withChildIdentity(type:index:)` — for container children
- `withChildIdentity(type:)` — for composite view body descent
- `withBranchIdentity(_:)` — for ConditionalView branches
- `withChildIdentity(type:index:)`. Ufor container children
- `withChildIdentity(type:)`. Ufor composite view body descent
- `withBranchIdentity(_:)`. Ufor ConditionalView branches
#### 4. `@State` Self-Hydration
@@ -182,33 +182,33 @@ via `stateStorage.invalidateDescendants(of:)`.
### Phase 2: Identity Propagation ✅
- [x] 6. **`renderToBuffer` (free function)** — `withChildIdentity(type: V.Body.self)` on body descent
- [x] 7. **`TupleView.childInfos`** — `withChildIdentity(type:index:)` via `infos.count` workaround
- [x] 8. **`ViewArray.childInfos`** — `enumerated()` for child index
- [x] 9. **`resolveChildInfos` / `makeChildInfo`** — no change needed, context flows through
- [x] 10. **`ConditionalView`** — `withBranchIdentity("true"/"false")` + `invalidateDescendants`
- [x] 6. **`renderToBuffer` (free function)**: `withChildIdentity(type: V.Body.self)` on body descent
- [x] 7. **`TupleView.childInfos`**: `withChildIdentity(type:index:)` via `infos.count` workaround
- [x] 8. **`ViewArray.childInfos`**: `enumerated()` for child index
- [x] 9. **`resolveChildInfos` / `makeChildInfo`**. Uno change needed, context flows through
- [x] 10. **`ConditionalView`**: `withBranchIdentity("true"/"false")` + `invalidateDescendants`
### Phase 3: State Hydration ✅
- [x] 11. **`State<Value>`** — self-hydrating init via `StateRegistration.activeContext`
- [x] 12. **`renderToBuffer`** — set/clear active context around `body` evaluation, save/restore for nesting
- [x] 13. **`AppStorage`** — not applicable (uses explicit string keys, no identity needed)
- [x] 11. **`State<Value>`**. Uself-hydrating init via `StateRegistration.activeContext`
- [x] 12. **`renderToBuffer`**. Uset/clear active context around `body` evaluation, save/restore for nesting
- [x] 13. **`AppStorage`**. Unot applicable (uses explicit string keys, no identity needed)
### Phase 4: Cleanup and Edge Cases ✅
- [x] 14. **Remove scene cache** — `RenderLoop.scene` removed, `app.body` evaluated fresh each frame
- [x] 15. **ConditionalView branch invalidation** — already done in Phase 2 step 10
- [x] 16. **Lifecycle coordination** — `stateStorage.beginRenderPass()`/`endRenderPass()` in `RenderLoop.render()`
- [x] 14. **Remove scene cache**: `RenderLoop.scene` removed, `app.body` evaluated fresh each frame
- [x] 15. **ConditionalView branch invalidation**. Ualready done in Phase 2 step 10
- [x] 16. **Lifecycle coordination**: `stateStorage.beginRenderPass()`/`endRenderPass()` in `RenderLoop.render()`
### Phase 5: Tests and Documentation ✅
- [x] 17. **Write tests** — 12 tests: self-hydration, multi-property, identity paths, branch invalidation, GC, renderToBuffer integration, nested views
- [x] 18. **Update DocC** — StateManagement.md (structural identity, persistent storage, GC), RenderCycle.md (state tracking, identity in context, dispatch code)
- [x] 19. **Inline docs** — all new types fully documented (ViewIdentity, StateStorage, StateBox, HydrationContext, StateRegistration)
- [x] 17. **Write tests**: 12 tests: self-hydration, multi-property, identity paths, branch invalidation, GC, renderToBuffer integration, nested views
- [x] 18. **Update DocC**: StateManagement.md (structural identity, persistent storage, GC), RenderCycle.md (state tracking, identity in context, dispatch code)
- [x] 19. **Inline docs**. Uall new types fully documented (ViewIdentity, StateStorage, StateBox, HydrationContext, StateRegistration)
## Open Questions
1. ~~**Mirror vs. explicit protocol for hydration?**~~ **Resolved:** Self-hydrating init — `@State.init` checks `StateRegistration.activeContext` and retrieves persistent `StateBox` directly. No Mirror, no unsafe pointers, no type-erased protocol needed.
1. ~~**Mirror vs. explicit protocol for hydration?**~~ **Resolved:** Self-hydrating init: `@State.init` checks `StateRegistration.activeContext` and retrieves persistent `StateBox` directly. No Mirror, no unsafe pointers, no type-erased protocol needed.
2. ~~**Parameter pack `repeat` with mutable index?**~~ **Resolved:** `infos.count` as index proxy.
@@ -6,10 +6,10 @@ The render pipeline is optimized across four phases: (1) line-level diffing comp
## Completed
**2026-02-02** — Phases 1–4 completed. All phases implemented across three branches:
- `refactor/render-pipeline-phase1` — Phase 1 (line-level diffing) + Phase 2 (output buffering) + CI fix. PR #62.
- `refactor/render-pipeline-phase3` — Phase 3 (caching). PR #63.
- `refactor/render-pipeline-phase4` — Phase 4 (architecture cleanup, renamed from "subtree memoization").
**0: Phases 1–4 completed. All phases implemented across three branches:
- `refactor/render-pipeline-phase1`: Phase 1 (line-level diffing) + Phase 2 (output buffering) + CI fix. PR #62.
- `refactor/render-pipeline-phase3`: Phase 3 (caching). PR #63.
- `refactor/render-pipeline-phase4`: Phase 4 (architecture cleanup, renamed from "subtree memoization").
Note: The original Phase 4 (subtree memoization) was replaced with architecture cleanup. Subtree memoization remains a future project.
@@ -28,7 +28,7 @@ Note: The original Phase 4 (subtree memoization) was replaced with architecture
## Problem
Every frame in TUIKit reconstructs the entire view tree, re-renders every view into fresh FrameBuffers, and rewrites every terminal line — regardless of whether anything changed. This causes visible stuttering/jerkiness in animations (Spinner) and will get worse as the UI grows.
Every frame in TUIKit reconstructs the entire view tree, re-renders every view into fresh FrameBuffers, and rewrites every terminal line. Uregardless of whether anything changed. This causes visible stuttering/jerkiness in animations (Spinner) and will get worse as the UI grows.
### Current per-frame cost (for a 30-view app on a 50-line terminal)
@@ -46,12 +46,12 @@ Every frame in TUIKit reconstructs the entire view tree, re-renders every view i
### Root causes
1. **Full tree reconstruction** — `App.body` is evaluated every frame. Every view struct, TupleView, Stack, and modifier is created fresh. Every `@ViewBuilder` closure re-executes.
2. **Full screen repaint** — `WindowGroup.renderScene` writes every terminal line every frame via individual POSIX `write()` calls, even if nothing changed.
3. **Uncached computed properties** — `FrameBuffer.width` runs the ANSI regex on every line every time it's accessed. Called hundreds of times per frame.
4. **Uncached terminal size** — `terminal.width`/`height` call `ioctl()` every time (~5× per frame).
5. **No output buffering** — Each terminal line produces 2 `write()` syscalls (cursor move + content). No batching.
6. **Redundant string operations** — Every content line goes through regex stripping, `replacingOccurrences`, padding concatenation.
1. **Full tree reconstruction**: `App.body` is evaluated every frame. Every view struct, TupleView, Stack, and modifier is created fresh. Every `@ViewBuilder` closure re-executes.
2. **Full screen repaint**: `WindowGroup.renderScene` writes every terminal line every frame via individual POSIX `write()` calls, even if nothing changed.
3. **Uncached computed properties**: `FrameBuffer.width` runs the ANSI regex on every line every time it's accessed. Called hundreds of times per frame.
4. **Uncached terminal size**: `terminal.width`/`height` call `ioctl()` every time (~5× per frame).
5. **No output buffering**: Each terminal line produces 2 `write()` syscalls (cursor move + content). No batching.
6. **Redundant string operations**: Every content line goes through regex stripping, `replacingOccurrences`, padding concatenation.
## Goal
@@ -70,12 +70,12 @@ SwiftUI maintains a persistent **Attribute Graph** that tracks dependencies betw
### What's applicable to TUIKit (incremental approach)
We adopt a **phased approach** — each phase is independently valuable and shippable:
We adopt a **phased approach**. Ueach phase is independently valuable and shippable:
1. **Phase 1: Line-level diffing** — highest ROI. Store previous frame, only write changed lines. Eliminates >90% of terminal writes for mostly-static UI.
2. **Phase 2: Output buffering** — batch all terminal output into one `write()` call.
3. **Phase 3: Caching** — cache `FrameBuffer.width`, terminal size, avoid redundant regex.
4. **Phase 4: Subtree memoization** — skip re-rendering subtrees whose inputs haven't changed.
1. **Phase 1: Line-level diffing**. Uhighest ROI. Store previous frame, only write changed lines. Eliminates >90% of terminal writes for mostly-static UI.
2. **Phase 2: Output buffering**. Ubatch all terminal output into one `write()` call.
3. **Phase 3: Caching**. Ucache `FrameBuffer.width`, terminal size, avoid redundant regex.
4. **Phase 4: Subtree memoization**. Uskip re-rendering subtrees whose inputs haven't changed.
Phase 1–3 are mechanical optimizations that don't change the architecture. Phase 4 is the structural change toward SwiftUI-style incremental rendering.
@@ -108,7 +108,7 @@ func writeFrame(_ buffer: FrameBuffer, terminal: Terminal) {
**Key decisions:**
- Compare the **final output strings** (with ANSI codes), not stripped text. This is a simple `==` comparison, O(1) amortized for equal strings (Swift uses hash-based comparison for long strings).
- Store `previousFrame` in `RenderLoop` (not global, injected via constructor — no singletons).
- Store `previousFrame` in `RenderLoop` (not global, injected via constructor. Uno singletons).
- On terminal resize (SIGWINCH), invalidate the entire previous frame to force a full repaint.
- The status bar gets its own `previousStatusBarLines` for independent diffing.
@@ -223,7 +223,7 @@ public struct FrameBuffer {
During `WindowGroup.renderScene`, each content line goes through `strippedLength` (regex) + `replacingOccurrences` + padding. We can pre-compute the stripped length when the FrameBuffer is built, avoiding the regex in the output phase entirely.
This is a natural extension of 3b — if `FrameBuffer` tracks `width`, individual line lengths could be tracked too.
This is a natural extension of 3b. Uif `FrameBuffer` tracks `width`, individual line lengths could be tracked too.
### Phase 4: Subtree Memoization (Future)
@@ -283,7 +283,7 @@ func renderToBuffer<V: View>(_ view: V, context: RenderContext) -> FrameBuffer {
- [x] 10. Single `terminal.getSize()` call per frame in `RenderLoop.render()` (instead of 2 ioctl calls)
- [x] 11. `FrameBuffer.width` as stored `public private(set) var` with `didSet` recomputation
- [x] 12. All `FrameBuffer.width` call sites audited — all read-only, no external mutation
- [x] 12. All `FrameBuffer.width` call sites audited. Uall read-only, no external mutation
- [x] 13. `strippedLength` counts visible chars without intermediate string allocation
### Phase 4: Architecture Cleanup (replaced original "Subtree Memoization")
@@ -310,7 +310,7 @@ func renderToBuffer<V: View>(_ view: V, context: RenderContext) -> FrameBuffer {
| Phase | What it eliminates | Estimated improvement |
|-------|---|---|
| Phase 1 | ~94% of terminal write syscalls for mostly-static UI | **Biggest visual improvement** — less flicker, smoother animations |
| Phase 1 | ~94% of terminal write syscalls for mostly-static UI | **Biggest visual improvement**. Uless flicker, smoother animations |
| Phase 2 | Remaining per-line syscall overhead | ~100 syscalls → 1 per frame |
| Phase 3a | ioctl syscalls | ~5 syscalls → 0 per frame (except resize) |
| Phase 3b | Regex evaluations for width | ~100–200 regex calls → 0 per frame |
@@ -320,8 +320,8 @@ func renderToBuffer<V: View>(_ view: V, context: RenderContext) -> FrameBuffer {
1. **FrameBuffer as value type vs reference type?** Width caching is awkward with a value type (`mutating get` won't work on `let` bindings). Options: (a) stored `width` property updated by mutation methods, (b) switch to reference type (class), (c) internal `_Storage` class wrapper. Option (a) is simplest.
2. **String comparison cost for line diffing?** Swift Strings use UTF-8 storage. Equality check is O(N) in the worst case but O(1) for pointer-equal strings. Since we rebuild strings every frame, they won't be pointer-equal. For 50-line terminal with ~120 chars per line, that's ~6KB of comparison per frame — negligible.
2. **String comparison cost for line diffing?** Swift Strings use UTF-8 storage. Equality check is O(N) in the worst case but O(1) for pointer-equal strings. Since we rebuild strings every frame, they won't be pointer-equal. For 50-line terminal with ~120 chars per line, that's ~6KB of comparison per frame. Unegligible.
3. **Status bar diffing separately or unified?** The status bar renders on its own pass with its own context. Keeping its diffing separate (own `previousLines`) is cleaner and avoids coupling.
4. **Should `buildOutputLines` handle the background color replacement?** Yes — this isolates the "raw FrameBuffer → terminal-ready strings" transformation into a testable pure function.
4. **Should `buildOutputLines` handle the background color replacement?** Yes. Uthis isolates the "raw FrameBuffer → terminal-ready strings" transformation into a testable pure function.
+8 -8
View File
@@ -6,7 +6,7 @@ Spinners now animate loading states with three styles: rotating dots (braille),
## Completed
**2026-02-02** — PR #61 merged. Three styles (dots, line, bouncing Knight Rider with ● dot fade trail). Simplified API: `Spinner("Label", style: .bouncing, color: .green)`. Fixed calibrated intervals, 9-position track with 2-position edge overshoot for smooth trail fade-in/out. Run loop upgraded to ~25 FPS (VTIME=0 + usleep 40ms).
**0: PR #61 merged. Three styles (dots, line, bouncing Knight Rider with ● dot fade trail). Simplified API: `Spinner("Label", style: .bouncing, color: .green)`. Fixed calibrated intervals, 9-position track with 2-position edge overshoot for smooth trail fade-in/out. Run loop upgraded to ~25 FPS (VTIME=0 + usleep 40ms).
## Checklist
@@ -34,13 +34,13 @@ Spinner("Processing...", style: .bouncing, color: .cyan)
### Key Design Decisions
- **Radically simplified API** — Removed `SpinnerSpeed`, `BouncingTrackWidth`, `BouncingTrailLength` enums. One init with 3 parameters.
- **Uniform dot character (●)** — All track positions use the same character. Visual distinction comes purely from opacity fade. Avoids Unicode alignment issues between different dot characters.
- **Edge overshoot (2 positions)** — Highlight travels from -2 to trackWidth+1, so the trail fades smoothly at edges instead of being cut off.
- **6-step trail** — Opacities: [1.0, 0.75, 0.5, 0.35, 0.22, 0.15]. Last step matches inactive dot opacity (0.15) for seamless blend.
- **Fixed intervals** — Dots: 110ms, Line: 140ms, Bouncing: 100ms. Calibrated by user testing.
- **Time-based frames** — Frame index calculated from elapsed time, not counter-based. Prevents drift.
- **VTIME=0 run loop** — Non-blocking stdin read + usleep(40ms) for ~25 FPS. Benefits all future animations.
- **Radically simplified API**: Removed `SpinnerSpeed`, `BouncingTrackWidth`, `BouncingTrailLength` enums. One init with 3 parameters.
- **Uniform dot character (●)**: All track positions use the same character. Visual distinction comes purely from opacity fade. Avoids Unicode alignment issues between different dot characters.
- **Edge overshoot (2 positions)**: Highlight travels from -2 to trackWidth+1, so the trail fades smoothly at edges instead of being cut off.
- **6-step trail**: Opacities: [1.0, 0.75, 0.5, 0.35, 0.22, 0.15]. Last step matches inactive dot opacity (0.15) for seamless blend.
- **Fixed intervals**: Dots: 110ms, Line: 140ms, Bouncing: 100ms. Calibrated by user testing.
- **Time-based frames**: Frame index calculated from elapsed time, not counter-based. Prevents drift.
- **VTIME=0 run loop**: Non-blocking stdin read + usleep(40ms) for ~25 FPS. Benefits all future animations.
## Goal
+9 -9
View File
@@ -6,7 +6,7 @@ Toast notifications appear and fade smoothly to communicate ephemeral messages (
## Completed
**2026-02-05** — Superseded by the Notification System (PR #77). The final implementation diverged significantly: fire-and-forget `NotificationService.post()` instead of `Binding<Bool>`, no severity styles (just a notification), fixed top-right placement, `Box`-based rendering.
**0: Superseded by the Notification System (PR #77). The final implementation diverged significantly: fire-and-forget `NotificationService.post()` instead of `Binding<Bool>`, no severity styles (just a notification), fixed top-right placement, `Box`-based rendering.
## Goal
@@ -30,11 +30,11 @@ content.toast("Info", isPresented: $show, style: .info, alignment: .top)
### Parameters
- `message: String` — the toast text
- `isPresented: Binding<Bool>` — controls visibility; auto-set to `false` after dismiss
- `style: ToastStyle` — visual preset (default: `.info`)
- `duration: TimeInterval` — how long the toast stays visible (default: 3.0)
- `alignment: Alignment` — where to position the toast (default: `.bottom`)
- `message: String`. Uthe toast text
- `isPresented: Binding<Bool>`. Ucontrols visibility; auto-set to `false` after dismiss
- `style: ToastStyle`. Uvisual preset (default: `.info`)
- `duration: TimeInterval`. Uhow long the toast stays visible (default: 3.0)
- `alignment: Alignment`. Uwhere to position the toast (default: `.bottom`)
### ToastStyle
@@ -62,7 +62,7 @@ The toast uses the same lifecycle task pattern as Spinner, but instead of cyclin
The current phase and start time are stored in `StateStorage`. A lifecycle task runs at 40ms intervals (matching the run loop), calculates the current opacity from elapsed time, and calls `setNeedsRender()`.
All text rendering goes through `ANSIRenderer.colorize()` which supports `Color.opacity()` — this gives us smooth fading using the existing RGB color system.
All text rendering goes through `ANSIRenderer.colorize()` which supports `Color.opacity()`. Uthis gives us smooth fading using the existing RGB color system.
### Rendering
@@ -87,7 +87,7 @@ Positioned via `.overlay(alignment:)` on the base content.
The `ToastModifier` is a `Renderable` that manages:
- A lifecycle token (UUID-based) for the animation task
- StateStorage entries for: phase enum, phase start time, current opacity
- The `Binding<Bool>` for isPresented — flipped to `false` after fade-out completes
- The `Binding<Bool>` for isPresented. Uflipped to `false` after fade-out completes
### Integration with Existing Systems
@@ -126,5 +126,5 @@ The `ToastModifier` is a `Renderable` that manages:
## Open Questions
1. **Border or borderless?** Bordered box (like Alert) or minimal colored background strip?
2. **Icon characters?** `✓` success, `⚠` warning, `✕` error, `ℹ` info — or simpler?
2. **Icon characters?** `✓` success, `⚠` warning, `✕` error, `ℹ` info. Uor simpler?
3. **Max width?** Fixed width or auto-sized to message length?
@@ -6,19 +6,19 @@ Focus Sections enable multi-panel TUIs where Tab switches between named focusabl
## Completed
**2026-02-03** — All framework steps implemented and tested. 482 tests passing. Example App update deferred to separate redesign effort.
**0: All framework steps implemented and tested. 482 tests passing. Example App update deferred to separate redesign effort.
---
## Problem
Currently, the StatusBar is a flat global state — one set of items for the entire screen. There is no concept of focus-aware StatusBar items that change based on which UI area is active.
Currently, the StatusBar is a flat global state. Uone set of items for the entire screen. There is no concept of focus-aware StatusBar items that change based on which UI area is active.
Real-world TUI apps (e.g. a Spotify-like player with playlist + tracklist) need:
- **Tab/arrow navigation** between focusable areas on one screen
- **Context-dependent StatusBar** — each focused area shows its own shortcuts
- **Cascading inheritance** — if a focused area doesn't define StatusBar items, it inherits from its parent
- **Visual focus indicator** — the user must immediately see which section is active
- **Context-dependent StatusBar**: each focused area shows its own shortcuts
- **Cascading inheritance**: if a focused area doesn't define StatusBar items, it inherits from its parent
- **Visual focus indicator**: the user must immediately see which section is active
## Key Design Principle: Declarative, Not Imperative
@@ -26,7 +26,7 @@ StatusBar items are **not added and removed at runtime**. They are **declared**
Each Focus Section declares its items once via `.statusBarItems()`. These declarations exist as long as the section exists. When the active section changes (e.g. via Tab), the StatusBar **resolves** which items to display by reading the active section's declaration and walking up the tree.
There is no push/pop, no add/remove at runtime. The `activeSectionID` pointer moves — the next render frame picks up the new section's items automatically.
There is no push/pop, no add/remove at runtime. The `activeSectionID` pointer moves. Uthe next render frame picks up the new section's items automatically.
This replaces the current imperative `userContextStack` (push/pop) in `StatusBarState`.
@@ -54,9 +54,9 @@ In macOS, the active window is obvious via title bar highlighting and drop shado
#### Breathing Animation
- The dot **pulses** (fades in and out) using smooth RGB color interpolation — not ANSI dim/bold steps, but true 16M color fading via `38;2;r;g;b`
- The dot **pulses** (fades in and out) using smooth RGB color interpolation. Unot ANSI dim/bold steps, but true 16M color fading via `38;2;r;g;b`
- The fade interpolates between a dimmed version (~20% brightness) and the full `palette.accent` color using a sine curve
- Cycle duration: ~3 seconds (slow, calm breathing — like the MacBook sleep LED or the Landing Page theme buttons)
- Cycle duration: ~3 seconds (slow, calm breathing. Ulike the MacBook sleep LED or the Landing Page theme buttons)
- ~8-10 discrete brightness steps per cycle, ~300ms per step
#### Timer Architecture
@@ -81,18 +81,18 @@ let color = lerp(dimColor, accentColor, phase) // Interpolated RGB
public enum StatusBarItemComposition {
/// Merges with parent items. Child items override parent items on shortcut conflict.
case merge
/// Replaces all parent items. Acts as a cascade barrier — nothing above leaks through.
/// Replaces all parent items. Acts as a cascade barrier. Unothing above leaks through.
case replace
}
```
- **`.merge`** (default) — Section's items are combined with parent items. If a child declares the same shortcut as a parent, the child wins.
- **`.replace`** — Section's items are the only items shown. Parent items are invisible. Use case: Modals that need a clean slate (ESC→close, not ESC→back).
- **`.merge`** (default): Section's items are combined with parent items. If a child declares the same shortcut as a parent, the child wins.
- **`.replace`**: Section's items are the only items shown. Parent items are invisible. Use case: Modals that need a clean slate (ESC→close, not ESC→back).
### API
```swift
// Two panels — each merges its items with the parent's (default behavior)
// Two panels. Ueach merges its items with the parent's (default behavior)
HStack {
PlaylistView()
.focusSection("playlist")
@@ -109,14 +109,14 @@ HStack {
}
}
.statusBarItems {
// Parent items — visible as long as child uses .merge (default)
// Parent items. Uvisible as long as child uses .merge (default)
StatusBarItem(shortcut: Shortcut.escape, label: "back")
StatusBarItem(shortcut: Shortcut.tab, label: "switch panel")
}
```
```swift
// Modal — replaces all parent items (clean slate)
// Modal. Ureplaces all parent items (clean slate)
.modal(isPresented: $showSettings) {
SettingsView()
.focusSection("settings")
@@ -128,7 +128,7 @@ HStack {
```
```swift
// Panel without own StatusBar items — inherits everything from parent
// Panel without own StatusBar items. Uinherits everything from parent
HStack {
SidebarView()
.focusSection("sidebar") // No items → inherits parent's StatusBar
@@ -176,7 +176,7 @@ struct StatusBarItemsModifier<Content: View>: View {
}
```
The `context: String?` parameter is removed — context is now determined by the focus section, not by a manual string.
The `context: String?` parameter is removed. Ucontext is now determined by the focus section, not by a manual string.
#### 3. FocusManager Extensions
@@ -203,10 +203,10 @@ FocusManager
New framework-level components:
- **`PulseTimer`** — Dedicated `DispatchSourceTimer` that drives the breathing animation independently from Spinner and RenderLoop. Maintains a phase counter (0.0–1.0, sine-based). Calls `setNeedsRender()` on each step change.
- **`Color.lerp(_:_:phase:)`** — RGB linear interpolation between two colors. Used to compute the current breathing color from dimmed (20%) to full accent.
- **`BorderRenderer` changes** — When rendering a border for a view inside an active focus section, the top-left corner is followed by a ● character in the interpolated pulse color instead of the normal horizontal border character.
- **`RenderContext` changes** — Carries the current pulse phase so `BorderRenderer` can read it during rendering without accessing global state.
- **`PulseTimer`**: Dedicated `DispatchSourceTimer` that drives the breathing animation independently from Spinner and RenderLoop. Maintains a phase counter (0.0–1.0, sine-based). Calls `setNeedsRender()` on each step change.
- **`Color.lerp(_:_:phase:)`**: RGB linear interpolation between two colors. Used to compute the current breathing color from dimmed (20%) to full accent.
- **`BorderRenderer` changes**: When rendering a border for a view inside an active focus section, the top-left corner is followed by a ● character in the interpolated pulse color instead of the normal horizontal border character.
- **`RenderContext` changes**: Carries the current pulse phase so `BorderRenderer` can read it during rendering without accessing global state.
#### 5. StatusBar Cascading Resolution
@@ -240,29 +240,29 @@ Modals are natural focus sections. When a modal is presented:
- Base content sections are inactive (already handled by context isolation)
- When modal closes, previous section regains focus
This eliminates the need for special modal ESC handling — it's just a focus section with `.replace` composition.
This eliminates the need for special modal ESC handling. Uit's just a focus section with `.replace` composition.
## Implementation Steps
- [x] **Step 1: FocusSection registration** — `FocusSection` type, FocusManager tracks sections, `.focusSection()` modifier registers them during rendering
- [x] **Step 2: Section-aware navigation** — Tab cycles sections, Up/Down within active section
- [x] **Step 3: Breathing dot indicator** — `PulseTimer`, `Color.lerp()`, `BorderRenderer` integration, `●` in active section border
- [x] **Step 4: StatusBar cascading** — `StatusBarItemComposition` enum, `.statusBarItems(.merge/.replace)` API, cascading resolution from active section to root
- [x] **Step 5: Modal as FocusSection** — ModalPresentationModifier/AlertPresentationModifier auto-create focus sections with dedicated section IDs
- [x] **Step 6: Example app** — Deferred. Example App will be redesigned as multiple small focused apps (separate effort).
- [x] **Step 7: Tests** — 26 new tests: focus section cycling, StatusBar cascading, Color.lerp, PulseTimer, border indicator
- [x] **Step 1: FocusSection registration**: `FocusSection` type, FocusManager tracks sections, `.focusSection()` modifier registers them during rendering
- [x] **Step 2: Section-aware navigation**: Tab cycles sections, Up/Down within active section
- [x] **Step 3: Breathing dot indicator**: `PulseTimer`, `Color.lerp()`, `BorderRenderer` integration, `●` in active section border
- [x] **Step 4: StatusBar cascading**: `StatusBarItemComposition` enum, `.statusBarItems(.merge/.replace)` API, cascading resolution from active section to root
- [x] **Step 5: Modal as FocusSection**: ModalPresentationModifier/AlertPresentationModifier auto-create focus sections with dedicated section IDs
- [x] **Step 6: Example app**: Deferred. Example App will be redesigned as multiple small focused apps (separate effort).
- [x] **Step 7: Tests**: 26 new tests: focus section cycling, StatusBar cascading, Color.lerp, PulseTimer, border indicator
## Decisions Made
- **2026-02-03**: API naming — `addStatusBarItems()` / `setStatusBarItems()` renamed to `.statusBarItems(.merge)` / `.statusBarItems(.replace)` with `StatusBarItemComposition` enum. Reason: The old names were imperative and suggested runtime add/remove behavior. The new API is declarative — it describes composition strategy, not an action.
- **2026-02-03**: Default composition is `.merge` — the common case (section adds items to parent's). `.replace` is the explicit opt-in for modals/clean-slate scenarios.
- **2026-02-03**: API naming: `addStatusBarItems()` / `setStatusBarItems()` renamed to `.statusBarItems(.merge)` / `.statusBarItems(.replace)` with `StatusBarItemComposition` enum. Reason: The old names were imperative and suggested runtime add/remove behavior. The new API is declarative. Uit describes composition strategy, not an action.
- **2026-02-03**: Default composition is `.merge`. Uthe common case (section adds items to parent's). `.replace` is the explicit opt-in for modals/clean-slate scenarios.
- **2026-02-03**: `.statusBarItems { ... }` without composition parameter defaults to `.merge`.
- **2026-02-03**: Active section indicator is a **breathing ● dot** rendered inside the section's top border, one position right of the corner. Uses true 16M RGB color interpolation (sine-based fade between 20% and 100% of `palette.accent`). Driven by a dedicated framework-level `PulseTimer`, independent from Spinner and RenderLoop timers.
## Open Questions (for future iterations)
- Should sections support a `defaultFocus` parameter to specify which child gets focus when the section activates?
- How to handle nested focus sections (section within a section)? The cascade model already supports nesting — `.replace` blocks the cascade, `.merge` is transparent.
- How to handle nested focus sections (section within a section)? The cascade model already supports nesting: `.replace` blocks the cascade, `.merge` is transparent.
- Should Tab always cycle sections, or should it be configurable per section?
- When using `.merge` with a shortcut conflict, should the child silently override, or should there be a warning in debug builds?
- ~~Should `focusSection()` visually indicate which section is active?~~ → **Yes. Breathing ● dot in border.**
+13 -13
View File
@@ -1,4 +1,4 @@
# Palette Consolidation — Replace 6 Palette Structs with SystemPalette.Preset Enum
# Palette Consolidation: Replace 6 Palette Structs with SystemPalette.Preset Enum
## Preface
@@ -6,7 +6,7 @@ Six palette files are consolidated into one: a `SystemPalette.Preset` enum with
## Completed
**2026-02-03** — PR #70 merged. Six palette files consolidated into single `SystemPalette.Preset` enum with shared implementation.
**0: PR #70 merged. Six palette files consolidated into single `SystemPalette.Preset` enum with shared implementation.
## Problem
@@ -34,22 +34,22 @@ A single `SystemPalette: BlockPalette` struct that takes a `SystemPalette.Preset
### What stays the same
- `Palette` protocol — unchanged
- `BlockPalette` protocol — unchanged
- `Cyclable` protocol — unchanged
- `ThemeManager` — unchanged (works with `any Cyclable`)
- `SemanticColor` — unchanged (resolves against `any Palette`)
- Custom user palettes — still conform to `Palette` or `BlockPalette` directly
- `Palette` protocol. Uunchanged
- `BlockPalette` protocol. Uunchanged
- `Cyclable` protocol. Uunchanged
- `ThemeManager`. Uunchanged (works with `any Cyclable`)
- `SemanticColor`. Uunchanged (resolves against `any Palette`)
- Custom user palettes. Ustill conform to `Palette` or `BlockPalette` directly
### What changes
1. **Delete**: 6 palette files in `Sources/TUIkit/Styling/Palettes/`
2. **New**: `PalettePreset.swift` in `Sources/TUIkit/Styling/Palettes/` — enum + SystemPalette + tuning data
3. **Update**: `PaletteRegistry` in `Theme.swift` — build from `SystemPalette.Preset.allCases`
4. **Update**: `PaletteKey` default — use `SystemPalette(.green)`
2. **New**: `PalettePreset.swift` in `Sources/TUIkit/Styling/Palettes/`. Uenum + SystemPalette + tuning data
3. **Update**: `PaletteRegistry` in `Theme.swift`. Ubuild from `SystemPalette.Preset.allCases`
4. **Update**: `PaletteKey` default. Uuse `SystemPalette(.green)`
5. **Update**: Doc comments referencing `GreenPalette()` etc. → `SystemPalette(.green)`
6. **Update**: Convenience accessors on `BlockPalette` — `.green`, `.amber`, `.default` etc.
7. **Update**: Tests — replace concrete palette types with `SystemPalette(.xxx)`
6. **Update**: Convenience accessors on `BlockPalette`: `.green`, `.amber`, `.default` etc.
7. **Update**: Tests. Ureplace concrete palette types with `SystemPalette(.xxx)`
### Convenience API
+4 -4
View File
@@ -2,15 +2,15 @@
## Preface
Subtree memoization caches rendered FrameBuffers by view identity: when a view conforms to `Equatable` and is wrapped in `.equatable()`, the framework compares the new view with the cached one. If equal (and available size hasn't changed), the cached FrameBuffer is reused — skipping the entire subtree's re-rendering. `RenderCache` stores buffers keyed by `ViewIdentity`, invalidated when `@State` changes or environment changes. Between state changes, identical views like Spinners skip rendering entirely.
Subtree memoization caches rendered FrameBuffers by view identity: when a view conforms to `Equatable` and is wrapped in `.equatable()`, the framework compares the new view with the cached one. If equal (and available size hasn't changed), the cached FrameBuffer is reused. Uskipping the entire subtree's re-rendering. `RenderCache` stores buffers keyed by `ViewIdentity`, invalidated when `@State` changes or environment changes. Between state changes, identical views like Spinners skip rendering entirely.
## Completed
**2026-02-04** — PR #71 merged. 18 tests (11 RenderCache + 7 EquatableView), 503 total.
**0: PR #71 merged. 18 tests (11 RenderCache + 7 EquatableView), 503 total.
## Problem
After phases 1–4 optimized terminal I/O (line diffing, output buffering, caching), the view tree is still **fully reconstructed every frame**. Every `body` is evaluated, every `renderToBuffer()` runs, every FrameBuffer is allocated — even when nothing changed.
After phases 1–4 optimized terminal I/O (line diffing, output buffering, caching), the view tree is still **fully reconstructed every frame**. Every `body` is evaluated, every `renderToBuffer()` runs, every FrameBuffer is allocated. Ueven when nothing changed.
For a UI with 30 views and a single animating Spinner, ~29 views produce identical output. The entire subtree is re-rendered for nothing.
@@ -195,6 +195,6 @@ renderToBuffer(view, context)
## Open Questions
1. **Should `EquatableView` also check environment values?** Currently only checking view equality + context size. If a parent changes `.foregroundColor()`, the cached buffer would be stale. For now: no environment check — the full-clear-on-state-change covers most cases, and environment changes typically accompany state changes. Can add later if needed.
1. **Should `EquatableView` also check environment values?** Currently only checking view equality + context size. If a parent changes `.foregroundColor()`, the cached buffer would be stale. For now: no environment check. Uthe full-clear-on-state-change covers most cases, and environment changes typically accompany state changes. Can add later if needed.
2. **Should we expose cache statistics?** A `RenderCache.Stats` struct (hits, misses, entries) could help developers optimize. Low priority, easy to add.
@@ -6,7 +6,7 @@ A generic `AvatarMarquee` component now displays scrolling avatar lists (stargaz
## Completed
**2026-02-04** — AvatarMarquee component implemented with infinite scroll, smooth braking, fade masks, arrow targeting, and popover integration. StargazersPanel migrated.
**0: AvatarMarquee component implemented with infinite scroll, smooth braking, fade masks, arrow targeting, and popover integration. StargazersPanel migrated.
---
+15 -15
View File
@@ -1,4 +1,4 @@
# Project Dashboard — Live GitHub Metrics Page
# Project Dashboard: Live GitHub Metrics Page
## Preface
@@ -6,7 +6,7 @@ A `/dashboard` page now displays live GitHub metrics: stat cards (commits, stars
## Completed
**2026-02-04** — Dashboard live at `/dashboard` with stat cards, commit heatmap, language bar, commit list, stargazer panel, shared nav, and rate limit display.
**0: Dashboard live at `/dashboard` with stat cards, commit heatmap, language bar, commit list, stargazer panel, shared nav, and rate limit display.
## Checklist
@@ -36,7 +36,7 @@ A `/dashboard` subpage of the Landing Page that visualizes all relevant project
- Next.js App Router (new route `app/dashboard/page.tsx`)
- Tailwind CSS v4 (existing theme variables)
- GitHub REST API (client-side fetch, 60 req/h unauthenticated)
- No chart library — custom SVG/CSS visualizations (consistent with retro style)
- No chart library. Ucustom SVG/CSS visualizations (consistent with retro style)
## Data Strategy
@@ -66,11 +66,11 @@ All client-side via `useGitHubStats()` hook. A `useEffect` on mount fires ~12 pa
- Display remaining rate limit in footer
- Graceful degradation: On 403 → error message instead of crash
- No auto-refresh — only on page load + manual refresh button
- No auto-refresh. Uonly on page load + manual refresh button
### Refresh
Manual refresh button in header (next to the title). Shows loading spinner during fetch. No auto-refresh — with 13 req per fetch and 60 req/h limit, manual is the safest option.
Manual refresh button in header (next to the title). Shows loading spinner during fetch. No auto-refresh. Uwith 13 req per fetch and 60 req/h limit, manual is the safest option.
## Layout
@@ -157,22 +157,22 @@ Manual refresh button in header (next to the title). Shows loading spinner durin
### 1. GitHub API Hook
- [x] `useGitHubStats.ts` — Types, fetch logic, parallel requests, error handling
- [x] `useGitHubStats.ts`: Types, fetch logic, parallel requests, error handling
- [x] Rate limit tracking (remaining/limit from response headers)
- [x] AbortController for cleanup on unmount
- [x] `refresh()` function in hook return for manual re-fetch
### 2. Dashboard Components
- [x] `StatCard.tsx` — Icon + number + label, loading skeleton
- [x] `ActivityHeatmap.tsx` — CSS Grid, 7 rows × 52 cols, tooltip with date/count
- [x] `LanguageBar.tsx` — Stacked bar + legend, percentage calculation
- [x] `CommitList.tsx` — SHA link, full message (title + expandable body), author, relative time helper
- [x] `RepoInfo.tsx` — Metadata grid (Created, License, Size, Last Push, Branch)
- [x] `StatCard.tsx`: Icon + number + label, loading skeleton
- [x] `ActivityHeatmap.tsx`: CSS Grid, 7 rows × 52 cols, tooltip with date/count
- [x] `LanguageBar.tsx`: Stacked bar + legend, percentage calculation
- [x] `CommitList.tsx`: SHA link, full message (title + expandable body), author, relative time helper
- [x] `RepoInfo.tsx`: Metadata grid (Created, License, Size, Last Push, Branch)
### 3. Dashboard Page
- [x] `app/dashboard/page.tsx` — Layout with all components
- [x] `app/dashboard/page.tsx`: Layout with all components
- [x] Loading state (skeleton)
- [x] Error state (rate limit exceeded / network error)
- [x] Rate limit display in footer
@@ -191,6 +191,6 @@ Manual refresh button in header (next to the title). Shows loading spinner durin
## Open Questions
1. **Auto-Refresh?** Timer that re-fetches every 5 minutes? For now no — only on page load. Can be added later.
2. **Shared Nav?** The nav is currently inline in `page.tsx`. Should be extracted as its own component so Dashboard has the same nav. But: minimal-invasive — could also be copied.
3. **Chart Library?** Recharts, Chart.js? No — custom SVG/CSS fits the retro style better and avoids a dependency.
1. **Auto-Refresh?** Timer that re-fetches every 5 minutes? For now no. Uonly on page load. Can be added later.
2. **Shared Nav?** The nav is currently inline in `page.tsx`. Should be extracted as its own component so Dashboard has the same nav. But: minimal-invasive. Ucould also be copied.
3. **Chart Library?** Recharts, Chart.js? No. Ucustom SVG/CSS fits the retro style better and avoids a dependency.
@@ -1,4 +1,4 @@
# Stargazer Mastodon Lookup — Automatic Account Discovery
# Stargazer Mastodon Lookup: Automatic Account Discovery
## Preface
@@ -6,7 +6,7 @@ A scheduled GitHub Action discovers Mastodon accounts for stargazers: searches G
## Completed
**2026-02-04** — Superseded by [2026-02-04-stargazer-social-lookup.md](2026-02-04-stargazer-social-lookup.md) which expands scope to multi-platform discovery (Mastodon + Twitter/X + Bluesky).
**0: Superseded by [2026-02-04-stargazer-social-lookup.md](2026-02-04-stargazer-social-lookup.md) which expands scope to multi-platform discovery (Mastodon + Twitter/X + Bluesky).
---
@@ -68,7 +68,7 @@ Find Mastodon accounts for all repository stargazers and display/link them in th
1. **Stargazers** are fetched **live** from GitHub API on every page load (existing behavior)
2. **Mastodon data** is loaded from a cached JSON file (fetched once, updated daily)
3. **Merge at runtime**: Match stargazers by `login` with Mastodon cache entries
4. **New stargazers** appear immediately — just without Mastodon info until next scheduled run
4. **New stargazers** appear immediately. Ujust without Mastodon info until next scheduled run
### Update Strategy: Incremental + Weekly Full Refresh
@@ -1,4 +1,4 @@
# Stargazer Social Account Lookup — Multi-Platform Discovery
# Stargazer Social Account Lookup: Multi-Platform Discovery
## Preface
@@ -6,7 +6,7 @@ Multi-platform social lookup now discovers Mastodon, Twitter, and Bluesky accoun
## Completed
**2026-02-04** — Social lookup script, GitHub Action workflow, and UI integration all functional. Supersedes [2026-02-04-stargazer-mastodon-lookup.md](2026-02-04-stargazer-mastodon-lookup.md).
**0: Social lookup script, GitHub Action workflow, and UI integration all functional. Supersedes [2026-02-04-stargazer-mastodon-lookup.md](2026-02-04-stargazer-mastodon-lookup.md).
## Checklist
+24 -24
View File
@@ -1,12 +1,12 @@
# Remove Block/Flat Appearances — Simplify to Border-Only Rendering
# Remove Block/Flat Appearances: Simplify to Border-Only Rendering
## Preface
Block and Flat appearances are both removed (36 files changed). The framework now offers four border-based appearances: line, rounded, doubleLine, heavy — all using standard Unicode box-drawing characters. Surface color tokens are consolidated into `Palette` directly (no more `BlockPalette` protocol). Simplified architecture, eliminated half-block complexity that broke on many terminals, and gained universal compatibility using only ANSI backgrounds and standard borders.
Block and Flat appearances are both removed (36 files changed). The framework now offers four border-based appearances: line, rounded, doubleLine, heavy. Uall using standard Unicode box-drawing characters. Surface color tokens are consolidated into `Palette` directly (no more `BlockPalette` protocol). Simplified architecture, eliminated half-block complexity that broke on many terminals, and gained universal compatibility using only ANSI backgrounds and standard borders.
## Completed
**2026-02-05** — Both Block and Flat were removed entirely. The framework now has 4 standard border-based appearances only (line, rounded, doubleLine, heavy). BorderStyle.ascii was also removed. Surface color tokens consolidated into Palette. 36 files changed, +225 / −1140 lines. 526 tests passing.
**0: Both Block and Flat were removed entirely. The framework now has 4 standard border-based appearances only (line, rounded, doubleLine, heavy). BorderStyle.ascii was also removed. Surface color tokens consolidated into Palette. 36 files changed, +225 / −1140 lines. 526 tests passing.
## Checklist
@@ -39,7 +39,7 @@ Replace the fragile Block Appearance (half-block Unicode characters `▄▀█`
### What stays
- **Three surface color tokens**: `surfaceBackground`, `surfaceHeaderBackground`, `elevatedBackground` — these define the visual hierarchy and are the core of the "flat" look.
- **Three surface color tokens**: `surfaceBackground`, `surfaceHeaderBackground`, `elevatedBackground`. Uthese define the visual hierarchy and are the core of the "flat" look.
- **Color hierarchy**: background (darkest) → surfaceHeaderBackground → surfaceBackground → elevatedBackground (brightest).
- **Per-view section coloring**: ContainerView header/footer use `surfaceHeaderBackground`, body uses `surfaceBackground`. Buttons use `elevatedBackground`.
@@ -47,19 +47,19 @@ Replace the fragile Block Appearance (half-block Unicode characters `▄▀█`
| Aspect | Block (old) | Flat (new) |
|--------|-------------|------------|
| Border characters | `▄▀█` half/full blocks | None — `BorderStyle.none` (spaces) |
| Section transitions | `blockSeparator` with FG/BG trick | No separator — sections distinguished by background color only |
| Side borders | `█` full blocks | No side borders — content fills full width with background color |
| Top/bottom edges | `▄▄▄` / `▀▀▀` rows | No edge rows — background starts/ends with content |
| Terminal compat | Broken in many terminals | Universal — only requires ANSI background color support |
| Border characters | `▄▀█` half/full blocks | None: `BorderStyle.none` (spaces) |
| Section transitions | `blockSeparator` with FG/BG trick | No separator. Usections distinguished by background color only |
| Side borders | `█` full blocks | No side borders. Ucontent fills full width with background color |
| Top/bottom edges | `▄▄▄` / `▀▀▀` rows | No edge rows. Ubackground starts/ends with content |
| Terminal compat | Broken in many terminals | Universal. Uonly requires ANSI background color support |
### What gets removed
- `BlockPalette` protocol — the 3 color properties move into `Palette` directly
- `BlockPalette` protocol. Uthe 3 color properties move into `Palette` directly
- `BorderStyle.block` preset + `blockBottomHorizontal` + `blockFooterSeparator` statics
- `BorderRenderer` block methods: `blockTopBorder`, `blockBottomBorder`, `blockContentLine`, `blockSeparator`
- All `appearance.rawId == .block` branches in views — replaced with `== .flat` branches
- `BlockThemePage.swift` example page — replace with `FlatThemePage.swift` or integrate into existing demo
- All `appearance.rawId == .block` branches in views. Ureplaced with `== .flat` branches
- `BlockThemePage.swift` example page. Ureplace with `FlatThemePage.swift` or integrate into existing demo
### Migration: `BlockPalette` → `Palette`
@@ -84,7 +84,7 @@ protocol Palette {
This eliminates the `as? BlockPalette` casts and fallbacks throughout the codebase.
### Flat Rendering — Per Component
### Flat Rendering: Per Component
All flat-rendered sections use **1 character horizontal padding** (left and right) so content doesn't stick to the edges. This replaces the `█` side borders from block rendering with simple space padding inside the background fill.
@@ -92,7 +92,7 @@ All flat-rendered sections use **1 character horizontal padding** (left and righ
- Header lines: persistent background = `surfaceHeaderBackground`, 1-char padding left/right. No top border row.
- Body lines: persistent background = `surfaceBackground`, 1-char padding left/right. No side borders.
- Footer lines: persistent background = `surfaceHeaderBackground`, 1-char padding left/right. No bottom border row.
- No separator rows between sections — color change alone provides visual distinction.
- No separator rows between sections. Ucolor change alone provides visual distinction.
**BorderModifier (Box, `.border()`):**
- All content lines: persistent background = `surfaceBackground`, 1-char padding left/right.
@@ -108,7 +108,7 @@ All flat-rendered sections use **1 character horizontal padding** (left and righ
**AppHeader:**
- Full-width content with persistent background = `appHeaderBackground`, 1-char padding left/right.
- Bottom divider: thin line using `─` (standard divider, not `▀`), or no divider at all — TBD.
- Bottom divider: thin line using `─` (standard divider, not `▀`), or no divider at all: TBD.
**Menu:**
- Header lines: persistent background = `surfaceHeaderBackground`, 1-char padding left/right.
@@ -117,16 +117,16 @@ All flat-rendered sections use **1 character horizontal padding** (left and righ
## Steps
## Completed — 2026-02-05
## Completed: 2026-02-05
### Phase 1: Palette Refactoring
- [x] Move `surfaceBackground`, `surfaceHeaderBackground`, `elevatedBackground` from `BlockPalette` into `Palette` with default implementations
- [x] Remove `BlockPalette` protocol entirely
- [x] Change `SystemPalette: BlockPalette` → `SystemPalette: Palette`
- [x] Remove `Palette` convenience accessors (`blockSurfaceBackground` etc.) — use direct property access
- [x] Update `SemanticColor` — keep the 3 surface cases, simplify `resolve()` to use `Palette` directly (no cast)
- [x] Keep `Color.Semantic` accessors — renamed comments from "BlockPalette" to "Surface"
- [x] Remove `Palette` convenience accessors (`blockSurfaceBackground` etc.). Uuse direct property access
- [x] Update `SemanticColor`. Ukeep the 3 surface cases, simplify `resolve()` to use `Palette` directly (no cast)
- [x] Keep `Color.Semantic` accessors. Urenamed comments from "BlockPalette" to "Surface"
- [x] Update related tests
### Phase 2: Appearance & BorderStyle Cleanup
@@ -140,7 +140,7 @@ All flat-rendered sections use **1 character horizontal padding** (left and righ
### Phase 3: Rendering Simplification
- [x] Remove `BorderRenderer` block methods (4 methods)
- [x] Add `flatContentLine(content:innerWidth:backgroundColor:)` — persistent BG + 1-char padding
- [x] Add `flatContentLine(content:innerWidth:backgroundColor:)`. Upersistent BG + 1-char padding
- [x] Rewrite `BorderModifier` → `renderFlatStyle` (background-only, no borders)
- [x] Rewrite `ContainerView` → `renderFlatStyle` (header/body/footer sections)
- [x] Rewrite `Button` flat branch (rename check only)
@@ -154,16 +154,16 @@ All flat-rendered sections use **1 character horizontal padding** (left and righ
- [x] Rename `BlockThemePage` → `FlatThemePage`
- [x] Replace `BorderRendererBlockTests` with `BorderRendererFlatTests` (3 tests)
- [x] Update `PaletteDefaultTests` → `PaletteSurfaceDefaultTests`
- [x] Update `PredefinedPaletteTests` — renamed luminance order tests
- [x] Full `swift build` + `swiftlint` + `swift test` — 536 tests / 88 suites passing
- [x] Update `PredefinedPaletteTests`. Urenamed luminance order tests
- [x] Full `swift build` + `swiftlint` + `swift test`: 536 tests / 88 suites passing
### Phase 5: Cleanup
- [x] Grep for "block" references in Sources/ and Tests/ — updated 11 doc comments
- [x] Grep for "block" references in Sources/ and Tests/. Uupdated 11 doc comments
- [x] PR (pending)
## Risk Assessment
- **Low risk**: The rendering is simpler than what it replaces. ANSI background colors work everywhere.
- **Migration**: Purely internal refactoring. No public API surface changes beyond renaming `.block` → `.flat`.
- **Visual difference**: Flat will look slightly different from Block — no smooth half-block transitions. But that's the point: simpler, universal, still visually distinct from bordered appearances.
- **Visual difference**: Flat will look slightly different from Block. Uno smooth half-block transitions. But that's the point: simpler, universal, still visually distinct from bordered appearances.
+3 -3
View File
@@ -1,4 +1,4 @@
# ProgressView — Determinate Progress Bar
# ProgressView: Determinate Progress Bar
## Preface
@@ -6,7 +6,7 @@
## Completed
**2026-02-05** — PR merged with all progress bar styles, full SwiftUI API parity, and 26 tests passing.
**0: PR merged with all progress bar styles, full SwiftUI API parity, and 26 tests passing.
## Checklist
@@ -118,5 +118,5 @@ dot: ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬●───────
- [x] Add `Equatable` conformance
- [x] Implement 5 bar styles via `ProgressBarStyle` enum + `.progressBarStyle(_:)` modifier
- [x] Create tests in `Tests/TUIkitTests/ProgressViewTests.swift` (26 tests / 3 suites)
- [x] Add to example app (ContainersPage — ProgressViewRow)
- [x] Add to example app (ContainersPage: ProgressViewRow)
- [x] `swift build` + `swiftlint` + `swift test`
@@ -6,7 +6,7 @@ Subtree memoization is activated: environment snapshot comparison in `RenderLoop
## Completed
**2026-02-05** — All 6 phases implemented. PR #74 with environment snapshot comparison, core type Equatable conformances, and debug statistics.
**2026-02-05**: All 6 phases implemented. PR #74 with environment snapshot comparison, core type Equatable conformances, and debug statistics.
## Checklist
@@ -29,7 +29,7 @@ Subtree memoization is activated: environment snapshot comparison in `RenderLoop
## Goal
Make the existing subtree memoization (Phase 5, PR #71) actually effective. Currently `EquatableView` + `RenderCache` are fully implemented but **unused in production**: zero views call `.equatable()`, and only 3 trivial views (`Spacer`, `Divider`, `EmptyView`) conform to `Equatable`. Additionally, environment changes bypass cache invalidation — a latent bug that becomes real the moment `.equatable()` is used.
Make the existing subtree memoization (Phase 5, PR #71) actually effective. Currently `EquatableView` + `RenderCache` are fully implemented but **unused in production**: zero views call `.equatable()`, and only 3 trivial views (`Spacer`, `Divider`, `EmptyView`) conform to `Equatable`. Additionally, environment changes bypass cache invalidation. This is a latent bug that becomes real the moment `.equatable()` is used.
This plan closes the gaps:
1. Fix the environment/cache invalidation bug
@@ -48,7 +48,7 @@ This plan closes the gaps:
- `HorizontalAlignment`, `VerticalAlignment` (simple enums, implicit)
- `Spacer`, `Divider`, `EmptyView`
### Need `Equatable` Conformance (trivial — add declaration, auto-synthesis works)
### Need `Equatable` Conformance (trivial, add declaration, auto-synthesis works)
- `TextStyle` (9 properties: `Color?` × 2, `Bool` × 7)
- `Alignment` (2 properties: `HorizontalAlignment`, `VerticalAlignment`)
- `ContainerConfig` (5 properties: `BorderStyle?`, `Color?` × 2, `EdgeInsets`, `Bool`)
@@ -57,12 +57,12 @@ This plan closes the gaps:
- `StateBox.value.didSet` → `renderCache.clearAll()` ✅
- `ThemeManager.applyCurrentItem()` → `appState.setNeedsRender()` only ❌
- `StatusBarState` (6 call sites) → `appState.setNeedsRender()` only ❌
- `PulseTimer` / `Spinner` → `setNeedsRender()` only — **correct**, these don't change view content
- **Fix**: Environment snapshot comparison in `RenderLoop` — see Phase 1
- `PulseTimer` / `Spinner` → `setNeedsRender()` only. This is **correct**, as these don't change view content.
- **Fix**: Environment snapshot comparison in `RenderLoop`. See Phase 1.
### Where Cache Helps Most
- **Spinner frames** (~25 FPS): `setNeedsRender()` without `@State` change → cache stays valid between state changes. Static views next to spinners benefit fully.
- **Pulse animation**: Same pattern — `PulseTimer` triggers renders, cache survives.
- **Pulse animation**: Same pattern. `PulseTimer` triggers renders, cache survives.
- **After any state change**: One full render repopulates cache, subsequent frames reuse it until next state change.
### Closure Problem
@@ -73,29 +73,29 @@ This plan closes the gaps:
### Phase 1: Fix Cache Invalidation Bug (High Priority)
**Principle: The SDK handles invalidation — developers never think about it.**
**Principle: The SDK handles invalidation. Developers never think about it.**
#### The Problem
Environment changes (theme, palette, appearance) bypass `renderCache.clearAll()`. Only `StateBox.value.didSet` clears the cache. `ThemeManager`, `StatusBarState`, and other framework services call `appState.setNeedsRender()` directly — the cache serves stale content.
Environment changes (theme, palette, appearance) bypass `renderCache.clearAll()`. Only `StateBox.value.didSet` clears the cache. `ThemeManager`, `StatusBarState`, and other framework services call `appState.setNeedsRender()` directly, causing the cache to serve stale content.
#### Rejected Approaches
- **Option A**: `clearAll()` in `AppState.setNeedsRender()` — kills cache during Spinner/Pulse frames (~25 FPS of wasted memoization)
- **Option B**: `clearAll()` in specific callers (`ThemeManager`, etc.) — fragile, easy to forget for future callers
- **Option A**: `clearAll()` in `AppState.setNeedsRender()`. This kills cache during Spinner/Pulse frames (~25 FPS of wasted memoization).
- **Option B**: `clearAll()` in specific callers (`ThemeManager`, etc.). This is fragile and easy to forget for future callers.
- **Generation counter on `EnvironmentValues`**: `buildEnvironment()` creates a fresh instance every frame and sets the same N keys → mutation count is identical regardless of whether values changed. Values are `Any` → can't compare.
#### Chosen Approach: Environment Snapshot Comparison in RenderLoop
`RenderLoop` tracks the identity of environment values that affect visual output. After `buildEnvironment()`, it compares the current snapshot with the previous frame. If different → `clearAll()`.
The snapshot tracks **only values that affect rendered output**: palette name and appearance name. Reference-type services (`FocusManager`, `ThemeManager`) don't affect cached content — they're infrastructure.
The snapshot tracks **only values that affect rendered output**: palette name and appearance name. Reference-type services (`FocusManager`, `ThemeManager`) don't affect cached content because they're infrastructure.
This is:
- **Automatic** — developer never thinks about it
- **Spinner/Pulse safe** — environment doesn't change between their frames → no clear
- **Correct** — any theme/appearance change triggers clear
- **Cheap** — two string comparisons per frame
- **Automatic**: developer never thinks about it
- **Spinner/Pulse safe**: environment doesn't change between their frames → no clear
- **Correct**: any theme/appearance change triggers clear
- **Cheap**: two string comparisons per frame
#### Implementation
@@ -115,14 +115,14 @@ This is:
#### 2a. Types (prerequisites for views)
- [x] `TextStyle: Equatable` — auto-synthesis
- [x] `Alignment: Equatable` — auto-synthesis
- [x] `ContainerConfig: Equatable` — auto-synthesis
- [x] `ContainerStyle: Equatable` — auto-synthesis (added during implementation)
- [x] `TextStyle: Equatable` with auto-synthesis
- [x] `Alignment: Equatable` with auto-synthesis
- [x] `ContainerConfig: Equatable` with auto-synthesis
- [x] `ContainerStyle: Equatable` with auto-synthesis (added during implementation)
#### 2b. Leaf Views
- [x] `Text: Equatable` — stored properties: `content: String`, `style: TextStyle`
- [x] `Text: Equatable` with stored properties: `content: String`, `style: TextStyle`
- [x] Test: existing EquatableView tests cover Text equality
#### 2c. Container Views (conditional conformance)
@@ -143,10 +143,10 @@ This is:
- [x] `FlexibleFrameView: Equatable where Content: Equatable`
- [x] `OverlayModifier: Equatable where Base: Equatable, Overlay: Equatable`
- [x] `DimmedModifier: Equatable where Content: Equatable`
- Skipped: `EnvironmentModifier` — `WritableKeyPath` is not `Equatable`
- Skipped: `PaddingModifier`, `BackgroundModifier` — these are `ViewModifier` (buffer transform), not `View`, so not relevant for `EquatableView` comparison
- Skipped: `EnvironmentModifier` because `WritableKeyPath` is not `Equatable`
- Skipped: `PaddingModifier`, `BackgroundModifier` because these are `ViewModifier` (buffer transform), not `View`, so not relevant for `EquatableView` comparison
Note: Modifier views with closures (`KeyPressModifier`, `OnAppearModifier`, `TaskModifier`, etc.) **cannot** be made `Equatable` — this is expected and matches SwiftUI behavior.
Note: Modifier views with closures (`KeyPressModifier`, `OnAppearModifier`, `TaskModifier`, etc.) **cannot** be made `Equatable`. This is expected and matches SwiftUI behavior.
### Phase 3: Debug Tooling (Medium Priority)
@@ -154,19 +154,19 @@ Note: Modifier views with closures (`KeyPressModifier`, `OnAppearModifier`, `Tas
- [x] Increment counters in `lookup` (hit/miss), `clearAll` (clears), `store`
- [x] `private(set) var stats` + `statsAtFrameStart` for per-frame deltas
- [x] `TUIKIT_DEBUG_RENDER=1` env var: per-identity HIT/MISS/STORE logs + FRAME summary
- [x] `logDebug(@autoclosure)` — zero cost when disabled, stderr output
- [x] `logDebug(@autoclosure)` with zero cost when disabled, stderr output
- [x] 11 new tests for stats counting, delta, reset
- Deferred: `View._printChanges()` equivalent — future work
- Deferred: `View._printChanges()` equivalent for future work
### Phase 4: Example App — View Decomposition (Medium Priority)
### Phase 4: Example App View Decomposition (Medium Priority)
- [x] `ContainerTypesRow` — Card/Box/Panel comparison row (from ContainersPage)
- [x] `SettingsAndAlignmentRow` — settings panel + alignment demos (from ContainersPage)
- [x] `FeatureBox` — extracted from MainMenuPage's private `featureBox()` method
- Skipped: ButtonsPage — nearly all sections depend on `@State clickCount` via Button closures, decomposition ineffective for memoization
- Skipped: CollapsibleSection — depends on `@State showDetails`, must stay in parent
- [x] `ContainerTypesRow` for Card/Box/Panel comparison row (from ContainersPage)
- [x] `SettingsAndAlignmentRow` for settings panel + alignment demos (from ContainersPage)
- [x] `FeatureBox` extracted from MainMenuPage's private `featureBox()` method
- Skipped: ButtonsPage because nearly all sections depend on `@State clickCount` via Button closures, making decomposition ineffective for memoization
- Skipped: CollapsibleSection because it depends on `@State showDetails` and must stay in parent
### Phase 5: Example App — Apply `.equatable()` (Medium Priority)
### Phase 5: Example App Apply `.equatable()` (Medium Priority)
- [x] `FeatureBox: View, Equatable` + `.equatable()` on 3 instances in MainMenuPage
- [x] `ContainerTypesRow: View, Equatable` + `.equatable()` in ContainersPage
@@ -174,7 +174,7 @@ Note: Modifier views with closures (`KeyPressModifier`, `OnAppearModifier`, `Tas
### Phase 6: Documentation (Low Priority)
- [x] Update `RenderCycle.md` — new "Subtree Memoization" section with how-it-works, invalidation, decision guide, type list, debug logging
- [x] Update `RenderCycle.md` with new "Subtree Memoization" section covering how-it-works, invalidation, decision guide, type list, debug logging
- [x] Add `EquatableView` to Renderable lists in existing sections
- [x] Correct "no subtree memoization" claim
- [x] Code example using real `FeatureBox` from example app
@@ -202,13 +202,13 @@ Note: Modifier views with closures (`KeyPressModifier`, `OnAppearModifier`, `Tas
| Risk | Mitigation |
|---|---|
| Auto-synthesized `Equatable` on generic views may not compile if `Content` has constraints | Conditional conformance `where Content: Equatable` — standard Swift pattern |
| Auto-synthesized `Equatable` on generic views may not compile if `Content` has constraints | Conditional conformance `where Content: Equatable`. This is the standard Swift pattern. |
| Over-caching after environment changes (stale colors/borders) | Phase 1 fixes this before any `.equatable()` usage |
| Debug logging performance overhead | Gated behind environment variable, zero cost when disabled |
| `TupleView` Equatable — `(A, B, C, ...)` tuples up to arity 6 are Equatable in Swift, but TUIKit's `TupleView` may need explicit conformance | Investigate during Phase 2c, add if needed |
| `TupleView` Equatable: `(A, B, C, ...)` tuples up to arity 6 are Equatable in Swift, but TUIKit's `TupleView` may need explicit conformance | Investigate during Phase 2c, add if needed |
## Open Questions
1. **`TupleView` Equatable**: `VStack { Text("A"); Text("B") }` creates a `TupleView<(Text, Text)>`. Is `TupleView` `Equatable` when its elements are? Needs investigation — may need explicit conditional conformance for each arity.
2. **Granular cache invalidation**: Instead of `clearAll()`, could we invalidate only entries that depend on changed environment keys? Complex but would maximize cache effectiveness. Probably not worth it now — `clearAll()` is simple and correct.
1. **`TupleView` Equatable**: `VStack { Text("A"); Text("B") }` creates a `TupleView<(Text, Text)>`. Is `TupleView` `Equatable` when its elements are? Needs investigation and may need explicit conditional conformance for each arity.
2. **Granular cache invalidation**: Instead of `clearAll()`, could we invalidate only entries that depend on changed environment keys? Complex but would maximize cache effectiveness. Probably not worth it now because `clearAll()` is simple and correct.
3. **Automatic `.equatable()`**: Should the framework automatically wrap views in `EquatableView` when they conform to `Equatable`? SwiftUI does NOT do this (requires explicit `.equatable()`). Recommend keeping opt-in for now.
@@ -4,7 +4,7 @@ Social lookup script is optimized to eliminate false positives: instance validat
## Completed
**2026-02-05** — All 8 implementation steps done. Commits: `fb4eef0`, `032bcc7`.
**2026-02-05**: All 8 implementation steps done. Commits: `fb4eef0`, `032bcc7`.
## Checklist
@@ -20,11 +20,11 @@ Social lookup script is optimized to eliminate false positives: instance validat
- [x] Verify no false positives
- [x] Commit updated social-cache.json
# Social Lookup Script — Matching Algorithm Optimization
# Social Lookup Script: Matching Algorithm Optimization
## Goal
Reduce false positives and increase true matches in `docs/scripts/update-social-cache.ts`. The script discovers Mastodon, Twitter, and Bluesky accounts for GitHub stargazers — but currently produces several false positives and misses verifiable matches.
Reduce false positives and increase true matches in `docs/scripts/update-social-cache.ts`. The script discovers Mastodon, Twitter, and Bluesky accounts for GitHub stargazers, but currently produces several false positives and misses verifiable matches.
## Findings
@@ -32,16 +32,16 @@ Reduce false positives and increase true matches in `docs/scripts/update-social-
| User | Platform | Match | Root Cause |
|---|---|---|---|
| `sqwu` | Mastodon | `@menghang@bento.me` | Blog URL `bento.me/@menghang` matched by `MASTODON_URL_REGEX` — bento.me is a link-in-bio service, not a Mastodon instance |
| `gahms` | Mastodon | `@henriksen@knowit.dk` | Bio contains `henriksen@knowit.dk` (corporate email) — matched by `MASTODON_HANDLE_REGEX` because `knowit.dk` is not on the email provider blocklist |
| `jtvargas` | Mastodon | `@apps@go.jrtv.space` | Blog URL `go.jrtv.space/@apps` matched — personal redirect service, not Mastodon |
| `sqwu` | Mastodon | `@menghang@bento.me` | Blog URL `bento.me/@menghang` matched by `MASTODON_URL_REGEX`. However, bento.me is a link-in-bio service, not a Mastodon instance. |
| `gahms` | Mastodon | `@henriksen@knowit.dk` | Bio contains `henriksen@knowit.dk` (corporate email). Matched by `MASTODON_HANDLE_REGEX` because `knowit.dk` is not on the email provider blocklist. |
| `jtvargas` | Mastodon | `@apps@go.jrtv.space` | Blog URL `go.jrtv.space/@apps` matched. This is a personal redirect service, not Mastodon. |
### Bug: `verified` Flag Always `false`
All 33 social entries in the cache show `verified: false`, including:
- **7 Twitter entries with `source: "github"`** — `getTwitterFromGitHubProfile()` sets `verified: true` (line 532), but the cache shows `false`. Likely cause: entries were written by an earlier script version that did not set the flag, and incremental runs skip already-cached users.
- **All `username-match` entries** — Avatar comparison never matched (different images due to resizing/compression), cross-verification found no backlink, but the entry was not deleted. Suggests the deletion logic may not be working as intended, or these entries predate it.
- **7 Twitter entries with `source: "github"`**: `getTwitterFromGitHubProfile()` sets `verified: true` (line 532), but the cache shows `false`. Likely cause: entries were written by an earlier script version that did not set the flag, and incremental runs skip already-cached users.
- **All `username-match` entries**: Avatar comparison never matched (different images due to resizing/compression), cross-verification found no backlink, but the entry was not deleted. This suggests the deletion logic may not be working as intended, or these entries predate it.
### Systematic Weaknesses
@@ -59,7 +59,7 @@ The blocklist has only 7 entries. **No instance validation** is performed.
`MASTODON_HANDLE_REGEX` matches `user@domain.tld`. The email filter only blocks 12 common providers. Any corporate email (`user@company.dk`, `user@firm.io`) gets misidentified as a Mastodon handle.
The fundamental problem: `user@domain` is ambiguous — it could be a Mastodon handle OR an email. The current approach (blocklist) cannot scale. Need a positive identification strategy instead.
The fundamental problem: `user@domain` is ambiguous. It could be a Mastodon handle or an email. The current approach (blocklist) cannot scale. A positive identification strategy is needed instead.
#### 3. Avatar Hash Too Fragile (Medium Impact)
@@ -141,7 +141,7 @@ Ensure that entries from high-trust sources keep their verified flag:
- `source: "keybase"` (cryptographically verified) → always `verified: true`
- `source: "manual"` (manual override) → always `verified: true`
The `crossPlatformVerify()` function must skip entries that are already `verified: true` from authoritative sources. Currently it checks `!accounts.mastodon.verified` which should work — but add the same guard for Twitter defensively.
The `crossPlatformVerify()` function must skip entries that are already `verified: true` from authoritative sources. Currently it checks `!accounts.mastodon.verified` which should work. Add the same guard for Twitter defensively.
#### 2b. Full Refresh Overwrites Stale Entries
@@ -166,7 +166,7 @@ Current hash is too strict. Options:
2. **Accept close file sizes**: If sizes differ by <5% and hash matches, accept.
3. **Leave as-is**: Backlink verification is the better signal. Avatar matching is nice-to-have.
Recommendation: Option 3 — rely on backlink verification. Avatar comparison is unreliable across CDNs.
Recommendation: Option 3. Rely on backlink verification, as avatar comparison is unreliable across CDNs.
### Phase 4: Cache Cleanup (Medium Priority)
@@ -195,6 +195,6 @@ After script changes are applied, run `--full` refresh to regenerate the cache.
| Risk | Mitigation |
|---|---|
| NodeInfo requests add latency | Cached per domain, only hit once per unknown domain per run |
| NodeInfo endpoint down on some instances | Timeout (5s), fall back to rejection — better to miss than false-match |
| NodeInfo endpoint down on some instances | Timeout (5s), fall back to rejection. Better to miss than false-match. |
| Stricter bio regex misses real Mastodon handles | Instance validation as fallback for bare `user@domain` patterns |
| Full refresh hits GitHub API rate limit | Script already uses `GITHUB_TOKEN` (5000 req/h), processes sequentially with 200ms delay |
+6 -6
View File
@@ -6,7 +6,7 @@
## Completed
**2026-02-06** — All implementation tasks completed. Toggle component ready for use with both styles (toggle/checkbox) and full focus management.
**2026-02-06**: All implementation tasks completed. Toggle component ready for use with both styles (toggle/checkbox) and full focus management.
## Checklist
@@ -44,7 +44,7 @@ Toggle(isOn: $isEnabled) {
```
Key SwiftUI patterns:
- `isOn: Binding<Bool>` — two-way binding
- `isOn: Binding<Bool>` for two-way binding
- Label as trailing `@ViewBuilder` closure or convenience `String` init
- `.toggleStyle()` modifier for visual variants
@@ -53,7 +53,7 @@ Key SwiftUI patterns:
### API
```swift
// Primary API — matches SwiftUI
// Primary API (matches SwiftUI)
Toggle(isOn: $isEnabled) {
Text("Enable notifications")
}
@@ -69,8 +69,8 @@ Toggle("Dark mode", isOn: $darkMode, style: .checkbox)
```swift
public enum ToggleStyle {
case toggle // [●○] / [○●] — dot slides left/right
case checkbox // [x] / [ ] — classic checkbox
case toggle // [●○] / [○●] where dot slides left/right
case checkbox // [x] / [ ] as classic checkbox
}
```
@@ -196,7 +196,7 @@ Example outputs:
## Pre-Requisite: Unified Focus Indicator ✅
Completed 2026-02-06 (PR #81):
- [x] `BorderRenderer.focusIndicatorPrefix()` — shared helper for pulsing `●` indicator
- [x] `BorderRenderer.focusIndicatorPrefix()` as shared helper for pulsing `●` indicator
- [x] Button: pulsing accent-colored brackets when focused (no dot for bracketed elements)
- [x] Plain buttons: pulsing dot indicator
- [x] PulseTimer reset on focus change
+4 -4
View File
@@ -2,7 +2,7 @@
## Preface
Session state now survives app crashes with `@RestoredState` — a disk-persisted wrapper around `@State`. Values auto-save on change (debounced) and auto-restore on startup. Signal handlers (SIGTERM/SIGINT) flush state before shutdown so no data is lost. Perfect for file managers, editors, or any app tracking last directory, scroll position, or open files. Builds on the State Storage Identity system to keep state stable across renders while adding optional persistence.
Session state now survives app crashes with `@RestoredState`. Ua disk-persisted wrapper around `@State`. Values auto-save on change (debounced) and auto-restore on startup. Signal handlers (SIGTERM/SIGINT) flush state before shutdown so no data is lost. Perfect for file managers, editors, or any app tracking last directory, scroll position, or open files. Builds on the State Storage Identity system to keep state stable across renders while adding optional persistence.
## Context / Problem
@@ -10,9 +10,9 @@ Builds on the State Storage Identity refactoring. `StateStorage` keeps `@State`
### Use Cases in Terminal Apps
1. **Crash Recovery** — SIGTERM/SIGINT: write last state to disk, restore on restart
2. **Session Continuity** — e.g. a TUI file manager remembers current directory, scroll position
3. **Explicit Save/Restore** — user-triggered (keybinding for "Save Session")
1. **Crash Recovery**: SIGTERM/SIGINT: write last state to disk, restore on restart
2. **Session Continuity**. Ue.g. a TUI file manager remembers current directory, scroll position
3. **Explicit Save/Restore**. Uuser-triggered (keybinding for "Save Session")
**Not a use case:** iOS-style background killing (does not exist in terminal apps).
@@ -2,7 +2,7 @@
## Preface
Composition replaces inheritance throughout the framework: Alert, Dialog, Panel, and Card all use the `renderContainer()` helper rather than inheriting from ContainerView. List and Table will follow the same pattern with `renderListWithFocus()` and `renderTableWithFocus()` helpers plus shared `FocusableItemListHandler`. This ensures consistency, maximizes code reuse, and keeps view definitions simple (plain structs) while rendering logic lives in testable helper functions — true to SwiftUI/TUIKit's design philosophy.
Composition replaces inheritance throughout the framework: Alert, Dialog, Panel, and Card all use the `renderContainer()` helper rather than inheriting from ContainerView. List and Table will follow the same pattern with `renderListWithFocus()` and `renderTableWithFocus()` helpers plus shared `FocusableItemListHandler`. This ensures consistency, maximizes code reuse, and keeps view definitions simple (plain structs) while rendering logic lives in testable helper functions. Utrue to SwiftUI/TUIKit's design philosophy.
## Context / Problem
@@ -129,8 +129,8 @@ This would be shared between List and Table!
## Implementation Plan
1. **Document composition pattern** — establish guidelines
2. **Review current container components** — verify consistency
1. **Document composition pattern**. Uestablish guidelines
2. **Review current container components**. Uverify consistency
3. **Create/refactor renderListWithFocus() helper** for List and Table
4. **Extract shared state management** into `FocusableItemListHandler`
5. **Verify all components use composition** via renderContainer()
@@ -149,10 +149,10 @@ This would be shared between List and Table!
**DO NOT inherit from ContainerView.** Instead:
1. **Create `renderListWithFocus()` helper** — similar to `renderContainer()`
1. **Create `renderListWithFocus()` helper**. Usimilar to `renderContainer()`
2. **Extract common focus logic** into `FocusableItemListHandler`
3. **Extract common container logic** into configuration structs
4. **Both List and Table use the helper** — maximum code reuse
4. **Both List and Table use the helper**. Umaximum code reuse
This follows SwiftUI/TUIKit patterns:
- Composition over inheritance
@@ -85,11 +85,11 @@ These are already using `renderContainer()` helper, so they won't be directly af
## Implementation Plan
1. **Create _ContainerViewCore** — private struct with Renderable
1. **Create _ContainerViewCore**. Uprivate struct with Renderable
2. **Move all rendering logic** from ContainerView to _ContainerViewCore
3. **Make ContainerView a simple View** with body that creates _ContainerViewCore
4. **Verify modifiers work** — test `.foregroundColor()`, `.padding()`, etc.
5. **Check all users** — Card, Panel, Alert, Dialog still work
4. **Verify modifiers work**. Utest `.foregroundColor()`, `.padding()`, etc.
5. **Check all users**: Card, Panel, Alert, Dialog still work
6. **Update tests** if needed
## Checklist
+2 -2
View File
@@ -42,7 +42,7 @@ The refactoring moves all rendering logic to an internal `_ContainerViewCore` st
4. Verify `ContainerConfig` and `ContainerStyle` helpers still work
### Phase 3: Testing & Verification
1. Run all tests — verify no breakage
1. Run all tests. Uverify no breakage
2. Check users: Card, Panel, Alert, Dialog still render correctly
3. Verify modifiers work: test `.foregroundColor()` on ContainerView
4. Test environment propagation to nested content
@@ -66,7 +66,7 @@ The refactoring moves all rendering logic to an internal `_ContainerViewCore` st
4. Verify `ContainerConfig` and `ContainerStyle` helpers still work
### Phase 3: Testing & Verification
1. Run all tests — verify no breakage
1. Run all tests. Uverify no breakage
2. Check users: Card, Panel, Alert, Dialog still render correctly
3. Verify modifiers work: test `.foregroundColor()` on ContainerView
4. Test environment propagation to nested content
+6 -6
View File
@@ -21,10 +21,10 @@ Extract and formalize reusable components needed by both List and Table includin
- Rendering patterns ad-hoc per control
### Target State
- `FocusableItemListHandler` — shared focus/navigation logic
- `SelectionStateManager<T>` — shared selection tracking
- `renderFocusableContainer()` — helper similar to `renderContainer()`
- `ItemStateRenderer` — utilities for styling items based on focus/selection
- `FocusableItemListHandler`. Ushared focus/navigation logic
- `SelectionStateManager<T>`. Ushared selection tracking
- `renderFocusableContainer()`. Uhelper similar to `renderContainer()`
- `ItemStateRenderer`. Uutilities for styling items based on focus/selection
## Implementation Steps
@@ -44,8 +44,8 @@ Extract and formalize reusable components needed by both List and Table includin
- `binding: Binding<AnyHashable>?`
- `selectedValue: AnyHashable?`
4. Methods:
- `select(index: Int)` — select by index
- `select(value: SelectionValue)` — select by value
- `select(index: Int)`. Uselect by index
- `select(value: SelectionValue)`. Uselect by value
- `isSelected(index: Int) -> Bool`
- `isSelected(value: SelectionValue) -> Bool`
5. Type-erasure utilities for `Binding<SelectionValue>` → `Binding<AnyHashable>`
+6 -6
View File
@@ -2,7 +2,7 @@
## Preface
List now gives TUI apps the power of SwiftUI's List: arbitrary nested views, ForEach with dynamic content, optional selection binding via `.tag()`, and keyboard navigation (Up/Down/Home/End/PageUp/PageDown) with auto-scrolling. Focused item always visible, scroll indicators show bounds, selection updates on Enter. MVP focuses on core scrollable list without sections — they come later once the API is proven.
List now gives TUI apps the power of SwiftUI's List: arbitrary nested views, ForEach with dynamic content, optional selection binding via `.tag()`, and keyboard navigation (Up/Down/Home/End/PageUp/PageDown) with auto-scrolling. Focused item always visible, scroll indicators show bounds, selection updates on Enter. MVP focuses on core scrollable list without sections. Uthey come later once the API is proven.
## Context / Problem
@@ -92,7 +92,7 @@ List {
}
```
**Sections deferred to Phase 2** — complexity of section headers/footers can wait.
**Sections deferred to Phase 2**. Ucomplexity of section headers/footers can wait.
### API
@@ -381,9 +381,9 @@ List(selection: $selected) {
## Files
- `Sources/TUIkit/Views/List.swift` — List component + ListHandler
- `Tests/TUIkitTests/ListTests.swift` — 25+ tests
- `Sources/TUIkitExample/Pages/ListPage.swift` — Example page
- `Sources/TUIkit/Views/List.swift`: List component + ListHandler
- `Tests/TUIkitTests/ListTests.swift`: 25+ tests
- `Sources/TUIkitExample/Pages/ListPage.swift`: Example page
## Dependencies
@@ -423,7 +423,7 @@ Key insight (like RadioButtonGroup):
### Initial MVP
- Render all rows (even off-screen) — simple, correct semantics
- Render all rows (even off-screen). Usimple, correct semantics
- Cache rendered buffers in RenderContext if available
- Measure: 100 rows should render in <50ms
+46 -46
View File
File diff suppressed because one or more lines are too long
+54 -54
View File
@@ -1,8 +1,8 @@
# TUIKit — Tasks
# TUIKit: Tasks
## In Progress
- [ ] **Architecture Alignment** — Refactor core components to follow View pattern
- [ ] **Architecture Alignment**: Refactor core components to follow View pattern
- [ ] ContainerView refactor (body: some View, move Renderable to _ContainerViewCore)
- [ ] List & Table architecture finalization (shared handlers, helpers)
- [ ] Verify modifiers work on all controls
@@ -13,13 +13,13 @@
#### High
- [ ] **TextInput / TextField** — Single-line text input with cursor, backspace, delete, scrolling
- [ ] **TextInput / TextField**: Single-line text input with cursor, backspace, delete, scrolling
#### Medium
- [ ] **List & Table** — Shared architecture (focus handler, selection state, rendering). BLOCKED: Wait for architecture plan finalization
- [ ] **List (scrollable)** — Implement after shared architecture is finalized
- [ ] **Table** — Implement after shared architecture is finalized
- [ ] **List & Table**: Shared architecture (focus handler, selection state, rendering). BLOCKED: Wait for architecture plan finalization
- [ ] **List (scrollable)**: Implement after shared architecture is finalized
- [ ] **Table**: Implement after shared architecture is finalized
#### Low
@@ -28,77 +28,77 @@
### Performance
- [ ] **`View._printChanges()` Equivalent** — Debug mechanism that logs why body was re-evaluated
- [ ] **`View._printChanges()` Equivalent**: Debug mechanism that logs why body was re-evaluated
### Infrastructure
- [ ] **Example App Redesign** — Feature catalog → multiple small example apps
- [ ] **Example App Redesign**: Feature catalog → multiple small example apps
### Testing & Docs
- [ ] **Mobile/Tablet Docs** — Test DocC on mobile devices (landing page done)
- [ ] **Code Examples** — Counter, Todo List, Form, Table/List
- [ ] **Mobile/Tablet Docs**: Test DocC on mobile devices (landing page done)
- [ ] **Code Examples**: Counter, Todo List, Form, Table/List
## Completed
### 2026-02-06
- [x] **Toggle / Checkbox** — Boolean toggle with Space/Enter, slider + checkbox styles, focus indicator, disabled state, 17 tests, example page with menu integration
- [x] **Dashboard Cache + Auto-Refresh** — localStorage cache (5 min TTL), auto-refresh timer, Framer Motion list animations, no skeleton flash (PR #80)
- [x] **License Change** — CC BY-NC-SA 4.0 → MIT, 141 Swift files + LICENSE file
- [x] **Mobile Responsive** — SiteNav hamburger, StatCards vertical, heatmap hidden, CommitList compact, HeroTerminal power-button disabled on phones, footer stacked/centered
- [x] **Toggle / Checkbox**: Boolean toggle with Space/Enter, slider + checkbox styles, focus indicator, disabled state, 17 tests, example page with menu integration
- [x] **Dashboard Cache + Auto-Refresh**: localStorage cache (5 min TTL), auto-refresh timer, Framer Motion list animations, no skeleton flash (PR #80)
- [x] **License Change**: CC BY-NC-SA 4.0 → MIT, 141 Swift files + LICENSE file
- [x] **Mobile Responsive**: SiteNav hamburger, StatCards vertical, heatmap hidden, CommitList compact, HeroTerminal power-button disabled on phones, footer stacked/centered
### 2026-02-05
- [x] **ProgressView** — 5 bar styles, SwiftUI API parity, `darker(by:)`/`lighter(by:)` → relative % (PR #79)
- [x] **Remove Block/Flat Appearances** — Eliminated block, flat, ascii. BorderedView consolidated into ContainerView. Consistent 1-char padding in all containers. DocC overhauled. (PR #78)
- [x] **Notification System** — Fire-and-forget `NotificationService`, fade-in/out animation, word-wrap, top-right overlay, Box rendering. No severity styles, no Binding. (PR #77)
- [x] **ProgressView**: 5 bar styles, SwiftUI API parity, `darker(by:)`/`lighter(by:)` → relative % (PR #79)
- [x] **Remove Block/Flat Appearances**: Eliminated block, flat, ascii. BorderedView consolidated into ContainerView. Consistent 1-char padding in all containers. DocC overhauled. (PR #78)
- [x] **Notification System**: Fire-and-forget `NotificationService`, fade-in/out animation, word-wrap, top-right overlay, Box rendering. No severity styles, no Binding. (PR #77)
- [x] **Render Performance Phase 2** — Cache invalidation fix, Equatable on 15 types/views, debug tooling, example app decomposition + `.equatable()`, DocC documentation (PR #74)
- [x] **Social Lookup Optimization** — GitHub Social API, NodeInfo instance validation, timeouts, false positives eliminated
- [x] **Dashboard: Branches → Open Issues** — StatCard replaced with `SFBubbleLeftAndExclamationmarkBubbleRightFill` icon
- [x] **GH Actions: Social Cache Workflow** — 403 push denied, fixed with PAT + `contents: write` permissions
- [x] **TupleView Equatable** — Conditional Equatable via parameter packs, enables `.equatable()` on VStack/HStack/ZStack compositions (PR #76)
- [x] **Markdown Language Audit** — German remnants translated to English across 4 files (PR #75)
- [x] **Render Performance Phase 2**: Cache invalidation fix, Equatable on 15 types/views, debug tooling, example app decomposition + `.equatable()`, DocC documentation (PR #74)
- [x] **Social Lookup Optimization**: GitHub Social API, NodeInfo instance validation, timeouts, false positives eliminated
- [x] **Dashboard: Branches → Open Issues**: StatCard replaced with `SFBubbleLeftAndExclamationmarkBubbleRightFill` icon
- [x] **GH Actions: Social Cache Workflow**: 403 push denied, fixed with PAT + `contents: write` permissions
- [x] **TupleView Equatable**: Conditional Equatable via parameter packs, enables `.equatable()` on VStack/HStack/ZStack compositions (PR #76)
- [x] **Markdown Language Audit**: German remnants translated to English across 4 files (PR #75)
### 2026-02-03
- [x] **Subtree Memoization** — EquatableView + RenderCache, opt-in via `.equatable()`, cache cleared on @State change
- [x] **Palette Consolidation** — 6 Palette-Structs → `SystemPalette.Preset` enum + `SystemPalette` (PR #70)
- [x] **Extension Separation** — 35 source files: functions moved to access-level extensions (PR #69)
- [x] **AppHeader** — Framework-managed Header Bar, `.appHeader {}` Modifier
- [x] **Focus Sections** — `.focusSection()`, section-aware FocusManager, StatusBar Cascading
- [x] **Dimmed Overlay Rewrite** — Palette-based dimming, ornament stripping, ANSI-aware splitting
- [x] **Unified File Headers** — 136 Swift files standardized
- [x] **OverlaysPage Redesign** — 8 overlay variants, modal focus isolation (PR #67)
- [x] **Subtree Memoization**: EquatableView + RenderCache, opt-in via `.equatable()`, cache cleared on @State change
- [x] **Palette Consolidation**: 6 Palette-Structs → `SystemPalette.Preset` enum + `SystemPalette` (PR #70)
- [x] **Extension Separation**: 35 source files: functions moved to access-level extensions (PR #69)
- [x] **AppHeader**: Framework-managed Header Bar, `.appHeader {}` Modifier
- [x] **Focus Sections**: `.focusSection()`, section-aware FocusManager, StatusBar Cascading
- [x] **Dimmed Overlay Rewrite**: Palette-based dimming, ornament stripping, ANSI-aware splitting
- [x] **Unified File Headers**: 136 Swift files standardized
- [x] **OverlaysPage Redesign**: 8 overlay variants, modal focus isolation (PR #67)
### 2026-02-02
- [x] **Render-Pipeline Phase 1–4** — Line-Diffing, Output Buffering, Caching, Architecture Cleanup (PR #62, #63)
- [x] **Spinner View** — dots/line/bouncing Styles, auto-animating (PR #61)
- [x] **Structural Identity for @State** — ViewIdentity, StateStorage, self-hydrating @State (PR #60)
- [x] **Landing Page Optimization** — CRT boot/shutdown, terminal Markdown parser, smart typing, SEO
- [x] **Test Quality Audits** — 134 worthless tests removed, 33 weak assertions tightened
- [x] **CI Automation** — Test-Badge, Git Author Cleanup
- [x] **Render-Pipeline Phase 1–4**: Line-Diffing, Output Buffering, Caching, Architecture Cleanup (PR #62, #63)
- [x] **Spinner View**: dots/line/bouncing Styles, auto-animating (PR #61)
- [x] **Structural Identity for @State**: ViewIdentity, StateStorage, self-hydrating @State (PR #60)
- [x] **Landing Page Optimization**: CRT boot/shutdown, terminal Markdown parser, smart typing, SEO
- [x] **Test Quality Audits**: 134 worthless tests removed, 33 weak assertions tightened
- [x] **CI Automation**: Test-Badge, Git Author Cleanup
### 2026-01-31
- [x] **Source Restructure** — Directory-Reorg, Phosphor→Palette Rename (PR #30)
- [x] **EnvironmentStorage Elimination** — Singleton removed, SemanticColor system (PR #31)
- [x] **Palette Protocol Split** — `Palette` + `BlockPalette` (later removed), ANSI→RGB (PR #48)
- [x] **Access-Level Refactor** — Public API surface restricted (PR #37)
- [x] **DocC Documentation** — 8 guide articles, diagrams, palette/keyboard reference
- [x] **Source Restructure**: Directory-Reorg, Phosphor→Palette Rename (PR #30)
- [x] **EnvironmentStorage Elimination**: Singleton removed, SemanticColor system (PR #31)
- [x] **Palette Protocol Split**: `Palette` + `BlockPalette` (later removed), ANSI→RGB (PR #48)
- [x] **Access-Level Refactor**: Public API surface restricted (PR #37)
- [x] **DocC Documentation**: 8 guide articles, diagrams, palette/keyboard reference
### 2026-01-30
- [x] **Code Quality PR #5–#18** — Dead Code, Singletons, SwiftLint, Linux Compat, AppRunner Decomposition
- [x] **Testing PR #20–#23** — 4 phases, 569 tests initial
- [x] **DocC + GitHub Pages** — swift-docc-plugin, CI Deploy, Custom Domain
- [x] **Landing Page** — Next.js, CRT Terminal, Blade Runner Atmosphere (PR #40–#54)
- [x] **Code Quality PR #5–#18**: Dead Code, Singletons, SwiftLint, Linux Compat, AppRunner Decomposition
- [x] **Testing PR #20–#23**: 4 phases, 569 tests initial
- [x] **DocC + GitHub Pages**: swift-docc-plugin, CI Deploy, Custom Domain
- [x] **Landing Page**: Next.js, CRT Terminal, Blade Runner Atmosphere (PR #40–#54)
### 2026-01-29
- [x] **Git Cleanup** — `.claude/` removed from history, branches deleted
- [x] **Git Cleanup**: `.claude/` removed from history, branches deleted
## Notes
@@ -109,34 +109,34 @@
## Feature Ideas (Backlog)
### Render Performance — Architectural Guidelines from SwiftUI Patterns - 2026-02-05 12:21
### Render Performance: Architectural Guidelines from SwiftUI Patterns - 2026-02-05 12:21
Permanent architectural concern. Synthesized from the [SwiftUI performance article](https://www.swiftdifferently.com/blog/swiftui/swiftui-performance-article) by Omar Elsayed, the [SwiftUI Agent Skill](https://github.com/AvdLee/SwiftUI-Agent-Skill) by Antoine van der Lee (references: `performance-patterns.md`, `view-structure.md`, `list-patterns.md`, `layout-best-practices.md`), and evaluated for applicability to a TUI framework.
**Problem:** State changes trigger body re-evaluation. The cost scales with the number of primitive views in the body. SwiftUI diffs old vs. new body output to find what changed — the more primitives, the more work. Identical patterns apply to TUIKit's render pipeline.
**Problem:** State changes trigger body re-evaluation. The cost scales with the number of primitive views in the body. SwiftUI diffs old vs. new body output to find what changed: the more primitives, the more work. Identical patterns apply to TUIKit's render pipeline.
**Core principles and TUIKit applicability:**
1. **View struct decomposition = diffing boundaries**
Separate structs let the framework skip body re-evaluation when inputs haven't changed. `@ViewBuilder` functions and computed properties do NOT create boundaries — they inline at runtime. *Directly applicable.* Already partially addressed via `EquatableView` + `RenderCache`, but structural decomposition should be a documented best practice.
Separate structs let the framework skip body re-evaluation when inputs haven't changed. `@ViewBuilder` functions and computed properties do NOT create boundaries: they inline at runtime. *Directly applicable.* Already partially addressed via `EquatableView` + `RenderCache`, but structural decomposition should be a documented best practice.
2. **POD (Plain Old Data) views for fast diffing**
Views containing only simple value types (no property wrappers) use `memcmp` for fastest comparison. Wrap expensive non-POD views in POD parent structs. *Applicable:* TUIKit views with only `let` properties and no `@State` can benefit from this pattern.
3. **Pass only needed values, not entire models**
Passing large context/config objects creates broad dependencies — any property change triggers updates in all observing views. Pass specific values instead. *Directly applicable:* TUIKit views should receive only the data they render, not entire app state objects.
Passing large context/config objects creates broad dependencies: any property change triggers updates in all observing views. Pass specific values instead. *Directly applicable:* TUIKit views should receive only the data they render, not entire app state objects.
4. **Avoid redundant state updates in hot paths**
Check for value changes before assigning state. Gate frequent updates (scroll, resize) by thresholds. *Applicable to TUIKit:* terminal resize events, keyboard repeat, timer ticks — guard with `if newValue != oldValue`.
Check for value changes before assigning state. Gate frequent updates (scroll, resize) by thresholds. *Applicable to TUIKit:* terminal resize events, keyboard repeat, timer ticks: guard with `if newValue != oldValue`.
5. **No object creation or heavy computation in body**
Formatters, sorting, filtering — all belong outside body. Body should be a pure structural declaration. *Directly applicable.* DateFormatters → static properties; sorted arrays → computed on state change, not in body.
Formatters, sorting, filtering: all belong outside body. Body should be a pure structural declaration. *Directly applicable.* DateFormatters → static properties; sorted arrays → computed on state change, not in body.
6. **Stable identity for ForEach / list items**
Never use `.indices` for dynamic content. Ensure constant view count per element. Prefilter arrays instead of inline filtering. Avoid `AnyView` in list rows. *Applicable when TUIKit gets List/Table components:* stable identity prevents excessive diffing and potential crashes.
7. **Prefer modifiers over conditional views for state changes**
`opacity(0)` vs. `if isVisible { ... }` — conditionals destroy view identity and state. Use modifiers to represent different states of the *same* view. *Partially applicable:* TUIKit's modifier chain (`.hidden()`, `.disabled()`) should be preferred over conditional inclusion where possible.
`opacity(0)` vs. `if isVisible { ... }`: conditionals destroy view identity and state. Use modifiers to represent different states of the *same* view. *Partially applicable:* TUIKit's modifier chain (`.hidden()`, `.disabled()`) should be preferred over conditional inclusion where possible.
8. **Container views: `@ViewBuilder let content` over closures**
Closures can't be compared → always cause re-renders. `@ViewBuilder let content: Content` allows the framework to diff the content. *Directly applicable to TUIKit container views.*