--- name: widget-creator description: Step-by-step guide for creating new widgets in the Hex1b TUI library. Use when implementing new widgets from scratch, including widget records, nodes, extension methods, theming, reconciliation, and tests. --- # Widget Creator Skill This skill provides a comprehensive step-by-step guide for AI coding agents to create new widgets in the Hex1b TUI library. Widgets are the building blocks of Hex1b applications, following a declarative pattern inspired by React. ## Overview Creating a widget in Hex1b involves several coordinated files: | File | Purpose | |------|---------| | `src/Hex1b/Widgets/{Name}Widget.cs` | Immutable widget record (describes what to render) | | `src/Hex1b/Nodes/{Name}Node.cs` | Mutable node class (manages state, renders) | | `src/Hex1b/{Name}Extensions.cs` | Fluent API extension methods | | `src/Hex1b/Theming/{Name}Theme.cs` | Theme elements (colors, characters) | | `tests/Hex1b.Tests/{Name}NodeTests.cs` | Unit tests | ## Step-by-Step Process ### Step 1: Define the Widget Record Widgets are **immutable** `record` types that describe the desired UI. They capture configuration and event handlers but contain no rendering logic. **Location**: `src/Hex1b/Widgets/{Name}Widget.cs` **Key principles**: - Use primary constructor parameters for required data - Use `internal` properties with `init` for optional configuration - Event handlers use `Func?` pattern - Provide sync and async overloads for event handlers using `this with { }` pattern - Implement `Reconcile()` to create/update the corresponding node - Implement `GetExpectedNodeType()` to return the node type **Template**: ```csharp using Hex1b.Events; using Hex1b.Nodes; namespace Hex1b.Widgets; /// /// Brief description of what the widget does. /// /// Description of the main property. public sealed record MyWidget(string PrimaryProperty) : Hex1bWidget { /// /// ActionId for the activate action. Use "WidgetName.ActionName" naming /// convention (PascalCase, omit "Widget" suffix from the widget name). /// Define one static readonly ActionId per rebindable action. /// public static readonly ActionId Activate = new($"{nameof(MyWidget)}.{nameof(Activate)}"); /// /// Optional configuration property. /// internal bool SomeOption { get; init; } /// /// Event handler for some action. /// internal Func? ActionHandler { get; init; } /// /// Sets a synchronous action handler. /// public MyWidget OnAction(Action handler) => this with { ActionHandler = args => { handler(args); return Task.CompletedTask; } }; /// /// Sets an asynchronous action handler. /// public MyWidget OnAction(Func handler) => this with { ActionHandler = handler }; internal override Hex1bNode Reconcile(Hex1bNode? existingNode, ReconcileContext context) { var node = existingNode as MyNode ?? new MyNode(); // Mark dirty if properties changed if (node.PrimaryProperty != PrimaryProperty || node.SomeOption != SomeOption) { node.MarkDirty(); } node.PrimaryProperty = PrimaryProperty; node.SomeOption = SomeOption; node.SourceWidget = this; // Convert typed event handler to internal handler if needed if (ActionHandler != null) { node.ActionCallback = async ctx => { var args = new MyEventArgs(this, node, ctx); await ActionHandler(args); }; } else { node.ActionCallback = null; } return node; } internal override Type GetExpectedNodeType() => typeof(MyNode); } ``` ### Step 2: Create Event Args (if needed) If your widget has event handlers, create a typed event args class. **Location**: `src/Hex1b/Events/{Name}EventArgs.cs` **Template**: ```csharp using Hex1b.Input; using Hex1b.Widgets; namespace Hex1b.Events; /// /// Event arguments for MyWidget actions. /// public sealed class MyEventArgs { /// /// The widget that raised the event. /// public MyWidget Widget { get; } /// /// The node that raised the event. /// public MyNode Node { get; } /// /// The input binding context. /// public InputBindingActionContext Context { get; } internal MyEventArgs(MyWidget widget, MyNode node, InputBindingActionContext context) { Widget = widget; Node = node; Context = context; } } ``` ### Step 3: Create the Node Class Nodes are **mutable** classes that hold render state and perform actual rendering. They receive updates during reconciliation and implement layout and rendering. **Location**: `src/Hex1b/Nodes/{Name}Node.cs` **Key principles**: - Properties are mutable (set from widget during reconciliation) - Track state that must survive re-renders (focus, cursor position, etc.) - Call `MarkDirty()` when internal state changes - Implement `Measure()` to calculate size - Implement `Render()` to draw to terminal - Override `IsFocusable` if the widget can receive focus - Override `ConfigureDefaultBindings()` for keyboard/mouse handling > **Lift state up when mutable state would benefit composites.** If your widget owns non-trivial mutable state that a parent might want to read, write, or coordinate with — buffer text, selection index, navigation history, checked value — promote that state to a public class and have your widget implement `IStatefulWidget` so callers can pass an external instance via `.State(...)`. See `TextBoxWidget` / `TextBoxState`, `EditorWidget` / `EditorState`, `NavigatorWidget` / `NavigatorState`, `CheckboxWidget` / `CheckboxState` for the canonical pattern. The composition guide has a worked example. Pure visual state (focus, hover, animation phase) stays internal to the node — only mutable *user-facing data* needs lifting. **Template**: ```csharp using Hex1b.Input; using Hex1b.Layout; using Hex1b.Theming; using Hex1b.Widgets; namespace Hex1b; /// /// Render node for MyWidget. /// public sealed class MyNode : Hex1bNode { public string PrimaryProperty { get; set; } = ""; public bool SomeOption { get; set; } /// /// The source widget for typed event args. /// public MyWidget? SourceWidget { get; set; } /// /// Callback for the action event. /// public Func? ActionCallback { get; set; } // Focus tracking (if widget is focusable) private bool _isFocused; public override bool IsFocused { get => _isFocused; set { if (_isFocused != value) { _isFocused = value; MarkDirty(); } } } public override bool IsFocusable => true; // Set to false for non-interactive widgets public override void ConfigureDefaultBindings(InputBindingsBuilder bindings) { if (ActionCallback != null) { // Use .Triggers() with an ActionId to make bindings rebindable by users. // Naming convention: "WidgetName.ActionName" (PascalCase, omit "Widget" suffix). // Define the ActionId as a static readonly field on the widget record: // public static readonly ActionId Activate = new($"{nameof(MyWidget)}.{nameof(Activate)}"); bindings.Key(Hex1bKey.Enter).Triggers(MyWidget.Activate, ActionCallback, "Activate"); } } public override Size Measure(Constraints constraints) { // Calculate the desired size var width = PrimaryProperty.Length; var height = 1; return constraints.Constrain(new Size(width, height)); } public override void Render(Hex1bRenderContext context) { var theme = context.Theme; // Get theme values var fg = theme.Get(MyTheme.ForegroundColor); var bg = theme.Get(MyTheme.BackgroundColor); // Build output string with colors var output = $"{fg.ToForegroundAnsi()}{bg.ToBackgroundAnsi()}{PrimaryProperty}{theme.GetResetToGlobalCodes()}"; // Use clipped rendering when a layout provider is active if (context.CurrentLayoutProvider != null) { context.WriteClipped(Bounds.X, Bounds.Y, output); } else { context.Write(output); } } } ``` ### Step 4: Create Theme Elements Theme elements allow users to customize the widget's appearance. **Location**: `src/Hex1b/Theming/{Name}Theme.cs` **Template**: ```csharp namespace Hex1b.Theming; /// /// Theme elements for MyWidget. /// public static class MyTheme { public static readonly Hex1bThemeElement ForegroundColor = new($"{nameof(MyTheme)}.{nameof(ForegroundColor)}", () => Hex1bColor.Default); public static readonly Hex1bThemeElement BackgroundColor = new($"{nameof(MyTheme)}.{nameof(BackgroundColor)}", () => Hex1bColor.Default); // For widgets with multiple states (focused, hovered, etc.) public static readonly Hex1bThemeElement FocusedForegroundColor = new($"{nameof(MyTheme)}.{nameof(FocusedForegroundColor)}", () => Hex1bColor.Black); public static readonly Hex1bThemeElement FocusedBackgroundColor = new($"{nameof(MyTheme)}.{nameof(FocusedBackgroundColor)}", () => Hex1bColor.White); // For character customization public static readonly Hex1bThemeElement SomeCharacter = new($"{nameof(MyTheme)}.{nameof(SomeCharacter)}", () => '█'); } ``` ### Step 5: Create Extension Methods Extension methods provide the fluent API for creating widgets. **Location**: `src/Hex1b/{Name}Extensions.cs` **Key principles**: - Extend `WidgetContext` for widgets that can be children - Provide overloads for common patterns - Use XML documentation for IntelliSense **Template**: ```csharp namespace Hex1b; using Hex1b.Widgets; /// /// Extension methods for creating MyWidget. /// public static class MyExtensions { /// /// Creates a MyWidget with the specified property. /// public static MyWidget My( this WidgetContext ctx, string primaryProperty) where TParent : Hex1bWidget => new(primaryProperty); /// /// Creates a MyWidget with options. /// public static MyWidget My( this WidgetContext ctx, string primaryProperty, bool someOption) where TParent : Hex1bWidget => new(primaryProperty) { SomeOption = someOption }; } ``` ### Step 6: Write Unit Tests Tests verify that the node behaves correctly. Test files use MSTest 4 (`MSTest.Sdk/4.2.3` with `OutputType=Exe`) and import `Microsoft.VisualStudio.TestTools.UnitTesting`; `Hex1b.Testing` is global-using'd for `TestSeq` helpers. **Location**: `tests/Hex1b.Tests/{Name}NodeTests.cs` **Key test scenarios**: - Measure returns correct size - Render outputs expected content - Input handling works correctly - Property changes mark node dirty - Focus state changes work **Template**: ```csharp using Hex1b; using Hex1b.Input; using Hex1b.Layout; using Hex1b.Theming; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace Hex1b.Tests; [TestClass] public class MyNodeTests { [TestMethod] public void Measure_ReturnsCorrectSize() { // Arrange var node = new MyNode { PrimaryProperty = "Hello" }; var constraints = new Constraints(0, 100, 0, 10); // Act var size = node.Measure(constraints); // Assert Assert.AreEqual(5, size.Width); // "Hello".Length Assert.AreEqual(1, size.Height); } [TestMethod] public void PropertyChange_MarksDirty() { // Arrange var node = new MyNode { PrimaryProperty = "Initial" }; node.ClearDirty(); // Simulate post-render state // Act node.PrimaryProperty = "Changed"; // Assert - if using property setter that marks dirty // This depends on whether your node implements dirty tracking in setters } [TestMethod] public void IsFocused_WhenSet_MarksDirty() { // Arrange var node = new MyNode(); node.ClearDirty(); // Act node.IsFocused = true; // Assert Assert.IsTrue(node.IsDirty); } } ``` ### Step 7: Build and Test After creating all files: ```bash # Build the library dotnet build src/Hex1b # Run all tests (MTP via global.json) dotnet test # Or run a test project as an executable dotnet run --project tests/Hex1b.Tests/ # Or run specific tests dotnet test --filter "MyNodeTests" ``` ## Common Patterns ### Fill Width (Horizontal Fill) For widgets that should fill available horizontal space by default: ```csharp public override Size Measure(Constraints constraints) { // Use all available width var width = constraints.MaxWidth; var height = 1; return constraints.Constrain(new Size(width, height)); } ``` ### Animation / Indeterminate State For widgets with animation (like indeterminate progress), you need to: 1. Track animation state in the node 2. Call `MarkDirty()` when animation changes 3. Use `Hex1bApp.Invalidate()` from the widget builder to trigger re-renders ### Container Widgets For widgets that contain children, see `VStackWidget`/`VStackNode` as examples: - Store children in `Hex1bWidget[]` (widget) / `List` (node) - Use `ReconcileContext.ReconcileChildren()` in widget's `Reconcile()` - Implement `GetChildren()` in node for focus traversal ### Theming Best Practices 1. **Use `Hex1bColor.Default`** for colors that should inherit from parent 2. **Provide focused/hovered variants** for interactive widgets 3. **Use characters for customizable borders/bullets** so users can theme them ## Testing Best Practices Testing widgets requires **two layers**: unit tests for isolated node behavior, and integration tests for real-world scenarios using `Hex1bApp`. Integration tests should export evidence in multiple formats for verification and documentation. ### Test File Organization | File | Purpose | |------|---------| | `tests/Hex1b.Tests/{Name}NodeTests.cs` | Unit tests for node behavior | | `tests/Hex1b.Tests/{Name}IntegrationTests.cs` | Integration tests with Hex1bApp | ### Unit Tests (NodeTests) Unit tests verify isolated node behavior without running a full app. **Key scenarios to cover**: 1. **Measure behavior** - Size calculations for various constraints 2. **Render output** - Correct ANSI sequences and text 3. **Property dirty tracking** - Changes trigger re-render 4. **Input handling** - Key/mouse events processed correctly 5. **Focus state** - Focus changes mark dirty and render differently 6. **Reconciliation** - Widget updates preserve/update node state **Example pattern**: ```csharp [TestMethod] public void Measure_FillsAvailableWidth() { var node = new ProgressNode { Value = 50, Maximum = 100 }; var size = node.Measure(new Constraints(0, 80, 0, 10)); Assert.AreEqual(80, size.Width); Assert.AreEqual(1, size.Height); } [TestMethod] public void PropertyChange_MarksDirty() { var node = new ProgressNode { Value = 50 }; node.ClearDirty(); // Simulate reconciliation with changed value var widget = new ProgressWidget { Value = 75 }; widget.Reconcile(node, new ReconcileContext(...)); Assert.IsTrue(node.IsDirty); } ``` ### Integration Tests (IntegrationTests) Integration tests spin up a real `Hex1bApp` and test the widget in various layout scenarios. **These tests must export evidence files** for verification. **Required export formats**: - **SVG** - Vector graphics for documentation - **HTML** - Styled HTML output - **ANSI** - Raw terminal sequences - **Asciinema (.cast)** - Animated recordings for dynamic behavior #### Test Infrastructure Setup ```csharp using Hex1b; using Hex1b.Input; using Hex1b.Theming; using Microsoft.VisualStudio.TestTools.UnitTesting; [TestClass] public class MyWidgetIntegrationTests : IDisposable { private readonly List _tempFiles = new(); private string GetTempFile() { var path = Path.Combine(Path.GetTempPath(), $"hex1b_test_{Guid.NewGuid()}.cast"); _tempFiles.Add(path); return path; } public void Dispose() { foreach (var file in _tempFiles) { try { File.Delete(file); } catch { } } } } ``` #### Basic Integration Test Pattern ```csharp [TestMethod] public async Task MyWidget_RendersCorrectly() { using var workload = new Hex1bAppWorkloadAdapter(); using var terminal = new Hex1bTerminal(workload, 60, 10); using var app = new Hex1bApp( ctx => ctx.VStack(v => [ v.Text("Label:"), v.My("Hello World") ]), new Hex1bAppOptions { WorkloadAdapter = workload } ); var runTask = app.RunAsync(TestContext.Current.CancellationToken); await new Hex1bTerminalInputSequenceBuilder() .WaitUntil(s => s.ContainsText("Label:"), TimeSpan.FromSeconds(2)) .Capture("mywidget-basic") // Exports SVG, HTML, ANSI .Ctrl().Key(Hex1bKey.C) .Build() .ApplyWithCaptureAsync(terminal, TestContext.Current.CancellationToken); await runTask; } ``` #### Layout Scenario Tests Test the widget in all common layout containers: ```csharp [TestMethod] public async Task MyWidget_InBorder() { using var workload = new Hex1bAppWorkloadAdapter(); using var terminal = new Hex1bTerminal(workload, 60, 10); using var app = new Hex1bApp( ctx => ctx.Border(b => [ b.Text("Content"), b.My("Inside border") ], title: "Panel"), new Hex1bAppOptions { WorkloadAdapter = workload } ); var runTask = app.RunAsync(TestContext.Current.CancellationToken); await new Hex1bTerminalInputSequenceBuilder() .WaitUntil(s => s.ContainsText("Panel"), TimeSpan.FromSeconds(2)) .Capture("mywidget-in-border") .Ctrl().Key(Hex1bKey.C) .Build() .ApplyWithCaptureAsync(terminal, TestContext.Current.CancellationToken); await runTask; } [TestMethod] public async Task MyWidget_InHStackWithFill() { using var workload = new Hex1bAppWorkloadAdapter(); using var terminal = new Hex1bTerminal(workload, 80, 10); using var app = new Hex1bApp( ctx => ctx.HStack(h => [ h.Text("Label: "), h.My("Content").Fill() ]), new Hex1bAppOptions { WorkloadAdapter = workload } ); var runTask = app.RunAsync(TestContext.Current.CancellationToken); await new Hex1bTerminalInputSequenceBuilder() .WaitUntil(s => s.ContainsText("Label:"), TimeSpan.FromSeconds(2)) .Capture("mywidget-hstack-fill") .Ctrl().Key(Hex1bKey.C) .Build() .ApplyWithCaptureAsync(terminal, TestContext.Current.CancellationToken); await runTask; } [TestMethod] [DataRow(40)] [DataRow(60)] [DataRow(80)] [DataRow(120)] public async Task MyWidget_RespondsToTerminalWidth(int width) { using var workload = new Hex1bAppWorkloadAdapter(); using var terminal = new Hex1bTerminal(workload, width, 10); using var app = new Hex1bApp( ctx => ctx.My("Responsive content"), new Hex1bAppOptions { WorkloadAdapter = workload } ); var runTask = app.RunAsync(TestContext.Current.CancellationToken); await new Hex1bTerminalInputSequenceBuilder() .WaitUntil(s => s.ContainsText("Responsive"), TimeSpan.FromSeconds(2)) .Capture($"mywidget-width-{width}") .Ctrl().Key(Hex1bKey.C) .Build() .ApplyWithCaptureAsync(terminal, TestContext.Current.CancellationToken); await runTask; } ``` #### Asciinema Recording Tests For widgets with animation or dynamic behavior, record asciinema sessions: ```csharp [TestMethod] public async Task MyWidget_RecordsAnimation() { var tempFile = GetTempFile(); using var workload = new Hex1bAppWorkloadAdapter(); var terminalOptions = new Hex1bTerminalOptions { Width = 60, Height = 10, WorkloadAdapter = workload }; var recorder = terminalOptions.AddAsciinemaRecorder(tempFile, new AsciinemaRecorderOptions { Title = "MyWidget Animation Demo", IdleTimeLimit = 0.5f }); using var terminal = new Hex1bTerminal(terminalOptions); var animationValue = 0.0; using var app = new Hex1bApp( ctx => ctx.My($"Value: {animationValue:F1}"), new Hex1bAppOptions { WorkloadAdapter = workload } ); using var cts = new CancellationTokenSource(); var runTask = app.RunAsync(cts.Token); recorder.AddMarker("Animation Start"); // Animate for ~2 seconds for (int i = 0; i < 40; i++) { animationValue = (i % 20) / 20.0; app.Invalidate(); await Task.Delay(50, TestContext.Current.CancellationToken); } recorder.AddMarker("Animation End"); var snapshot = terminal.CreateSnapshot(); TestCaptureHelper.Capture(snapshot, "mywidget-animated"); await TestCaptureHelper.CaptureCastAsync(recorder, "mywidget-animation", TestContext.Current.CancellationToken); cts.Cancel(); await runTask; } ``` #### Resize Scenario Tests Test how widgets respond to terminal resizing: ```csharp [TestMethod] public async Task MyWidget_RecordsResizeScenario() { var tempFile = GetTempFile(); using var workload = new Hex1bAppWorkloadAdapter(); var terminalOptions = new Hex1bTerminalOptions { Width = 100, Height = 10, WorkloadAdapter = workload }; var recorder = terminalOptions.AddAsciinemaRecorder(tempFile, new AsciinemaRecorderOptions { Title = "MyWidget Resize Behavior", IdleTimeLimit = 1.0f }); using var terminal = new Hex1bTerminal(terminalOptions); using var app = new Hex1bApp( ctx => ctx.My("Resize me"), new Hex1bAppOptions { WorkloadAdapter = workload } ); using var cts = new CancellationTokenSource(); var runTask = app.RunAsync(cts.Token); recorder.AddMarker("Initial Size (100 cols)"); await new Hex1bTerminalInputSequenceBuilder() .WaitUntil(s => s.ContainsText("Resize"), TimeSpan.FromSeconds(2)) .Wait(TimeSpan.FromMilliseconds(500)) .Build() .ApplyAsync(terminal, TestContext.Current.CancellationToken); // Resize to medium recorder.AddMarker("Resize to 60 cols"); await ((IHex1bTerminalWorkloadFilter)recorder).OnResizeAsync(60, 10, TimeSpan.FromSeconds(1)); terminal.Resize(60, 10); await workload.ResizeAsync(60, 10, TestContext.Current.CancellationToken); await Task.Delay(300, TestContext.Current.CancellationToken); TestCaptureHelper.Capture(terminal, "mywidget-resize-medium"); // Resize to narrow recorder.AddMarker("Resize to 40 cols"); await ((IHex1bTerminalWorkloadFilter)recorder).OnResizeAsync(40, 10, TimeSpan.FromSeconds(2)); terminal.Resize(40, 10); await workload.ResizeAsync(40, 10, TestContext.Current.CancellationToken); await Task.Delay(300, TestContext.Current.CancellationToken); TestCaptureHelper.Capture(terminal, "mywidget-resize-narrow"); await TestCaptureHelper.CaptureCastAsync(recorder, "mywidget-resize-demo", TestContext.Current.CancellationToken); cts.Cancel(); await runTask; } ``` #### Theming Tests Verify custom themes are applied correctly: ```csharp [TestMethod] public async Task MyWidget_RespectsCustomTheme() { using var workload = new Hex1bAppWorkloadAdapter(); using var terminal = new Hex1bTerminal(workload, 60, 10); var customTheme = new Hex1bTheme("CustomTest") .Set(MyTheme.ForegroundColor, Hex1bColor.Blue) .Set(MyTheme.BackgroundColor, Hex1bColor.Yellow); using var app = new Hex1bApp( ctx => ctx.My("Themed content"), new Hex1bAppOptions { WorkloadAdapter = workload, Theme = customTheme } ); var runTask = app.RunAsync(TestContext.Current.CancellationToken); await new Hex1bTerminalInputSequenceBuilder() .WaitUntil(s => s.ContainsText("Themed"), TimeSpan.FromSeconds(2)) .Capture("mywidget-custom-theme") .Ctrl().Key(Hex1bKey.C) .Build() .ApplyWithCaptureAsync(terminal, TestContext.Current.CancellationToken); await runTask; } ``` ### Integration Test Checklist Every widget should have integration tests covering: - [ ] **Basic rendering** - Widget appears correctly in simple VStack - [ ] **In Border** - Widget inside a Border container - [ ] **In HStack** - Widget with label in HStack - [ ] **Multiple instances** - Several widgets in a VStack - [ ] **Fixed width** - Widget with `.FixedWidth()` constraint - [ ] **Fill behavior** - Widget with `.Fill()` in cross-axis container - [ ] **Various terminal widths** - MSTest `DataRow` test with 40, 60, 80, 120 columns - [ ] **Custom theme** - Widget with custom theme elements - [ ] **Dynamic updates** - Widget responding to `app.Invalidate()` (if applicable) - [ ] **Animation recording** - Asciinema recording for animated states (if applicable) - [ ] **Resize behavior** - Recording of terminal resize handling (if fills space) ### Export Evidence Requirements All integration tests must generate evidence files via `TestCaptureHelper`: | Method | Output | Purpose | |--------|--------|---------| | `.Capture("name")` | SVG, HTML, ANSI | Static snapshot evidence | | `TestCaptureHelper.Capture(terminal, "name")` | SVG, HTML, ANSI | Manual capture | | `TestCaptureHelper.CaptureCastAsync(recorder, "name", ct)` | .cast file | Asciinema recording | These files are attached to test results and can be viewed in CI artifacts for debugging and documentation purposes. ## Visual Regression Testing For complex widgets like TableWidget, consider adding visual regression tests that capture baselines and compare rendered output. ### Baseline Test Infrastructure Visual regression tests use the full `Hex1bTerminal` stack to render widgets and capture output for comparison: ```csharp public static async Task<(string Ansi, string Text)> RenderTableAsync( TableVisualTestCase testCase, CancellationToken cancellationToken = default) { using var terminal = Hex1bTerminal.CreateBuilder() .WithHeadless() .WithDimensions(testCase.Width, testCase.Height) .WithHex1bApp((app, options) => ctx => BuildWidget(ctx, testCase)) .Build(); var runTask = terminal.RunAsync(cancellationToken); // Wait for render, capture snapshot, then exit await new Hex1bTerminalInputSequenceBuilder() .WaitUntil(s => s.ContainsText("Expected text"), TimeSpan.FromSeconds(2)) .Wait(TimeSpan.FromMilliseconds(50)) .Build() .ApplyAsync(terminal, cancellationToken); using var snapshot = terminal.CreateSnapshot(); var text = snapshot.GetScreenText(); // Exit and cleanup await new Hex1bTerminalInputSequenceBuilder() .Ctrl().Key(Hex1bKey.C) .Build() .ApplyAsync(terminal, cancellationToken); await runTask; return (ansi, text); } ``` ### Baseline Storage Baselines are stored in `tests/Hex1b.Tests/Baselines/{Widget}/` with: - `.ansi` files containing ANSI escape sequences (for color verification) - `.txt` files containing plain text (for structure verification) ### Updating Baselines When widget rendering changes intentionally: ```bash UPDATE_BASELINES=1 dotnet test --filter "{Widget}VisualRegressionTests" ``` ### Test Matrix Example For TableWidget, the visual regression test matrix covers: - **Data sizes**: 0, 1, 5, 50, 1000 rows - **Render modes**: Compact, Full - **Terminal sizes**: 80×24, 160×48 - **Selection states**: None, some, all selected - **Focus states**: Row focus, table focus indicator See `tests/Hex1b.Tests/TableVisualRegressionTests.cs` for the complete implementation. ## Checklist Before considering a widget complete: - [ ] Widget record with all configuration properties - [ ] Event args class (if widget has events) - [ ] Node class with Measure, Render, and input handling - [ ] Theme class with customizable elements - [ ] Extension methods for fluent API - [ ] **Unit tests** for core node functionality - [ ] **Integration tests** with exhaustive layout scenarios - [ ] **Export evidence** (SVG, HTML, ANSI, Asciinema) from integration tests - [ ] `dotnet build` succeeds - [ ] `dotnet test` passes ## Example: ProgressWidget The ProgressWidget was created following this skill. It demonstrates: - **Determinate mode**: Shows progress from min to max value - **Indeterminate mode**: Animated spinner for unknown completion - **Fill width**: Uses all available horizontal space by default - **Theming**: Customizable fill and track characters - **Comprehensive tests**: Unit tests and integration tests with full export evidence See the implementation files: - [src/Hex1b/Widgets/ProgressWidget.cs](../../../../src/Hex1b/Widgets/ProgressWidget.cs) - [src/Hex1b/Nodes/ProgressNode.cs](../../../../src/Hex1b/Nodes/ProgressNode.cs) - [src/Hex1b/ProgressExtensions.cs](../../../../src/Hex1b/ProgressExtensions.cs) - [src/Hex1b/Theming/ProgressTheme.cs](../../../../src/Hex1b/Theming/ProgressTheme.cs) - [tests/Hex1b.Tests/ProgressNodeTests.cs](../../../../tests/Hex1b.Tests/ProgressNodeTests.cs) (unit tests) - [tests/Hex1b.Tests/ProgressIntegrationTests.cs](../../../../tests/Hex1b.Tests/ProgressIntegrationTests.cs) (integration tests)