بنقرة واحدة
widget-creator
// 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.
// 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.
Guidelines for reviewing API design in the Hex1b codebase. Use when evaluating public APIs, reviewing accessibility modifiers, or assessing whether new APIs follow project conventions.
Agent for diagnosing and fixing flaky terminal UI tests in the Hex1b test suite. Use when tests pass locally but fail in CI, or when tests exhibit timing-sensitive behavior.
Guidelines for producing accurate and maintainable documentation for the Hex1b TUI library. Use when writing XML API documentation comments, creating end-user guides, or updating existing documentation.
Guidelines for writing unit tests in the Hex1b TUI library. Use when creating new tests for widgets, nodes, or terminal functionality.
Agent for validating Hex1b documentation against actual library behavior. Use when auditing documentation accuracy, testing interactive examples, or identifying discrepancies between documentation and implementation.
Guidelines for running and interpreting Surface API performance benchmarks. Use when modifying code in src/Hex1b/Surfaces/ to ensure performance is not regressed.
| 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. |
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.
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 |
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:
internal properties with init for optional configurationFunc<TEventArgs, Task>? patternthis with { } patternReconcile() to create/update the corresponding nodeGetExpectedNodeType() to return the node typeTemplate:
using Hex1b.Events;
using Hex1b.Nodes;
namespace Hex1b.Widgets;
/// <summary>
/// Brief description of what the widget does.
/// </summary>
/// <param name="PrimaryProperty">Description of the main property.</param>
public sealed record MyWidget(string PrimaryProperty) : Hex1bWidget
{
/// <summary>
/// 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.
/// </summary>
public static readonly ActionId Activate = new($"{nameof(MyWidget)}.{nameof(Activate)}");
/// <summary>
/// Optional configuration property.
/// </summary>
internal bool SomeOption { get; init; }
/// <summary>
/// Event handler for some action.
/// </summary>
internal Func<MyEventArgs, Task>? ActionHandler { get; init; }
/// <summary>
/// Sets a synchronous action handler.
/// </summary>
public MyWidget OnAction(Action<MyEventArgs> handler)
=> this with { ActionHandler = args => { handler(args); return Task.CompletedTask; } };
/// <summary>
/// Sets an asynchronous action handler.
/// </summary>
public MyWidget OnAction(Func<MyEventArgs, Task> 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);
}
If your widget has event handlers, create a typed event args class.
Location: src/Hex1b/Events/{Name}EventArgs.cs
Template:
using Hex1b.Input;
using Hex1b.Widgets;
namespace Hex1b.Events;
/// <summary>
/// Event arguments for MyWidget actions.
/// </summary>
public sealed class MyEventArgs
{
/// <summary>
/// The widget that raised the event.
/// </summary>
public MyWidget Widget { get; }
/// <summary>
/// The node that raised the event.
/// </summary>
public MyNode Node { get; }
/// <summary>
/// The input binding context.
/// </summary>
public InputBindingActionContext Context { get; }
internal MyEventArgs(MyWidget widget, MyNode node, InputBindingActionContext context)
{
Widget = widget;
Node = node;
Context = context;
}
}
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:
MarkDirty() when internal state changesMeasure() to calculate sizeRender() to draw to terminalIsFocusable if the widget can receive focusConfigureDefaultBindings() for keyboard/mouse handlingLift 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<TSelf, TState>so callers can pass an external instance via.State(...). SeeTextBoxWidget/TextBoxState,EditorWidget/EditorState,NavigatorWidget/NavigatorState,CheckboxWidget/CheckboxStatefor 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:
using Hex1b.Input;
using Hex1b.Layout;
using Hex1b.Theming;
using Hex1b.Widgets;
namespace Hex1b;
/// <summary>
/// Render node for MyWidget.
/// </summary>
public sealed class MyNode : Hex1bNode
{
public string PrimaryProperty { get; set; } = "";
public bool SomeOption { get; set; }
/// <summary>
/// The source widget for typed event args.
/// </summary>
public MyWidget? SourceWidget { get; set; }
/// <summary>
/// Callback for the action event.
/// </summary>
public Func<InputBindingActionContext, Task>? 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);
}
}
}
Theme elements allow users to customize the widget's appearance.
Location: src/Hex1b/Theming/{Name}Theme.cs
Template:
namespace Hex1b.Theming;
/// <summary>
/// Theme elements for MyWidget.
/// </summary>
public static class MyTheme
{
public static readonly Hex1bThemeElement<Hex1bColor> ForegroundColor =
new($"{nameof(MyTheme)}.{nameof(ForegroundColor)}", () => Hex1bColor.Default);
public static readonly Hex1bThemeElement<Hex1bColor> BackgroundColor =
new($"{nameof(MyTheme)}.{nameof(BackgroundColor)}", () => Hex1bColor.Default);
// For widgets with multiple states (focused, hovered, etc.)
public static readonly Hex1bThemeElement<Hex1bColor> FocusedForegroundColor =
new($"{nameof(MyTheme)}.{nameof(FocusedForegroundColor)}", () => Hex1bColor.Black);
public static readonly Hex1bThemeElement<Hex1bColor> FocusedBackgroundColor =
new($"{nameof(MyTheme)}.{nameof(FocusedBackgroundColor)}", () => Hex1bColor.White);
// For character customization
public static readonly Hex1bThemeElement<char> SomeCharacter =
new($"{nameof(MyTheme)}.{nameof(SomeCharacter)}", () => '█');
}
Extension methods provide the fluent API for creating widgets.
Location: src/Hex1b/{Name}Extensions.cs
Key principles:
WidgetContext<TParent> for widgets that can be childrenTemplate:
namespace Hex1b;
using Hex1b.Widgets;
/// <summary>
/// Extension methods for creating MyWidget.
/// </summary>
public static class MyExtensions
{
/// <summary>
/// Creates a MyWidget with the specified property.
/// </summary>
public static MyWidget My<TParent>(
this WidgetContext<TParent> ctx,
string primaryProperty)
where TParent : Hex1bWidget
=> new(primaryProperty);
/// <summary>
/// Creates a MyWidget with options.
/// </summary>
public static MyWidget My<TParent>(
this WidgetContext<TParent> ctx,
string primaryProperty,
bool someOption)
where TParent : Hex1bWidget
=> new(primaryProperty) { SomeOption = someOption };
}
Tests verify that the node behaves correctly.
Location: tests/Hex1b.Tests/{Name}NodeTests.cs
Key test scenarios:
Template:
using Hex1b;
using Hex1b.Input;
using Hex1b.Layout;
using Hex1b.Theming;
namespace Hex1b.Tests;
public class MyNodeTests
{
[Fact]
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.Equal(5, size.Width); // "Hello".Length
Assert.Equal(1, size.Height);
}
[Fact]
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
}
[Fact]
public void IsFocused_WhenSet_MarksDirty()
{
// Arrange
var node = new MyNode();
node.ClearDirty();
// Act
node.IsFocused = true;
// Assert
Assert.True(node.IsDirty);
}
}
After creating all files:
# Build the library
dotnet build src/Hex1b
# Run all tests
dotnet test
# Or run specific tests
dotnet test --filter "MyNodeTests"
For widgets that should fill available horizontal space by default:
public override Size Measure(Constraints constraints)
{
// Use all available width
var width = constraints.MaxWidth;
var height = 1;
return constraints.Constrain(new Size(width, height));
}
For widgets with animation (like indeterminate progress), you need to:
MarkDirty() when animation changesHex1bApp.Invalidate() from the widget builder to trigger re-rendersFor widgets that contain children, see VStackWidget/VStackNode as examples:
Hex1bWidget[] (widget) / List<Hex1bNode> (node)ReconcileContext.ReconcileChildren() in widget's Reconcile()GetChildren() in node for focus traversalHex1bColor.Default for colors that should inherit from parentTesting 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.
| 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 verify isolated node behavior without running a full app.
Key scenarios to cover:
Example pattern:
[Fact]
public void Measure_FillsAvailableWidth()
{
var node = new ProgressNode { Value = 50, Maximum = 100 };
var size = node.Measure(new Constraints(0, 80, 0, 10));
Assert.Equal(80, size.Width);
Assert.Equal(1, size.Height);
}
[Fact]
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.True(node.IsDirty);
}
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:
using Hex1b;
using Hex1b.Input;
using Hex1b.Theming;
public class MyWidgetIntegrationTests : IDisposable
{
private readonly List<string> _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 { }
}
}
}
[Fact]
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;
}
Test the widget in all common layout containers:
[Fact]
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;
}
[Fact]
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;
}
[Theory]
[InlineData(40)]
[InlineData(60)]
[InlineData(80)]
[InlineData(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;
}
For widgets with animation or dynamic behavior, record asciinema sessions:
[Fact]
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;
}
Test how widgets respond to terminal resizing:
[Fact]
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;
}
Verify custom themes are applied correctly:
[Fact]
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;
}
Every widget should have integration tests covering:
.FixedWidth() constraint.Fill() in cross-axis containerapp.Invalidate() (if applicable)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.
For complex widgets like TableWidget, consider adding visual regression tests that capture baselines and compare rendered output.
Visual regression tests use the full Hex1bTerminal stack to render widgets and capture output for comparison:
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);
}
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)When widget rendering changes intentionally:
UPDATE_BASELINES=1 dotnet test --filter "{Widget}VisualRegressionTests"
For TableWidget, the visual regression test matrix covers:
See tests/Hex1b.Tests/TableVisualRegressionTests.cs for the complete implementation.
Before considering a widget complete:
dotnet build succeedsdotnet test passesThe ProgressWidget was created following this skill. It demonstrates:
See the implementation files: