From 1d4bc401b90eaa9583035c3077a4a9e6c154da5b Mon Sep 17 00:00:00 2001 From: Ismael Canal Date: Thu, 16 Apr 2026 11:17:18 +0200 Subject: [PATCH 01/18] Add TUIKit cross-language component spec system Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 1 + README.md | 337 +++++++ _schema.md | 747 ++++++++++++++++ compile.ts | 496 +++++++++++ components/Dialog/Dialog.md | 191 ++++ components/Dialog/Dialog.preview.md | 45 + components/Dialog/Dialog.test.md | 355 ++++++++ components/HintBar/HintBar.md | 130 +++ components/HintBar/HintBar.preview.md | 44 + components/HintBar/HintBar.test.md | 195 ++++ components/Icons/Icons.md | 432 +++++++++ components/Icons/Icons.preview.md | 11 + components/Icons/Icons.test.md | 608 +++++++++++++ components/Input/Input.md | 316 +++++++ components/Input/Input.preview.md | 32 + components/Input/Input.test.md | 835 ++++++++++++++++++ components/Link/Link.md | 168 ++++ components/Link/Link.preview.md | 33 + components/Link/Link.test.md | 268 ++++++ components/Metric/Metric.md | 153 ++++ components/Metric/Metric.preview.md | 30 + components/Metric/Metric.test.md | 125 +++ components/QrCode/QrCode.md | 109 +++ components/QrCode/QrCode.preview.md | 17 + components/QrCode/QrCode.test.md | 159 ++++ components/Select/Select.md | 324 +++++++ components/Select/Select.preview.md | 78 ++ components/Select/Select.test.md | 507 +++++++++++ .../SelectAutocomplete/SelectAutocomplete.md | 359 ++++++++ .../SelectAutocomplete.preview.md | 38 + .../SelectAutocomplete.test.md | 525 +++++++++++ components/TabBar/TabBar.md | 238 +++++ components/TabBar/TabBar.preview.md | 67 ++ components/TabBar/TabBar.test.md | 424 +++++++++ components/Table/Table.md | 177 ++++ components/Table/Table.preview.md | 46 + components/Table/Table.test.md | 189 ++++ components/TextHeading/TextHeading.md | 85 ++ components/TextHeading/TextHeading.preview.md | 18 + components/TextHeading/TextHeading.test.md | 52 ++ components/TextSpinner/TextSpinner.md | 224 +++++ components/TextSpinner/TextSpinner.preview.md | 45 + components/TextSpinner/TextSpinner.test.md | 404 +++++++++ components/TextTitle/TextTitle.md | 80 ++ components/TextTitle/TextTitle.preview.md | 18 + components/TextTitle/TextTitle.test.md | 51 ++ components/TimelineItem/TimelineItem.md | 396 +++++++++ .../TimelineItem/TimelineItem.preview.md | 73 ++ components/TimelineItem/TimelineItem.test.md | 564 ++++++++++++ demo.md | 84 ++ lint.ts | 721 +++++++++++++++ targets/bun.md | 290 ++++++ targets/csharp.md | 294 ++++++ targets/go.md | 248 ++++++ targets/rust.md | 377 ++++++++ tokens/breakpoints.md | 28 + tokens/breakpoints.preview.md | 11 + tokens/colors.md | 215 +++++ tokens/colors.preview.md | 29 + tokens/icons.md | 112 +++ tokens/icons.preview.md | 29 + 61 files changed, 13257 insertions(+) create mode 100644 .gitignore create mode 100644 _schema.md create mode 100644 compile.ts create mode 100644 components/Dialog/Dialog.md create mode 100644 components/Dialog/Dialog.preview.md create mode 100644 components/Dialog/Dialog.test.md create mode 100644 components/HintBar/HintBar.md create mode 100644 components/HintBar/HintBar.preview.md create mode 100644 components/HintBar/HintBar.test.md create mode 100644 components/Icons/Icons.md create mode 100644 components/Icons/Icons.preview.md create mode 100644 components/Icons/Icons.test.md create mode 100644 components/Input/Input.md create mode 100644 components/Input/Input.preview.md create mode 100644 components/Input/Input.test.md create mode 100644 components/Link/Link.md create mode 100644 components/Link/Link.preview.md create mode 100644 components/Link/Link.test.md create mode 100644 components/Metric/Metric.md create mode 100644 components/Metric/Metric.preview.md create mode 100644 components/Metric/Metric.test.md create mode 100644 components/QrCode/QrCode.md create mode 100644 components/QrCode/QrCode.preview.md create mode 100644 components/QrCode/QrCode.test.md create mode 100644 components/Select/Select.md create mode 100644 components/Select/Select.preview.md create mode 100644 components/Select/Select.test.md create mode 100644 components/SelectAutocomplete/SelectAutocomplete.md create mode 100644 components/SelectAutocomplete/SelectAutocomplete.preview.md create mode 100644 components/SelectAutocomplete/SelectAutocomplete.test.md create mode 100644 components/TabBar/TabBar.md create mode 100644 components/TabBar/TabBar.preview.md create mode 100644 components/TabBar/TabBar.test.md create mode 100644 components/Table/Table.md create mode 100644 components/Table/Table.preview.md create mode 100644 components/Table/Table.test.md create mode 100644 components/TextHeading/TextHeading.md create mode 100644 components/TextHeading/TextHeading.preview.md create mode 100644 components/TextHeading/TextHeading.test.md create mode 100644 components/TextSpinner/TextSpinner.md create mode 100644 components/TextSpinner/TextSpinner.preview.md create mode 100644 components/TextSpinner/TextSpinner.test.md create mode 100644 components/TextTitle/TextTitle.md create mode 100644 components/TextTitle/TextTitle.preview.md create mode 100644 components/TextTitle/TextTitle.test.md create mode 100644 components/TimelineItem/TimelineItem.md create mode 100644 components/TimelineItem/TimelineItem.preview.md create mode 100644 components/TimelineItem/TimelineItem.test.md create mode 100644 demo.md create mode 100644 lint.ts create mode 100644 targets/bun.md create mode 100644 targets/csharp.md create mode 100644 targets/go.md create mode 100644 targets/rust.md create mode 100644 tokens/breakpoints.md create mode 100644 tokens/breakpoints.preview.md create mode 100644 tokens/colors.md create mode 100644 tokens/colors.preview.md create mode 100644 tokens/icons.md create mode 100644 tokens/icons.preview.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..849ddff --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/README.md b/README.md index e69de29..7c70ee9 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,337 @@ +# TUIkit Specs + +A spec-driven system for building UI components across programming languages. +Each component is defined as a language-agnostic markdown spec with behavioral +tests. An LLM agent acts as the "compiler" — reading specs and generating +idiomatic implementations per target framework. + +## How it works + +``` +┌─────────────┐ ┌──────────────┐ ┌─────────────────┐ +│ Spec files │────▶│ compile.ts │────▶│ LLM Agent │ +│ (.md) │ │ (prompt) │ │ (compiler) │ +└─────────────┘ └──────────────┘ └────────┬────────┘ + │ + ┌──────────────┐ │ + │ lock file │◀──────────────┤ + │ (.json) │ │ + └──────────────┘ ┌─────────▼────────┐ + │ dist/{target}/ │ + │ (generated code) │ + └──────────────────┘ +``` + +1. **Specs** define behavior + semantic tokens (like headless UI libraries) +2. **Target specs** define how to translate to a specific language/framework +3. **compile.ts** detects changed specs and generates a self-contained prompt +4. An **LLM agent** reads the prompt and generates idiomatic code +5. Generated code goes to **dist/** — specs stay clean +6. **Lock files** track which spec versions have been compiled + +## Quick start + +```bash +# Check what needs compiling +bun compile.ts status + +# Generate a prompt for the Go target +bun compile.ts prompt --target go + +# The prompt is written to dist/go/_compile-prompt.md +# Feed it to an LLM agent (e.g. Copilot CLI, Claude, etc.) +# The agent writes generated code to dist/go/ + +# After verifying the generated code works, lock the hashes +bun compile.ts lock --target go + +# Lint all specs against the schema +bun lint.ts +``` + +## Specs directory structure + +``` +specs/ + _schema.md Meta-spec — defines the format for all specs + compile.ts Compiler CLI + lint.ts Linter CLI + targets/ + go.md Go + Bubbletea target definition + bun.md Bun + Ink target definition + rust.md Rust + Ratatui target definition + csharp.md C# + Spectre.Console target definition + *.lock.json Lock files (per target, committed) + tokens/ + colors.md Semantic color tokens + icons.md Icon glyphs and aliases + breakpoints.md Responsive width thresholds + components/ + {Name}/ + {Name}.md Component spec + {Name}.test.md Behavioral test spec + dist/ Compiled output (gitignored) + {target}/ One folder per target +``` + +## Writing specs + +### Component spec + +Each component is a markdown file with YAML frontmatter and prose body: + +````yaml +--- +kind: component +name: MyComponent +description: One-line summary. +version: 1 +category: input # input | display | navigation | layout | feedback + +tokens: + colors: [textPrimary, selected] + icons: [iconPrompt] + +props: + label: + type: string + required: true + description: Display text. + +dependencies: + tokens: + - name: textPrimary + kind: color + usage: "Label text" + required: true + components: [] + +accessibility: + role: button + announce: + on_mount: "Button: {label}" +--- + +## Visual rules + +- Label text MUST use the `textPrimary` color token +- Active state MUST use the `selected` color token + +## Rendering example + +Given label: "Click me" + +​``` +Click me +​``` + +## Dependencies + +| Dependency | Kind | Usage | Required | +|------------|------|-------|----------| +| `textPrimary` | color | Label text | Yes | +| `selected` | color | Active state | Yes | +```` + +### Test spec + +Test specs live alongside component specs and use a block-based format: + +```markdown +--- +kind: test +component: MyComponent +version: 1 +--- + +## renders label text + +​`props +label: "Hello" +​` + +​`expect +Hello +​` +``` + +See `_schema.md` for the full format reference, including `input`, `state`, +`style`, and `accessibility` test blocks. + +### Conformance language + +All normative sections (Visual rules, Behavior, Edge cases) use +[RFC 2119](https://datatracker.ietf.org/doc/html/rfc2119) keywords: + +- **MUST** — absolute requirement +- **SHOULD** — strong recommendation +- **MAY** — optional behavior +- **MUST NOT** — absolute prohibition + +## Compiling to a target + +### Available targets + +| Target | Language | Framework | File | +| -------- | ---------- | -------------------- | ------------------- | +| `go` | Go | Bubbletea + Lipgloss | `targets/go.md` | +| `bun` | TypeScript | Ink + React | `targets/bun.md` | +| `rust` | Rust | Ratatui + Crossterm | `targets/rust.md` | +| `csharp` | C# | Spectre.Console | `targets/csharp.md` | + +### Workflow + +```bash +# 1. See what's changed +bun compile.ts status + +# 2. Generate the compilation prompt +bun compile.ts prompt --target go + +# 3. Feed dist/go/_compile-prompt.md to an LLM agent +# The agent generates code into dist/go/ + +# 4. Verify: run tests, check the demo CLI +cd dist/go && go test ./... && go run ./cmd/demo + +# 5. Lock the hashes +bun compile.ts lock --target go +``` + +### Custom output directory + +By default, compiled code goes to `specs/dist/`. Override with `--out`: + +```bash +# Output to a separate repo or directory +bun compile.ts prompt --target go --out ~/my-tuikit-go + +# The prompt and generated code go to ~/my-tuikit-go/go/ +``` + +### Adding a new target + +1. Create `targets/{name}.md` following the target spec format in `_schema.md` +2. Define: architecture pattern, type mapping, callback translation, state + machine pattern, token access, styling, composition, test pattern, key + mapping, dependencies, and demo CLI +3. Run `bun compile.ts status` — your target will show up with all specs dirty +4. Run `bun compile.ts prompt --target {name}` and compile + +## Building your own component library + +The specs are designed to bootstrap a full component library in your target +language. Use `--out` to point at your own project and maintain it independently. + +### Initial compilation + +```bash +# 1. Create your project directory +mkdir ~/my-tuikit-go && cd ~/my-tuikit-go +go mod init github.com/myorg/tuikit + +# 2. Generate the full compilation prompt +cd /path/to/specs +bun compile.ts prompt --target go --out ~/my-tuikit-go + +# 3. Feed the prompt to an LLM agent +# Point the agent at ~/my-tuikit-go/go/_compile-prompt.md +# It generates all components, tokens, and tests into ~/my-tuikit-go/go/ + +# 4. Verify everything works +cd ~/my-tuikit-go/go && go test ./... + +# 5. Lock the compiled state +cd /path/to/specs +bun compile.ts lock --target go +``` + +Your component library now lives in `~/my-tuikit-go/` — a standalone project +you own, version, and publish independently of the specs. + +### Incremental updates + +When specs change (new components, bug fixes, behavior changes), you don't +need to recompile everything: + +```bash +# See what changed since last compilation +bun compile.ts status --target go + +# Generate a prompt with only dirty specs +bun compile.ts prompt --target go --out ~/my-tuikit-go + +# The prompt tells the agent exactly which components to update +# Feed it to the agent — it patches your existing codebase + +# Verify and lock +cd ~/my-tuikit-go/go && go test ./... +cd /path/to/specs && bun compile.ts lock --target go +``` + +### Extending with custom components + +You can add components to the specs and compile them into your library: + +1. Create `components/MyComponent/MyComponent.md` following the format +2. Create `components/MyComponent/MyComponent.test.md` with behavioral tests +3. Run `bun lint.ts` to validate against the schema +4. Run `bun compile.ts prompt --target go --out ~/my-tuikit-go` +5. The new component appears in the prompt alongside any other dirty specs + +### Multiple targets from one spec set + +The same specs can produce libraries for different languages simultaneously: + +```bash +# Compile to all your targets +bun compile.ts prompt --target go --out ~/tuikit-go +bun compile.ts prompt --target rust --out ~/tuikit-rust +bun compile.ts prompt --target csharp --out ~/tuikit-csharp + +# Each output is a standalone project with idiomatic code +# Lock each target independently +bun compile.ts lock --target go +bun compile.ts lock --target rust +bun compile.ts lock --target csharp +``` + +## Linting + +```bash +# Lint all specs +bun lint.ts + +# Lint a single component +bun lint.ts --component Select + +# Show fix suggestions +bun lint.ts --fix +``` + +The linter checks: + +- Required frontmatter fields and valid values +- Naming conventions (PascalCase components, camelCase props) +- RFC 2119 keyword usage in normative sections +- ARIA accessibility structure for interactive components +- Token cross-references resolve to known tokens +- Required body sections (Visual rules, Rendering example, Dependencies) +- Test specs reference existing components + +## Design principles + +- **Specs capture intent, not implementation** — ~95% behavioral intent vs. + ~5% framework hints. This lets agents generate idiomatic code per framework + rather than awkward transliterations. + +- **Color tokens define meaning, not color values** — tokens like `textPrimary` + and `selected` define UI roles. The color engine (Rampa, hardcoded hex, + ANSI palette) is an implementation detail per target. + +- **Layout is out of scope** — specs define behavior and semantic tokens. + Spacing, padding, and spatial polish are per-target decisions (similar to + headless UI libraries like Radix or Base UI). + +- **Lock files enable incremental compilation** — only dirty specs trigger + regeneration. Schema changes invalidate everything. diff --git a/_schema.md b/_schema.md new file mode 100644 index 0000000..4710f0b --- /dev/null +++ b/_schema.md @@ -0,0 +1,747 @@ +--- +kind: schema +name: _schema +description: > + Meta-spec that defines the format for all TUIkit component and token specs. + Agents read this before generating new specs or compiling specs to code. +version: 2 +--- + +# TUIkit Spec Format + +This document defines the spec format used to describe TUIkit tokens and components +in a language-agnostic way. Specs are the **source of truth** for cross-language +code generation. + +## Conformance language (RFC 2119) + +All specs use RFC 2119 keywords to distinguish normative requirements from +informative guidance: + +- **MUST** / **MUST NOT**: Absolute requirement or prohibition. Agents MUST + generate code that enforces this. +- **SHOULD** / **SHOULD NOT**: Strong recommendation. Agents SHOULD generate + this unless the target framework makes it impractical. +- **MAY**: Optional behavior. Agents MAY omit or make configurable. + +All Behavior, Visual rules, and Edge cases sections MUST use conformance +keywords for any statement that affects rendered output or interaction logic. + +## File structure + +``` +specs/ + _schema.md ← this file (meta-spec) + README.md ← getting started guide + compile.ts ← compiler CLI (status/prompt/lock/clean) + lint.ts ← linter CLI (validate specs against schema) + targets/ + go.md ← Go + Bubbletea + Lipgloss + bun.md ← Bun + Ink + React + rust.md ← Rust + Ratatui + Crossterm + csharp.md ← C# + Spectre.Console + {target}.lock.json ← generated lock file (tracks compiled state) + tokens/ + colors.md ← semantic color tokens + icons.md ← icon glyphs and semantic aliases + breakpoints.md ← responsive width thresholds + components/ + {Name}/ + {Name}.md ← component spec + {Name}.test.md ← component test spec + {Name}.preview.md ← component preview spec + dist/ ← compiled output (gitignored) + {target}/ ← generated code per target + _compile-prompt.md ← the prompt fed to the agent + ... ← generated source files +``` + +--- + +## Compilation workflow + +The compiler detects spec changes via content hashing and generates +self-contained prompts for LLM agents. + +### Commands + +```bash +# Show dirty/clean status for all targets +bun compile.ts status + +# Show status for a specific target +bun compile.ts status --target go + +# Generate compilation prompt for all dirty specs +bun compile.ts prompt --target go + +# Generate prompt for a single component +bun compile.ts prompt --target go --component HintBar + +# Lock spec hashes after successful compilation +bun compile.ts lock --target go + +# Remove lock file and generated prompts +bun compile.ts clean --target go +``` + +### Lock file format + +Lock files track which spec versions have been compiled. A spec is +"dirty" when its content hash differs from the locked hash, or when the +schema hash changes (invalidating all entries). + +```json +{ + "target": "go", + "schemaHash": "ea8e9c21ed0044cc", + "updatedAt": "2026-03-28T00:00:00.000Z", + "entries": { + "components/HintBar": { + "version": "1", + "specHash": "a1b2c3d4e5f60718", + "testHash": "1234abcd5678ef90", + "lockedAt": "2026-03-28T00:00:00.000Z" + } + } +} +``` + +### Workflow + +1. Edit specs (component, token, or test files) +2. Run `status` to see what changed +3. Run `prompt --target ` to generate a compilation prompt +4. Feed the prompt to an LLM agent (e.g. Copilot CLI) +5. Verify generated code passes tests +6. Run `lock --target ` to snapshot current hashes + +--- + +## Token spec format + +Token specs define shared design values consumed by all components. + +### Frontmatter (required) + +```yaml +kind: token # always "token" +name: string # token group name (e.g., "colors", "icons", "breakpoints") +description: string # what this token group provides +version: number # spec version (increment on breaking changes) +``` + +### Frontmatter (token-specific) + +Token specs define their data directly in frontmatter. The format varies by token type: + +- **colors**: `tokens:` map with ramp coordinates and fallbacks per color mode +- **icons**: `groups:` map with glyph definitions and semantic aliases +- **breakpoints**: `values:` map with width thresholds + +### Body + +The markdown body contains: + +- Design principles (why these tokens exist) +- Implementation guide (how to build them in a target language) + +--- + +## Component spec format + +### Frontmatter (required) + +```yaml +kind: component # always "component" +name: string # PascalCase component name +description: string # one-line summary +version: number # spec version +category: string # one of: input, display, navigation, layout, feedback +``` + +### Frontmatter: tokens + +Declares which tokens this component uses. Agents use this to generate correct +imports and ensure token availability. + +```yaml +tokens: + colors: [textPrimary, textSecondary, selected, ...] + icons: [iconPrompt, iconSuccess, ...] +``` + +### Frontmatter: props + +Typed prop definitions. Every prop must declare type, required, and description. + +```yaml +props: + propName: + type: string | number | boolean | array | record | callback(args) | T + required: boolean + default: value # only if required is false + description: string +``` + +Supported types: + +- Primitives: `string`, `number`, `boolean` +- Collections: `array`, `record` +- Callbacks: `callback(arg1: type, arg2: type) → returnType` +- Unions: `string | false | null` +- Generic: `T` (component is generic over T) +- References: `SelectItem` (references another type defined in the spec) + +### Frontmatter: types (optional) + +Define supporting types used by props. + +```yaml +types: + SelectItem: + generic: T + fields: + label: { type: string, required: true, description: "Display text" } + value: { type: T, required: true, description: "Backing value" } + current: { type: boolean, required: false, description: "Marks as active choice" } +``` + +### Frontmatter: states (optional) + +Defines a state machine for interactive components. Stateless components omit this. + +```yaml +states: + initial: idle + definitions: + idle: + description: Component mounted, waiting for focus + transitions: + focus: focused + focused: + description: Component has keyboard focus + transitions: + select: selected + escape: dismissed + selected: + description: User confirmed a choice + terminal: true + dismissed: + description: User cancelled + terminal: true +``` + +Rules: + +- `initial` is the entry state +- `terminal: true` states emit a callback and end interaction +- Transitions are `trigger: targetState` pairs +- Triggers can be keyboard keys, programmatic events, or timers + +### Frontmatter: keyboard (optional) + +Maps keyboard input to actions. Actions reference state transitions or callbacks. + +```yaml +keyboard: + "↑": { action: "move selection up", wrap: true } + "↓": { action: "move selection down", wrap: true } + k: { action: "move selection up", note: "vim binding" } + j: { action: "move selection down", note: "vim binding" } + enter: { action: "confirm selection → fires onSelect" } + escape: { action: "cancel → fires onEscape" } + ctrl+g: { action: "cancel (alternative)", same_as: escape } + "1-9": { action: "select item by number (1-indexed)" } +``` + +### Frontmatter: breakpoints (optional) + +Defines responsive behavior at different terminal widths. + +```yaml +breakpoints: + compact: # < 80 columns + description: what changes at this breakpoint + narrow: # 80-119 columns + description: what changes at this breakpoint + wide: # ≥ 120 columns + description: what changes at this breakpoint +``` + +### Frontmatter: accessibility (optional) + +Screen reader and assistive technology behavior. Uses ARIA terminology for +formal role/state/property definitions. + +```yaml +accessibility: + role: string # ARIA role (e.g., "listbox", "textbox", "dialog", "status") + properties: # static ARIA attributes set once on mount + aria-label: string # MUST be descriptive of the component purpose + aria-describedby: string # MAY reference hint text or description + states: # dynamic ARIA states that change during interaction + aria-selected: string # description of when true/false + aria-expanded: string # description of when true/false + announce: + on_mount: string # screen reader announcement on first render + on_change: string # announcement when state changes + screen_reader_adaptations: + - when: string # condition (e.g., "screen reader detected") + change: string # what changes in rendering +``` + +Rules: + +- Every interactive component MUST define `role` and `announce.on_mount` +- Components with selection MUST define `states.aria-selected` +- `screen_reader_adaptations` MUST describe visual-to-text replacements + (e.g., replacing glyphs with text suffixes) + +### Frontmatter: animation (optional) + +Animation definitions with accessibility controls. + +```yaml +animation: + name: + frames: [frame1, frame2, ...] # or reference to icon spinner sequence + interval_ms: number + disable_when: [screen_reader, reduced_motion] +``` + +### Frontmatter: constants (optional) + +Named values that are part of the component contract (not configuration). +Constants differ from props in that they are fixed, not user-provided. + +```yaml +constants: + MIN_DIALOG_WIDTH: + type: number + value: 30 + description: "Minimum character width for dialog rendering" +``` + +### Frontmatter: variants (optional) + +Describes alternate forms of the component that share most behavior but +differ in specific props or rendering. Variants MUST reference the base +component and list only the differences. + +```yaml +variants: + UncontrolledInput: + description: "Input that manages its own value state internally" + props_override: + value: { required: false } + onChange: { required: false } + props_added: + defaultValue: { type: string, required: false, description: "Initial value" } +``` + +### Frontmatter: security (optional) + +Input sanitization and output safety rules. Components that accept URLs, +user-provided text, or render external content MUST define this section. + +```yaml +security: + sanitization: + - input: url + rule: "MUST strip ESC (0x1B), BEL (0x07), and ST (0x9C) characters" + reason: "Prevent terminal escape injection via malicious URLs" +``` + +### Frontmatter: dependencies (required) + +Every component MUST declare its dependencies on tokens and other components. +This enables the compiler to build dependency graphs and detect breaking changes. + +```yaml +dependencies: + tokens: + - name: selected + kind: color + usage: "Highlight indicator and text color" + required: true + - name: iconPrompt + kind: icon + usage: "Selection indicator glyph" + required: true + components: + - name: HintBar + usage: "Keyboard navigation hints in footer" + required: false + dependents: # other components that use this one + - SelectAutocomplete + - FilterMenu +``` + +Rules: + +- `required: true` tokens MUST be available at render time; absence is an error +- `required: false` tokens MAY be absent; component MUST render without them +- `dependents` is informational; used by the compiler for cascade invalidation + +Declares how this component composes with other components. + +```yaml +composition: + children: + - component: HintBar + slot: footer + default_props: { hints: { ... } } + optional: true # can be hidden via prop + slots: + footer: { description: "Bottom area for hints or actions" } +``` + +### Body sections + +The markdown body follows a consistent structure: + +#### 1. Overview (required) + +One paragraph describing the component's purpose. + +#### 2. Visual rules (required) + +Bullet list of styling rules. MUST reference token names, MUST NOT use raw colors. +MUST use RFC 2119 keywords. + +```markdown +## Visual rules + +- Selected item text MUST use the `selected` color token +- Selection indicator MUST use `iconPrompt` +- Current item MUST show `iconSuccess` in `statusSuccess` color +- Unselected items SHOULD use `textOnBackgroundSecondary` +``` + +#### 3. Rendering example (required) + +Shows expected output with annotations. + +````markdown +## Rendering example + +Given items: ["Alpha", "Beta (current)", "Gamma"] + +``` +❯ 1. Alpha + 2. Beta ✓ + 3. Gamma +↑↓ to navigate · Enter to select · Esc to cancel +``` +```` + +#### 4. Behavior (optional, for interactive components) + +Describes interaction patterns. For keyboard-driven interactions, MUST use +numbered algorithmic steps rather than prose: + +```markdown +### Escape key processing + +When the user presses Escape while in the `focused` state: + +1. Let item be escapeItem if provided and not null, else undefined +2. If item is defined: + a. Call onSelect(item) + b. Transition to `dismissed` state + c. Terminate processing +3. Let callback be onEscape if provided, else undefined +4. If callback is defined: + a. Call callback() + b. Transition to `dismissed` state + c. Terminate processing +5. Otherwise, remain in `focused` state and discard the keystroke +``` + +#### 5. Dependencies (required) + +Summary table of tokens and components consumed. MUST match the +`dependencies` frontmatter section. + +```markdown +## Dependencies + +| Dependency | Kind | Usage | Required | +| --------------- | --------- | ------------------- | -------- | +| `selected` | color | Highlight text | Yes | +| `statusSuccess` | color | Current item glyph | No | +| `iconPrompt` | icon | Selection indicator | Yes | +| `HintBar` | component | Footer hints | No | +``` + +#### 6. Variants (optional) + +Describes component variants (e.g., SelectWithTextInput). + +#### 6. Edge cases (optional) + +Documents boundary behavior. + +--- + +## Test spec format + +### Frontmatter (required) + +```yaml +kind: test +component: string # must match a component spec name +version: number +``` + +### Test blocks + +Each test is an H2 heading followed by fenced code blocks: + +#### Props block (required per test) + +````yaml +# Inside ```props block +items: ["Alpha", "Beta"] +onSelect: callback +```` + +#### Expect block — normative character grid assertion + +The expect block is **normative**. The rendered character grid is the +source of truth for output validation. No alternative rendering is valid +for the given props. + +```` +# Inside ```expect block — exact terminal output (normative) +❯ 1. Alpha + 2. Beta +```` + +The expect block matches **plain text only** — no ANSI codes. The test harness +strips ANSI before comparison. + +#### Input block — simulates keyboard input + +````yaml +# Inside ```input block +↓ # single key +↓ ↓ ↓ # sequence (space-separated) +"hello" # text input (quoted) +```` + +Input blocks are applied **sequentially** before the next expect block. + +#### Style block — semantic style assertions + +````yaml +# Inside ```style block +- selector: key("Esc") # targets a rendered text segment + bold: true + color: textPrimary # references a semantic token name +- selector: separator + color: textSecondary +```` + +Selectors: + +- `key("text")` — matches a key label +- `label("text")` — matches an action label +- `item(index)` — matches a list item by 0-based index +- `indicator(index)` — matches a selection indicator +- `separator` — matches separators +- `component("Name")` — matches a child component + +#### State block — state machine assertions + +````yaml +# Inside ```state block +before: idle +trigger: focus +after: focused +```` + +#### Accessibility block — screen reader assertions + +````yaml +# Inside ```accessibility block +announce: "Select: 3 items. Alpha, Beta, Gamma. Use arrow keys to navigate." +```` + +### Test sequencing + +Tests within a single H2 section run **top to bottom**. Blocks are: + +1. `props` — set up component +2. `expect` — assert initial render +3. `input` — simulate interaction +4. `expect` — assert updated render +5. (repeat input → expect as needed) + +This enables multi-step interaction tests: + +````markdown +## navigates down then selects + +​`props +items: ["A", "B", "C"] +​` + +​`expect +❯ 1. A + 2. B + 3. C +​` + +​`input +↓ +​` + +​```expect + +1. A + ❯ 2. B +2. C + ​``` + +​`input +enter +​` + +​`state +after: selected +selected_value: "B" +​` +```` + +--- + +## Preview spec format + +Preview specs define how a component is showcased in the demo app. Each file +lists named variants with their props — the demo app renders all variants for +the selected component. + +### Frontmatter (required) + +```yaml +kind: preview +component: string # must match a component spec name (or "colors", "icons", "breakpoints" for tokens) +version: number +``` + +### Variant blocks + +Each variant is an H2 heading followed by a `props` fenced code block: + +````markdown +## Default hints + +​`props +hints: + enter: "select" + esc: "cancel" + up-down: "navigate" +​` + +## Custom separator + +​`props +hints: + a: "one" + b: "two" + c: "three" +separator: " | " +​` +```` + +Rules: + +- Each `## heading` names the variant (displayed as a label in the demo) +- Each `props` block contains YAML props passed to the component +- Variants render **top to bottom** in the demo screen +- For token previews, the `props` block contains display configuration + (e.g., which token groups to show) +- Preview specs MUST NOT duplicate test logic — they showcase visual + surface area, not assert correctness + +--- + +## Target spec format + +Target specs define how component/token specs compile to a specific language + framework. + +### Frontmatter (required) + +```yaml +kind: target +name: string # short identifier (e.g., "go", "bun", "rust") +language: string # programming language +runtime: string # runtime + minimum version +framework: + name: string # TUI framework name + version: string # minimum version + paradigm: string # architectural pattern +``` + +### Required body sections + +1. **Architecture pattern** — how the framework structures components +2. **Type mapping** — table mapping spec types → language types +3. **Callback translation** — how spec callbacks become idiomatic (messages, actions, props) +4. **State machine translation** — how spec states map to the framework pattern +5. **Token access** — how components consume color/icon/breakpoint tokens +6. **Styling** — how to apply semantic colors and bold/italic to rendered text +7. **Composition** — how parent components embed children +8. **Test pattern** — how `.test.md` blocks become runnable tests +9. **Key mapping** — table mapping spec keyboard names to framework key events +10. **Dependencies** — required packages/modules + +### Demo CLI (required) + +Every target **must** include a demo CLI tool that renders all generated components +interactively. This is the primary visual verification mechanism. + +```yaml +demo: + entry: path/to/demo entrypoint + run_command: "command to start the demo" + description: > + Interactive preview of all components. Cycles through each component + with live keyboard interaction. +``` + +The demo must: + +- Render each component in isolation so visual output can be inspected +- Support keyboard interaction (navigate, select, escape) +- Use the generated token system (colors, icons) — not hardcoded values +- Be runnable with a single command from the target directory + +--- + +## Versioning and diff-based updates + +Each spec has a `version` field. When a spec changes: + +1. Bump `version` +2. The lockfile (per target language) records `{ spec_hash, generated_file_hashes }` +3. Only specs with changed hashes trigger regeneration +4. The agent receives: current generated code + spec diff → produces code patch +5. Tests run against patched code +6. If green, lockfile updates; if red, agent iterates + +--- + +## Naming conventions + +| Thing | Convention | Example | +| ----------- | ------------------ | -------------------------------- | +| Spec files | PascalCase.md | `Select.md`, `HintBar.md` | +| Test files | PascalCase.test.md | `Select.test.md` | +| Token files | lowercase.md | `colors.md`, `icons.md` | +| Props | camelCase | `onSelect`, `hideHints` | +| Tokens | camelCase | `textPrimary`, `iconPrompt` | +| States | lowercase | `idle`, `focused`, `selected` | +| Categories | lowercase | `input`, `display`, `navigation` | diff --git a/compile.ts b/compile.ts new file mode 100644 index 0000000..4533105 --- /dev/null +++ b/compile.ts @@ -0,0 +1,496 @@ +#!/usr/bin/env bun +/** + * TUIkit spec compiler + * + * Detects changed specs via content hashing and generates self-contained + * compilation prompts for LLM agents. Lock files track which spec versions + * have been compiled per target. + * + * Usage: + * bun compile.ts status [--target ] + * bun compile.ts prompt --target [--component ] + * bun compile.ts lock --target [--component ] + * bun compile.ts clean --target + */ + +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { dirname, join, relative } from "node:path"; + +// biome-ignore lint/suspicious/noConsole: CLI tool — stdout is the interface +const log = (...args: unknown[]) => console.log(...args); + +// ── Paths ────────────────────────────────────────────────────────────────── + +const SPECS_DIR = dirname(new URL(import.meta.url).pathname); +const TOKENS_DIR = join(SPECS_DIR, "tokens"); +const COMPONENTS_DIR = join(SPECS_DIR, "components"); +const TARGETS_DIR = join(SPECS_DIR, "targets"); +const SCHEMA_PATH = join(SPECS_DIR, "_schema.md"); +const DEMO_PATH = join(SPECS_DIR, "demo.md"); +const DEFAULT_DIST_DIR = join(SPECS_DIR, "dist"); + +// ── Types ────────────────────────────────────────────────────────────────── + +interface SpecEntry { + name: string; + kind: "token" | "component"; + specPath: string; + testPath?: string; + previewPath?: string; + specHash: string; + testHash?: string; + version: string; +} + +interface LockEntry { + version: string; + specHash: string; + testHash?: string; + lockedAt: string; +} + +interface LockFile { + target: string; + schemaHash: string; + updatedAt: string; + entries: Record; +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function sha256(content: string): string { + return createHash("sha256").update(content).digest("hex").slice(0, 16); +} + +function readFile(path: string): string { + return readFileSync(path, "utf-8"); +} + +function extractFrontmatter(content: string): Record { + const match = content.match(/^---\n([\s\S]*?)\n---/); + if (!match) return {}; + const fm: Record = {}; + for (const line of match[1].split("\n")) { + const sep = line.indexOf(":"); + if (sep > 0) { + fm[line.slice(0, sep).trim()] = line.slice(sep + 1).trim(); + } + } + return fm; +} + +function lockPath(target: string): string { + return join(TARGETS_DIR, `${target}.lock.json`); +} + +function readLock(target: string): LockFile | null { + const p = lockPath(target); + if (!existsSync(p)) return null; + return JSON.parse(readFile(p)); +} + +function writeLock(lock: LockFile): void { + writeFileSync(lockPath(lock.target), JSON.stringify(lock, null, 2) + "\n"); +} + +// ── Discovery ────────────────────────────────────────────────────────────── + +function discoverTargets(): string[] { + if (!existsSync(TARGETS_DIR)) return []; + return readdirSync(TARGETS_DIR) + .filter((f) => f.endsWith(".md")) + .map((f) => f.replace(".md", "")); +} + +function discoverSpecs(): SpecEntry[] { + const specs: SpecEntry[] = []; + + // Token specs + if (existsSync(TOKENS_DIR)) { + for (const file of readdirSync(TOKENS_DIR).filter((f) => f.endsWith(".md") && !f.includes(".preview."))) { + const specPath = join(TOKENS_DIR, file); + const content = readFile(specPath); + const fm = extractFrontmatter(content); + const previewPath = join(TOKENS_DIR, file.replace(".md", ".preview.md")); + specs.push({ + name: `tokens/${file.replace(".md", "")}`, + kind: "token", + specPath, + previewPath: existsSync(previewPath) ? previewPath : undefined, + specHash: sha256(content), + version: fm.version || "0", + }); + } + } + + // Component specs + if (existsSync(COMPONENTS_DIR)) { + for (const dir of readdirSync(COMPONENTS_DIR)) { + const dirPath = join(COMPONENTS_DIR, dir); + if (!statSync(dirPath).isDirectory()) continue; + + const specPath = join(dirPath, `${dir}.md`); + if (!existsSync(specPath)) continue; + + const content = readFile(specPath); + const fm = extractFrontmatter(content); + const testPath = join(dirPath, `${dir}.test.md`); + const hasTest = existsSync(testPath); + const previewPath = join(dirPath, `${dir}.preview.md`); + const hasPreview = existsSync(previewPath); + + specs.push({ + name: `components/${dir}`, + kind: "component", + specPath, + testPath: hasTest ? testPath : undefined, + previewPath: hasPreview ? previewPath : undefined, + specHash: sha256(content), + testHash: hasTest ? sha256(readFile(testPath)) : undefined, + version: fm.version || "0", + }); + } + } + + return specs; +} + +// ── Diff ─────────────────────────────────────────────────────────────────── + +interface DiffResult { + spec: SpecEntry; + reason: "new" | "spec-changed" | "test-changed" | "schema-changed"; +} + +function computeDirty(specs: SpecEntry[], lock: LockFile | null, schemaHash: string): DiffResult[] { + const dirty: DiffResult[] = []; + + for (const spec of specs) { + const entry = lock?.entries[spec.name]; + + if (!entry) { + dirty.push({ spec, reason: "new" }); + } else if (lock && lock.schemaHash !== schemaHash) { + dirty.push({ spec, reason: "schema-changed" }); + } else if (entry.specHash !== spec.specHash) { + dirty.push({ spec, reason: "spec-changed" }); + } else if (spec.testHash && entry.testHash !== spec.testHash) { + dirty.push({ spec, reason: "test-changed" }); + } + } + + return dirty; +} + +// ── Prompt generation ────────────────────────────────────────────────────── + +function generatePrompt(target: string, specs: SpecEntry[], allSpecs: SpecEntry[], distDir: string): string { + const schema = readFile(SCHEMA_PATH); + const targetSpec = readFile(join(TARGETS_DIR, `${target}.md`)); + + const tokenSpecs = allSpecs.filter((s) => s.kind === "token"); + const componentSpecs = specs.filter((s) => s.kind === "component"); + + const sections: string[] = []; + + sections.push(`# TUIkit compilation prompt — target: ${target}`); + sections.push(""); + sections.push("You are a TUIkit spec compiler. Generate idiomatic code for the target"); + sections.push("framework based ONLY on the specs below. Do not reference any TypeScript source."); + sections.push(""); + + sections.push("---"); + sections.push("## Meta-schema"); + sections.push(""); + sections.push(schema); + sections.push(""); + + sections.push("---"); + sections.push("## Target definition"); + sections.push(""); + sections.push(targetSpec); + sections.push(""); + + sections.push("---"); + sections.push("## Token specs"); + sections.push(""); + for (const token of tokenSpecs) { + sections.push(`### ${token.name}`); + sections.push(""); + sections.push(readFile(token.specPath)); + sections.push(""); + if (token.previewPath) { + sections.push(`### ${token.name} — preview`); + sections.push(""); + sections.push(readFile(token.previewPath)); + sections.push(""); + } + } + + if (componentSpecs.length > 0) { + sections.push("---"); + sections.push("## Component specs to compile"); + sections.push(""); + for (const comp of componentSpecs) { + sections.push(`### ${comp.name}`); + sections.push(""); + sections.push(readFile(comp.specPath)); + sections.push(""); + if (comp.testPath) { + sections.push(`### ${comp.name} — tests`); + sections.push(""); + sections.push(readFile(comp.testPath)); + sections.push(""); + } + if (comp.previewPath) { + sections.push(`### ${comp.name} — preview`); + sections.push(""); + sections.push(readFile(comp.previewPath)); + sections.push(""); + } + } + } + + sections.push("---"); + sections.push("## Instructions"); + sections.push(""); + sections.push("IMPORTANT: Do NOT spawn sub-agents or delegate to the task tool. Do ALL work yourself directly."); + sections.push(""); + sections.push("1. Read the target definition to understand the framework and paradigm."); + sections.push("2. Implement each component spec above in the target language/framework."); + sections.push("3. Implement each test spec as runnable tests in the target's test framework."); + sections.push("4. Use the token specs for all color/icon/breakpoint references."); + sections.push("5. Use the preview specs to build per-component demo screens in the demo app."); + sections.push(`6. Output all files to: \`${relative(SPECS_DIR, join(distDir, target))}/\``); + sections.push(` This is the dist directory — keep all generated code here, separate from specs.`); + sections.push(""); + + if (existsSync(DEMO_PATH)) { + sections.push("---"); + sections.push("## Demo specification"); + sections.push(""); + sections.push(readFile(DEMO_PATH)); + sections.push(""); + } + + sections.push("---"); + sections.push("## Verification (REQUIRED)"); + sections.push(""); + sections.push("After generating ALL files, you MUST:"); + sections.push(""); + sections.push("1. **Run tests**: Execute the target's test command and ensure ALL tests pass."); + sections.push(" Fix any failures before proceeding."); + sections.push("2. **Build the demo**: Compile/build the demo CLI and verify it starts without errors."); + sections.push(" For interpreted targets (Bun), verify the demo file has no syntax/import errors."); + sections.push("3. **Report**: State the final test count, pass/fail status, and demo build status."); + sections.push(""); + + return sections.join("\n"); +} + +// ── Commands ─────────────────────────────────────────────────────────────── + +function cmdStatus(targetFilter?: string): void { + const specs = discoverSpecs(); + const targets = targetFilter ? [targetFilter] : discoverTargets(); + const schemaHash = sha256(readFile(SCHEMA_PATH)); + + const tokenCount = specs.filter((s) => s.kind === "token").length; + const componentCount = specs.filter((s) => s.kind === "component").length; + log(`\n📦 TUIkit specs: ${specs.length} (${tokenCount} tokens, ${componentCount} components)`); + log(`📋 Schema hash: ${schemaHash}\n`); + + for (const target of targets) { + const lock = readLock(target); + const dirty = computeDirty(specs, lock, schemaHash); + const locked = lock ? Object.keys(lock.entries).length : 0; + + const icon = dirty.length === 0 ? "✅" : "🔶"; + log(`${icon} ${target}: ${dirty.length} dirty, ${locked} locked`); + + if (dirty.length > 0) { + for (const d of dirty) { + const tag = d.reason === "new" ? "NEW" : d.reason.toUpperCase(); + log(` ├─ ${d.spec.name} [${tag}]`); + } + } + + if (lock) { + log(` └─ last locked: ${lock.updatedAt}`); + } + log(""); + } +} + +function cmdPrompt(target: string, componentFilter?: string, distDir: string = DEFAULT_DIST_DIR): void { + const specs = discoverSpecs(); + const schemaHash = sha256(readFile(SCHEMA_PATH)); + const lock = readLock(target); + let dirty = computeDirty(specs, lock, schemaHash); + + if (componentFilter) { + dirty = dirty.filter( + (d) => d.spec.name === `components/${componentFilter}` || d.spec.name === `tokens/${componentFilter}`, + ); + } + + if (dirty.length === 0) { + log(`✅ No dirty specs for target "${target}".`); + return; + } + + const dirtySpecs = dirty.map((d) => d.spec); + const prompt = generatePrompt(target, dirtySpecs, specs, distDir); + + // Write prompt to dist directory + const outDir = join(distDir, target); + mkdirSync(outDir, { recursive: true }); + const outPath = join(outDir, "_compile-prompt.md"); + writeFileSync(outPath, prompt); + + log(`\n📝 Compilation prompt for "${target}" (${dirty.length} dirty specs):`); + log(` → ${relative(SPECS_DIR, outPath)}`); + log(""); + log("Dirty specs included:"); + for (const d of dirty) { + log(` ├─ ${d.spec.name} [${d.reason}]`); + } + log(""); + log("Feed this prompt to an LLM agent, then run:"); + log(` bun compile.ts lock --target ${target}`); + log(""); +} + +function cmdLock(target: string, componentFilter?: string): void { + const specs = discoverSpecs(); + const schemaHash = sha256(readFile(SCHEMA_PATH)); + const existing = readLock(target); + const now = new Date().toISOString(); + + const entries: Record = existing?.entries ?? {}; + + const toLock = componentFilter + ? specs.filter((s) => s.name === `components/${componentFilter}` || s.name === `tokens/${componentFilter}`) + : specs; + + for (const spec of toLock) { + entries[spec.name] = { + version: spec.version, + specHash: spec.specHash, + testHash: spec.testHash, + lockedAt: now, + }; + } + + const lock: LockFile = { + target, + schemaHash, + updatedAt: now, + entries, + }; + + writeLock(lock); + + log(`🔒 Locked ${toLock.length} specs for "${target}"`); + log(` → ${relative(SPECS_DIR, lockPath(target))}`); +} + +function cmdClean(target: string, distDir: string = DEFAULT_DIST_DIR): void { + const p = lockPath(target); + if (existsSync(p)) { + rmSync(p); + log(`🧹 Removed lock file for "${target}"`); + } else { + log(`No lock file found for "${target}"`); + } + + const promptPath = join(distDir, target, "_compile-prompt.md"); + if (existsSync(promptPath)) { + rmSync(promptPath); + log(`🧹 Removed compile prompt for "${target}"`); + } +} + +// ── CLI ──────────────────────────────────────────────────────────────────── + +function usage(): void { + log(` +TUIkit spec compiler — detect changes, generate prompts, track state. + +Commands: + status [--target ] Show dirty/clean status + prompt --target [--component ] Generate compilation prompt + lock --target [--component ] Snapshot spec hashes to lock file + clean --target Remove lock file + prompt + +Options: + --out Output directory for compiled code (default: specs/dist/) + +Examples: + bun compile.ts status + bun compile.ts prompt --target go + bun compile.ts prompt --target go --out ./my-tuikit + bun compile.ts prompt --target rust --component HintBar + bun compile.ts lock --target go + bun compile.ts clean --target bun +`); +} + +function parseArgs(argv: string[]): { command: string; target?: string; component?: string; out?: string } { + const command = argv[0] || "status"; + let target: string | undefined; + let component: string | undefined; + let out: string | undefined; + + for (let i = 1; i < argv.length; i++) { + if (argv[i] === "--target" && argv[i + 1]) { + target = argv[++i]; + } else if (argv[i] === "--component" && argv[i + 1]) { + component = argv[++i]; + } else if (argv[i] === "--out" && argv[i + 1]) { + out = argv[++i]; + } + } + + return { command, target, component, out }; +} + +const args = parseArgs(process.argv.slice(2)); +const distDir = args.out ? join(process.cwd(), args.out) : DEFAULT_DIST_DIR; + +switch (args.command) { + case "status": + cmdStatus(args.target); + break; + case "prompt": + if (!args.target) { + log("Error: --target is required for prompt command"); + process.exit(1); + } + cmdPrompt(args.target, args.component, distDir); + break; + case "lock": + if (!args.target) { + log("Error: --target is required for lock command"); + process.exit(1); + } + cmdLock(args.target, args.component); + break; + case "clean": + if (!args.target) { + log("Error: --target is required for clean command"); + process.exit(1); + } + cmdClean(args.target, distDir); + break; + case "help": + case "--help": + case "-h": + usage(); + break; + default: + log(`Unknown command: ${args.command}`); + usage(); + process.exit(1); +} diff --git a/components/Dialog/Dialog.md b/components/Dialog/Dialog.md new file mode 100644 index 0000000..5bf43fc --- /dev/null +++ b/components/Dialog/Dialog.md @@ -0,0 +1,191 @@ +--- +kind: component +name: Dialog +description: A bordered container with title, optional subtitle, content area, and footer. +version: 2 +category: layout + +tokens: + colors: [textPrimary, textSecondary, borderNeutral] + icons: [] + +props: + title: + type: string + required: true + description: Title displayed at the top of the dialog. + + subtitle: + type: string + required: false + description: Optional subtitle or description rendered below the title. + + children: + type: slot + required: true + description: Main content area of the dialog. + + footer: + type: slot + required: false + description: Optional footer content (e.g., HintBar, action buttons). + + width: + type: number | string + required: false + description: > + Width constraint. Accepts a fixed number or a string like "100%". + In border title placement mode, only numeric widths are honored; + string widths fall back to terminal width. + + padding: + type: number + required: false + default: 1 + description: Horizontal padding inside the dialog. + + showTitleDivider: + type: boolean + required: false + default: true + description: > + Whether to render a horizontal divider between the title section + and the content. Only applies when titlePlacement is "inside". + + titlePlacement: + type: string + required: false + default: "inside" + description: > + Where to render the title. + "inside" — title renders inside the box, below the top border. + "border" — title is embedded in the top border line (╭ Title ───╮). + +constants: + MIN_DIALOG_WIDTH: + value: 10 + description: > + Minimum width for the dialog to prevent broken borders on narrow + terminals. Applied in border title placement mode. + +accessibility: + role: dialog + properties: + aria-label: "Dialog: {title}" + aria-modal: "true" + announce: + on_mount: "Dialog: {title}. {subtitle}" + on_change: "Dialog content updated" + screen_reader_adaptations: + - when: screen reader detected + change: > + Bordered box-drawing chrome is removed. The dialog renders as a + flat vertical stack: bold title, subtitle (if present), children, + and footer — with no decorative borders or divider lines. + +dependencies: + tokens: + - name: textPrimary + kind: color + usage: "Title text (bold)" + required: true + - name: textSecondary + kind: color + usage: "Subtitle text" + required: false + - name: borderNeutral + kind: color + usage: "Border and divider lines" + required: true + components: [] +--- + +# Dialog + +A bordered dialog container with a title and optional footer. Use it for +centered modals, confirmation dialogs, and focused action panels that need +visual separation from surrounding content. + +## Visual rules + +- Outer border MUST use round box-drawing characters in `borderNeutral` color +- **Title** MUST be bold `textPrimary` +- **Subtitle** MUST use `textSecondary` +- Content MUST inherit default text styling +- A horizontal **divider** in `borderNeutral` MUST separate the title section + from the content (inside placement only, controlled by `showTitleDivider`) +- **Footer** MUST be separated from content by vertical padding equal to `padding` +- All internal sections MUST have horizontal padding equal to `padding` + +### Border title placement + +When `titlePlacement` is `"border"`: + +- The top border line MUST embed the title: `╭ Title ────────╮` +- Title text MUST be bold `textPrimary`; border characters MUST be `borderNeutral` +- If the title exceeds the available inner width, it MUST be truncated with an + ellipsis (`…`) +- The remaining box MUST have side and bottom borders only (no top border) +- Width MUST resolve to the numeric `width` prop or fall back to terminal width +- Width MUST be clamped to a minimum of `MIN_DIALOG_WIDTH` (10) + +## Rendering example + +### Inside placement (default) + +``` +╭──────────────────────────────╮ +│ Confirm Action │ +│ This cannot be undone │ +│──────────────────────────────│ +│ The file will be deleted. │ +│ │ +│ Enter to confirm · Esc cancel│ +╰──────────────────────────────╯ + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + title: bold/textPrimary + subtitle: textSecondary + divider: borderNeutral + border: round/borderNeutral +``` + +### Border placement + +``` +╭ Session Info ────────────────╮ +│ Model: GPT-4 │ +╰──────────────────────────────╯ + ^^^^^^^^^^^^^ + title embedded in top border: bold/textPrimary +``` + +## Screen reader mode + +When a screen reader is detected, all box-drawing chrome is removed: + +``` +Confirm Action +This cannot be undone +The file will be deleted. +Enter to confirm · Esc cancel +``` + +Title is rendered bold; subtitle, content, and footer follow in a flat stack. + +## Dependencies + +| Dependency | Kind | Usage | Required | +| --------------- | ----- | ------------------------ | -------- | +| `textPrimary` | color | Title text (bold) | Yes | +| `textSecondary` | color | Subtitle text | No | +| `borderNeutral` | color | Border and divider lines | Yes | + +## Edge cases + +- When `titlePlacement` is `"border"` and `width` is a string (e.g., `"100%"`), + the width MUST fall back to the terminal column count (default 80) +- Title truncation in border mode MUST preserve the ellipsis character within the + available inner width +- An absent `footer` MUST produce no extra spacing at the bottom +- An absent `subtitle` MUST produce no extra line between title and content/divider +- `showTitleDivider` MUST have no effect in border placement mode diff --git a/components/Dialog/Dialog.preview.md b/components/Dialog/Dialog.preview.md new file mode 100644 index 0000000..eb6cf37 --- /dev/null +++ b/components/Dialog/Dialog.preview.md @@ -0,0 +1,45 @@ +--- +kind: preview +component: Dialog +version: 1 +--- + +## Basic + +```props +title: "Notice" +children: "This is a simple dialog." +``` + +## With subtitle + +```props +title: "Confirm Delete" +subtitle: "This action cannot be undone" +children: "Are you sure?" +``` + +## Fixed width + +```props +title: "Narrow Dialog" +width: 40 +children: "Content constrained to 40 columns." +``` + +## Border title + +```props +title: "Session Info" +titlePlacement: "border" +children: "Model: GPT-4 · Tokens: 1,234" +``` + +## Full variant + +```props +title: "Permissions" +titlePlacement: "border" +subtitle: "Required for this workspace" +children: "Allow access to this folder?" +``` diff --git a/components/Dialog/Dialog.test.md b/components/Dialog/Dialog.test.md new file mode 100644 index 0000000..548ccb4 --- /dev/null +++ b/components/Dialog/Dialog.test.md @@ -0,0 +1,355 @@ +--- +kind: test +component: Dialog +version: 1 +--- + +# Dialog Tests — Inside Placement (default) + +## renders title and content with border + +```props +title: "Confirm" +children: "Are you sure?" +``` + +```expect +╭──────────────╮ +│ Confirm │ +│──────────────│ +│ Are you sure?│ +╰──────────────╯ +``` + +## renders title bold in textPrimary + +```props +title: "Title" +children: "Body" +``` + +```style +- selector: label("Title") + bold: true + color: textPrimary +``` + +## renders subtitle in textSecondary + +```props +title: "Delete" +subtitle: "This cannot be undone" +children: "Proceed?" +``` + +```style +- selector: label("This cannot be undone") + bold: false + color: textSecondary +``` + +## renders border in borderNeutral + +```props +title: "Info" +children: "Details here" +``` + +```style +- selector: component("Border") + color: borderNeutral +``` + +## renders footer section + +```props +title: "Action" +children: "Content" +footer: "Enter to confirm · Esc to cancel" +``` + +```expect +╭────────────────────────────────────╮ +│ Action │ +│────────────────────────────────────│ +│ Content │ +│ │ +│ Enter to confirm · Esc to cancel │ +╰────────────────────────────────────╯ +``` + +## hides divider when showTitleDivider is false + +```props +title: "No Divider" +children: "Content" +showTitleDivider: false +``` + +```expect +╭──────────────╮ +│ No Divider │ +│ Content │ +╰──────────────╯ +``` + +## omits subtitle when not provided + +```props +title: "Simple" +children: "Just content" +``` + +```expect +╭──────────────╮ +│ Simple │ +│──────────────│ +│ Just content │ +╰──────────────╯ +``` + +## omits footer when not provided + +```props +title: "Minimal" +children: "Body only" +``` + +```expect +╭─────────────╮ +│ Minimal │ +│─────────────│ +│ Body only │ +╰─────────────╯ +``` + +--- + +# Dialog Tests — Border Placement + +## renders title embedded in top border + +```props +title: "Session Info" +titlePlacement: "border" +width: 30 +children: "Model: GPT-4" +``` + +```expect +╭ Session Info ───────────────╮ +│ Model: GPT-4 │ +╰─────────────────────────────╯ +``` + +## title in border is bold textPrimary + +```props +title: "Info" +titlePlacement: "border" +width: 30 +children: "Content" +``` + +```style +- selector: label("Info") + bold: true + color: textPrimary +``` + +## truncates long title with ellipsis in border mode + +```props +title: "This Is An Extremely Long Title That Exceeds Width" +titlePlacement: "border" +width: 20 +children: "Content" +``` + +```expect +╭ This Is An Extr…╮ +│ Content │ +╰──────────────────╯ +``` + +## clamps width to minimum of 10 + +```props +title: "Tiny" +titlePlacement: "border" +width: 5 +children: "X" +``` + +```expect +╭ Tiny ──╮ +│ X │ +╰────────╯ +``` + +## falls back to terminal width for string width in border mode + +```props +title: "Wide" +titlePlacement: "border" +width: "100%" +children: "Full width" +``` + +```style +- selector: component("Border") + color: borderNeutral +``` + +## renders subtitle in border placement + +```props +title: "Dialog" +subtitle: "A description" +titlePlacement: "border" +width: 30 +children: "Body" +``` + +```expect +╭ Dialog ─────────────────────╮ +│ A description │ +│ Body │ +╰─────────────────────────────╯ +``` + +## renders footer in border placement + +```props +title: "Act" +titlePlacement: "border" +width: 40 +children: "Content" +footer: "Esc to close" +``` + +```expect +╭ Act ──────────────────────────────────╮ +│ Content │ +│ │ +│ Esc to close │ +╰───────────────────────────────────────╯ +``` + +--- + +# Dialog Tests — Accessibility + +## screen reader mode renders flat layout without borders + +```props +title: "Confirm" +subtitle: "Important" +children: "File will be deleted." +footer: "Press Enter" +``` + +```accessibility +screen_reader: true +``` + +```expect +Confirm +Important +File will be deleted. +Press Enter +``` + +## screen reader mode renders title bold + +```props +title: "Alert" +children: "Message" +``` + +```accessibility +screen_reader: true +``` + +```style +- selector: label("Alert") + bold: true +``` + +## screen reader mode omits absent subtitle + +```props +title: "Simple" +children: "Content" +``` + +```accessibility +screen_reader: true +``` + +```expect +Simple +Content +``` + +## screen reader mode omits absent footer + +```props +title: "Minimal" +children: "Body" +``` + +```accessibility +screen_reader: true +``` + +```expect +Minimal +Body +``` + +--- + +# Dialog Tests — Edge Cases + +## renders with custom padding + +```props +title: "Padded" +children: "Content" +padding: 2 +``` + +```style +- selector: component("Dialog") + paddingX: 2 +``` + +## renders with zero padding + +```props +title: "Tight" +children: "Content" +padding: 0 +``` + +```style +- selector: component("Dialog") + paddingX: 0 +``` + +## showTitleDivider has no effect in border placement + +```props +title: "Border" +titlePlacement: "border" +showTitleDivider: false +width: 30 +children: "Content" +``` + +```expect +╭ Border ─────────────────────╮ +│ Content │ +╰─────────────────────────────╯ +``` diff --git a/components/HintBar/HintBar.md b/components/HintBar/HintBar.md new file mode 100644 index 0000000..c6911af --- /dev/null +++ b/components/HintBar/HintBar.md @@ -0,0 +1,130 @@ +--- +kind: component +name: HintBar +description: Renders a row of keyboard shortcut hints with consistent styling. +version: 2 +category: navigation + +tokens: + colors: [textPrimary, textSecondary] + icons: [] + +props: + hints: + type: record + required: true + description: > + Key-value pairs where key is the keyboard shortcut and value is the + action description. Falsy values (false, null, undefined) are filtered + out, enabling conditional hints. + + separator: + type: string + required: false + default: " · " + description: Visual separator rendered between hint groups. + +key_display_map: + description: > + Before rendering, shortcut keys are transformed through a display map. + This normalizes raw key names to their visual representation. + mappings: + up: "↑" + down: "↓" + left: "←" + right: "→" + up-down: "↑↓" + left-right: "←→" + esc: "Esc" + enter: "Enter" + tab: "Tab" + shift+tab: "Shift+Tab" + fallback: Key string is rendered as-is (e.g., "s" stays "s", "q" stays "q") + matching: case-insensitive + +layout: + direction: horizontal (inline) + structure: "[key] [label] [separator] [key] [label] [separator] ..." + wrapping: none (single line) + +accessibility: + role: status + properties: + aria-label: "Keyboard shortcuts" + announce: + on_mount: "Keyboard shortcuts: {hint_list}" + screen_reader_adaptations: + - when: screen reader detected + change: "Bold key formatting is removed; hints render as plain text pairs" + +dependencies: + tokens: + - name: textPrimary + kind: color + usage: "Key label text (bold)" + required: true + - name: textSecondary + kind: color + usage: "Action label and separator text" + required: true + components: [] +--- + +# HintBar + +A horizontal row of keyboard shortcut hints. Each hint shows a **bold key** followed +by its action description, separated by a configurable delimiter. + +## Visual rules + +- The entire bar MUST use `textSecondary` as the base color +- Each **key** MUST be rendered bold in `textPrimary` +- Each **label** (action text) MUST be rendered in `textSecondary` +- The **separator** MUST be rendered in `textSecondary` +- A single space MUST separate the key from its label +- Falsy hint values MUST be silently excluded (no empty gaps) + +## Rendering example + +Given: + +``` +hints: { "up-down": "to navigate", "enter": "to select", "esc": "to cancel" } +``` + +Output: + +``` +↑↓ to navigate · Enter to select · Esc to cancel +^^ ^^^^^ ^^^ +bold/textPrimary bold/textPrimary bold/textPrimary + ^^^^^^^^^^^ ^^^^^^^^^ ^^^^^^^^^ + textSecondary textSecondary textSecondary +``` + +## Conditional hints + +Falsy values enable conditional rendering without external logic: + +``` +hints: { + "up-down": "to select", + "s": hasComments && "to show (3)", + "esc": "to close" +} +``` + +When `hasComments` is false, the "s" hint is excluded entirely. + +## Exported utilities + +- `formatKey(key: string) → string`: applies the key display map to a raw key name. + This utility SHOULD be available independently of the component for use in + other contexts that need consistent key formatting. + +## Dependencies + +| Dependency | Kind | Usage | Required | +| --------------- | ----- | ------------------------------- | -------- | +| `textPrimary` | color | Key label text (bold) | Yes | +| `textSecondary` | color | Action label and separator text | Yes | diff --git a/components/HintBar/HintBar.preview.md b/components/HintBar/HintBar.preview.md new file mode 100644 index 0000000..8b62180 --- /dev/null +++ b/components/HintBar/HintBar.preview.md @@ -0,0 +1,44 @@ +--- +kind: preview +component: HintBar +version: 1 +--- + +## Default + +```props +hints: + up-down: "to navigate" + enter: "to select" + esc: "to cancel" +``` + +## Custom keys + +```props +hints: + tab: "next file" + shift-tab: "previous file" + s: "to save" + esc: "to close" +``` + +## Conditional + +```props +hints: + up-down: "to navigate" + enter: "to select" + s: false + esc: "to cancel" +``` + +## Custom separator + +```props +hints: + a: "one" + b: "two" + c: "three" +separator: " | " +``` diff --git a/components/HintBar/HintBar.test.md b/components/HintBar/HintBar.test.md new file mode 100644 index 0000000..a702afe --- /dev/null +++ b/components/HintBar/HintBar.test.md @@ -0,0 +1,195 @@ +--- +kind: test +component: HintBar +version: 1 +--- + +# HintBar Tests + +## renders basic hints with separator + +```props +hints: { "esc": "to cancel", "enter": "to select" } +``` + +```expect +Esc to cancel · Enter to select +``` + +## transforms arrow keys to unicode symbols + +```props +hints: { "up-down": "to navigate" } +``` + +```expect +↑↓ to navigate +``` + +## uses custom separator + +```props +hints: { "q": "quit", "h": "help" } +separator: " | " +``` + +```expect +q quit | h help +``` + +## filters out falsy values + +```props +hints: { "esc": "to close", "s": false, "enter": "to confirm" } +``` + +```expect +Esc to close · Enter to confirm +``` + +## filters out null values + +```props +hints: { "a": "action", "b": null, "c": "cancel" } +``` + +```expect +a action · c cancel +``` + +## renders single hint without separator + +```props +hints: { "enter": "to continue" } +``` + +```expect +Enter to continue +``` + +## renders empty for all-falsy hints + +```props +hints: { "a": false, "b": null } +``` + +```expect + +``` + +## key display is case-insensitive + +```props +hints: { "ESC": "to quit", "ENTER": "to go" } +``` + +```expect +Esc to quit · Enter to go +``` + +## preserves unknown keys as-is + +```props +hints: { "q": "to quit", "ctrl+c": "to abort" } +``` + +```expect +q to quit · ctrl+c to abort +``` + +## maps all special keys correctly + +```props +hints: { "up": "up", "down": "down", "left": "left", "right": "right", "tab": "next", "shift+tab": "prev" } +``` + +```expect +↑ up · ↓ down · ← left · → right · Tab next · Shift+Tab prev +``` + +--- + +# formatKey utility tests + +## formatKey transforms known keys + +```formatKey +input: "esc" +expect: "Esc" +``` + +```formatKey +input: "enter" +expect: "Enter" +``` + +```formatKey +input: "up-down" +expect: "↑↓" +``` + +```formatKey +input: "left-right" +expect: "←→" +``` + +## formatKey preserves unknown keys + +```formatKey +input: "q" +expect: "q" +``` + +```formatKey +input: "ctrl+c" +expect: "ctrl+c" +``` + +## formatKey is case-insensitive + +```formatKey +input: "ESC" +expect: "Esc" +``` + +--- + +# Style assertions + +These verify color and weight usage. The test harness should validate +that rendered output uses the correct semantic tokens. + +## keys are bold with textPrimary color + +```props +hints: { "esc": "cancel" } +``` + +```style +- selector: key("esc") + bold: true + color: textPrimary +``` + +## labels use textSecondary color + +```props +hints: { "esc": "cancel" } +``` + +```style +- selector: label("cancel") + bold: false + color: textSecondary +``` + +## separator uses textSecondary color + +```props +hints: { "a": "one", "b": "two" } +``` + +```style +- selector: separator + color: textSecondary +``` diff --git a/components/Icons/Icons.md b/components/Icons/Icons.md new file mode 100644 index 0000000..c59e28c --- /dev/null +++ b/components/Icons/Icons.md @@ -0,0 +1,432 @@ +--- +kind: component +name: Icons +description: Factory system for creating icon components with consistent accessibility and optional semantic coloring. +version: 2 +category: display + +tokens: + colors: [statusSuccess, statusError, statusWarning, statusInfo, textSecondary, selected, brand, textTertiary] + icons: + [ + CHECK, + CROSS, + WARNING, + CIRCLE_FILLED, + CIRCLE_HALF, + CIRCLE_EMPTY, + DISABLED, + CHEVRON_RIGHT, + ARROW_RIGHT, + ARROW_LEFT, + ARROW_UP, + ARROW_DOWN, + SCROLLBAR, + CHECKBOX_CHECKED, + CHECKBOX_UNCHECKED, + DOT_SEPARATOR, + BULLET, + CHILD_LAST, + CHILD_MIDDLE, + CHILD_SKIP, + ] + +factories: + createIcon: + description: > + Creates a basic icon component that renders a Unicode glyph with optional + explicit color and accessibility attributes. + parameters: + glyph: + type: string + description: The Unicode character to render + defaultLabel: + type: string + description: > + Default aria-label for screen readers. When empty string, + the icon is decorative by default. + returns: + type: IconComponent + props: IconProps + + createColoredIcon: + description: > + Creates a semantic icon component that supports automatic coloring via + the `colored` prop. When `colored` is true, the icon automatically applies + the mapped semantic color from the color token system. Otherwise behaves + like a basic icon with manual `color` prop. + parameters: + glyph: + type: string + description: The Unicode character to render + defaultLabel: + type: string + description: Default aria-label for screen readers + semanticColorKey: + type: string + description: > + Key in the semantic color token map to use when colored=true + (e.g., "statusSuccess", "statusError") + returns: + type: SemanticIconComponent + props: SemanticIconProps + +props: + label: + type: string + required: false + description: > + Override the default aria-label. Useful when the same icon glyph + is reused in different semantic contexts. + + decorative: + type: boolean + required: false + description: > + If true, the icon is hidden from screen readers (aria-hidden="true") + and no aria-label is set. Defaults to true when the effective label + is an empty string. + + color: + type: SemanticColor + required: false + description: > + Explicit color override. Must be a semantic color token value, not a + raw color string. Available on both IconProps and SemanticIconProps + (mutually exclusive with colored on SemanticIconProps). + + colored: + type: boolean + required: false + description: > + SemanticIconProps only. When true, automatically applies the semantic + color mapped at factory creation time. Cannot be used together with + the color prop. + +instances: + semantic_icons: + description: Icons created with createColoredIcon — support the colored prop + items: + IconSuccess: + glyph: CHECK (✓) + defaultLabel: "Success" + semanticColorKey: statusSuccess + IconError: + glyph: CROSS (✗) + defaultLabel: "Error" + semanticColorKey: statusError + IconWarning: + glyph: WARNING (!) + defaultLabel: "Warning" + semanticColorKey: statusWarning + IconInfoCompleted: + glyph: CIRCLE_FILLED (●) + defaultLabel: "Completed" + semanticColorKey: statusInfo + IconDisabled: + glyph: DISABLED (⊘) + defaultLabel: "Disabled" + semanticColorKey: textSecondary + + standard_icons: + description: Icons created with createIcon — use explicit color prop only + items: + IconPrompt: + glyph: CHEVRON_RIGHT (❯) + defaultLabel: "Prompt" + IconInfoWorking: + glyph: CIRCLE_HALF (◐) + defaultLabel: "In progress" + IconInfoEmpty: + glyph: CIRCLE_EMPTY (○) + defaultLabel: "Empty" + IconArrowRight: + glyph: ARROW_RIGHT (→) + defaultLabel: "Keyboard Right" + IconArrowLeft: + glyph: ARROW_LEFT (←) + defaultLabel: "Keyboard Left" + IconArrowUp: + glyph: ARROW_UP (↑) + defaultLabel: "Keyboard Up" + IconArrowDown: + glyph: ARROW_DOWN (↓) + defaultLabel: "Keyboard Down" + IconScrollbar: + glyph: SCROLLBAR (▋) + defaultLabel: "" + note: Decorative by default (empty label) + IconCheckboxChecked: + glyph: CHECKBOX_CHECKED ([✓]) + defaultLabel: "Checked" + IconCheckboxUnchecked: + glyph: CHECKBOX_UNCHECKED ([ ]) + defaultLabel: "Unchecked" + IconSeparatorWord: + glyph: DOT_SEPARATOR (·) + defaultLabel: "" + note: Decorative by default (empty label) + IconSeparatorList: + glyph: BULLET (•) + defaultLabel: "" + note: Decorative by default (empty label) + IconNestingLast: + glyph: CHILD_LAST (└) + defaultLabel: "" + note: Decorative by default (empty label) + IconNestingMiddle: + glyph: CHILD_MIDDLE (├) + defaultLabel: "" + note: Decorative by default (empty label) + IconNestingSkip: + glyph: CHILD_SKIP (│) + defaultLabel: "" + note: Decorative by default (empty label) + +accessibility: + role: img + properties: + aria-label: "{defaultLabel} (overridden by label prop)" + aria-hidden: "true when decorative" + announce: + on_mount: "{label} (when not decorative)" + screen_reader_adaptations: + - when: screen reader detected + change: 'Icons with non-empty labels MUST be announced by their aria-label. Icons with empty labels (or decorative=true) MUST be hidden via aria-hidden="true".' + +dependencies: + tokens: + - name: statusSuccess + kind: color + usage: "IconSuccess semantic color" + required: true + - name: statusError + kind: color + usage: "IconError semantic color" + required: true + - name: statusWarning + kind: color + usage: "IconWarning semantic color" + required: true + - name: statusInfo + kind: color + usage: "IconInfoCompleted semantic color" + required: true + - name: textSecondary + kind: color + usage: "IconDisabled semantic color" + required: true + - name: selected + kind: color + usage: "Selection indicator color" + required: false + - name: brand + kind: color + usage: "Brand-colored icon variant" + required: false + - name: textTertiary + kind: color + usage: "Tertiary text color for icons" + required: false + - name: CHECK + kind: icon + usage: "Success glyph (✓)" + required: true + - name: CROSS + kind: icon + usage: "Error glyph (✗)" + required: true + - name: WARNING + kind: icon + usage: "Warning glyph (!)" + required: true + - name: CIRCLE_FILLED + kind: icon + usage: "Completed state glyph (●)" + required: true + - name: CIRCLE_HALF + kind: icon + usage: "In-progress state glyph (◐)" + required: true + - name: CIRCLE_EMPTY + kind: icon + usage: "Empty state glyph (○)" + required: true + - name: DISABLED + kind: icon + usage: "Disabled state glyph (⊘)" + required: true + - name: CHEVRON_RIGHT + kind: icon + usage: "Prompt indicator glyph (❯)" + required: true + - name: ARROW_RIGHT + kind: icon + usage: "Right arrow glyph (→)" + required: true + - name: ARROW_LEFT + kind: icon + usage: "Left arrow glyph (←)" + required: true + - name: ARROW_UP + kind: icon + usage: "Up arrow glyph (↑)" + required: true + - name: ARROW_DOWN + kind: icon + usage: "Down arrow glyph (↓)" + required: true + - name: SCROLLBAR + kind: icon + usage: "Scrollbar indicator glyph (▋)" + required: true + - name: CHECKBOX_CHECKED + kind: icon + usage: "Checked checkbox glyph ([✓])" + required: true + - name: CHECKBOX_UNCHECKED + kind: icon + usage: "Unchecked checkbox glyph ([ ])" + required: true + - name: DOT_SEPARATOR + kind: icon + usage: "Word separator glyph (·)" + required: true + - name: BULLET + kind: icon + usage: "List separator glyph (•)" + required: true + - name: CHILD_LAST + kind: icon + usage: "Last nesting connector (└)" + required: true + - name: CHILD_MIDDLE + kind: icon + usage: "Middle nesting connector (├)" + required: true + - name: CHILD_SKIP + kind: icon + usage: "Skip nesting connector (│)" + required: true + components: [] +--- + +# Icons + +A factory-based icon system that produces consistent, accessible icon components. +Two factory functions create all icon instances: + +- **`createIcon`** — produces basic icons with optional explicit `color` prop +- **`createColoredIcon`** — produces semantic icons that support `colored` prop + for automatic semantic coloring, plus explicit `color` as a fallback + +## Visual rules + +- Each icon MUST render a single Unicode glyph from the icon token system +- Color MUST be applied to the glyph text only (no background) +- When no color is provided, the icon MUST inherit the terminal foreground color +- Semantic icons with `colored=true` MUST use their mapped color token automatically +- The `color` and `colored` props MUST NOT be used together on semantic icons + +## Accessibility rules + +- Every icon MUST have a **default aria-label** set at factory creation time +- The `label` prop MUST override the default aria-label +- When the effective label is an empty string, the icon MUST be **decorative by default**: + `aria-hidden="true"` MUST be set and `aria-label` MUST NOT be set +- The `decorative` prop MUST explicitly override the auto-detection: + - `decorative=true` → MUST be aria-hidden, no label + - `decorative=false` → MUST be announced, even if label is empty + +## Factory pattern + +### createIcon(glyph, defaultLabel) + +Returns a component that renders `glyph` with: + +- Optional `color` (explicit SemanticColor) +- Optional `label` (overrides defaultLabel) +- Optional `decorative` (overrides auto-detection) + +### createColoredIcon(glyph, defaultLabel, semanticColorKey) + +Returns a component with all `createIcon` capabilities plus: + +- `colored=true` → automatically applies `colors[semanticColorKey]` +- `colored=false` (or omitted) + `color` → uses explicit color + +The discriminated union ensures `colored` and `color` cannot both be provided. + +## Rendering example + +``` +✓ Success ← IconSuccess with colored=true → statusSuccess color +✗ Error ← IconError with colored=true → statusError color +❯ ← IconPrompt with color=selected → selected color +○ ← IconInfoEmpty (no color) → terminal foreground +``` + +## Pre-built instance reference + +### Semantic icons (support colored prop) + +| Instance | Glyph | Label | Semantic Color | +| ----------------- | ----- | --------- | -------------- | +| IconSuccess | ✓ | Success | statusSuccess | +| IconError | ✗ | Error | statusError | +| IconWarning | ! | Warning | statusWarning | +| IconInfoCompleted | ● | Completed | statusInfo | +| IconDisabled | ⊘ | Disabled | textSecondary | + +### Standard icons (explicit color only) + +| Instance | Glyph | Label | Decorative? | +| --------------------- | ----- | -------------- | ----------- | +| IconPrompt | ❯ | Prompt | No | +| IconInfoWorking | ◐ | In progress | No | +| IconInfoEmpty | ○ | Empty | No | +| IconArrowRight | → | Keyboard Right | No | +| IconArrowLeft | ← | Keyboard Left | No | +| IconArrowUp | ↑ | Keyboard Up | No | +| IconArrowDown | ↓ | Keyboard Down | No | +| IconScrollbar | ▋ | (none) | Yes | +| IconCheckboxChecked | [✓] | Checked | No | +| IconCheckboxUnchecked | [ ] | Unchecked | No | +| IconSeparatorWord | · | (none) | Yes | +| IconSeparatorList | • | (none) | Yes | +| IconNestingLast | └ | (none) | Yes | +| IconNestingMiddle | ├ | (none) | Yes | +| IconNestingSkip | │ | (none) | Yes | + +## Dependencies + +| Dependency | Kind | Usage | Required | +| -------------------- | ----- | -------------------------------- | -------- | +| `statusSuccess` | color | IconSuccess semantic color | Yes | +| `statusError` | color | IconError semantic color | Yes | +| `statusWarning` | color | IconWarning semantic color | Yes | +| `statusInfo` | color | IconInfoCompleted semantic color | Yes | +| `textSecondary` | color | IconDisabled semantic color | Yes | +| `selected` | color | Selection indicator color | No | +| `brand` | color | Brand-colored icon variant | No | +| `textTertiary` | color | Tertiary text color for icons | No | +| `CHECK` | icon | Success glyph (✓) | Yes | +| `CROSS` | icon | Error glyph (✗) | Yes | +| `WARNING` | icon | Warning glyph (!) | Yes | +| `CIRCLE_FILLED` | icon | Completed state glyph (●) | Yes | +| `CIRCLE_HALF` | icon | In-progress state glyph (◐) | Yes | +| `CIRCLE_EMPTY` | icon | Empty state glyph (○) | Yes | +| `DISABLED` | icon | Disabled state glyph (⊘) | Yes | +| `CHEVRON_RIGHT` | icon | Prompt indicator glyph (❯) | Yes | +| `ARROW_RIGHT` | icon | Right arrow glyph (→) | Yes | +| `ARROW_LEFT` | icon | Left arrow glyph (←) | Yes | +| `ARROW_UP` | icon | Up arrow glyph (↑) | Yes | +| `ARROW_DOWN` | icon | Down arrow glyph (↓) | Yes | +| `SCROLLBAR` | icon | Scrollbar indicator glyph (▋) | Yes | +| `CHECKBOX_CHECKED` | icon | Checked checkbox glyph ([✓]) | Yes | +| `CHECKBOX_UNCHECKED` | icon | Unchecked checkbox glyph ([ ]) | Yes | +| `DOT_SEPARATOR` | icon | Word separator glyph (·) | Yes | +| `BULLET` | icon | List separator glyph (•) | Yes | +| `CHILD_LAST` | icon | Last nesting connector (└) | Yes | +| `CHILD_MIDDLE` | icon | Middle nesting connector (├) | Yes | +| `CHILD_SKIP` | icon | Skip nesting connector (│) | Yes | diff --git a/components/Icons/Icons.preview.md b/components/Icons/Icons.preview.md new file mode 100644 index 0000000..de3d3fa --- /dev/null +++ b/components/Icons/Icons.preview.md @@ -0,0 +1,11 @@ +--- +kind: preview +component: Icons +version: 1 +--- + +## All icons + +```props +icons: [iconSuccess, iconError, iconWarning, iconPrompt, iconInfoCompleted, iconInfoWorking, iconInfoEmpty, iconArrowUp, iconArrowDown, iconArrowLeft, iconArrowRight, iconCheckboxChecked, iconCheckboxUnchecked, iconScrollbar, iconSeparatorWord, iconSeparatorList, iconNestingLast, iconNestingMiddle, iconNestingSkip] +``` diff --git a/components/Icons/Icons.test.md b/components/Icons/Icons.test.md new file mode 100644 index 0000000..1849edf --- /dev/null +++ b/components/Icons/Icons.test.md @@ -0,0 +1,608 @@ +--- +kind: test +component: Icons +version: 1 +--- + +# createIcon factory tests + +## renders glyph with default label + +```props +factory: createIcon +glyph: "❯" +defaultLabel: "Prompt" +``` + +```expect +❯ +``` + +```accessibility +announce: "Prompt" +aria-hidden: false +``` + +## renders glyph with overridden label + +```props +factory: createIcon +glyph: "❯" +defaultLabel: "Prompt" +instanceProps: + label: "Active selection" +``` + +```expect +❯ +``` + +```accessibility +announce: "Active selection" +``` + +## decorative by default when label is empty + +```props +factory: createIcon +glyph: "▋" +defaultLabel: "" +``` + +```expect +▋ +``` + +```accessibility +aria-hidden: true +announce: null +``` + +## explicit decorative overrides non-empty label + +```props +factory: createIcon +glyph: "❯" +defaultLabel: "Prompt" +instanceProps: + decorative: true +``` + +```accessibility +aria-hidden: true +announce: null +``` + +## applies explicit color prop + +```props +factory: createIcon +glyph: "❯" +defaultLabel: "Prompt" +instanceProps: + color: selected +``` + +```style +- selector: label("❯") + color: selected +``` + +## no color — inherits terminal foreground + +```props +factory: createIcon +glyph: "○" +defaultLabel: "Empty" +``` + +```style +- selector: label("○") + color: null + description: No color is set; the glyph inherits the terminal default foreground. +``` + +--- + +# createColoredIcon factory tests + +## colored=true applies semantic color automatically + +```props +factory: createColoredIcon +glyph: "✓" +defaultLabel: "Success" +semanticColorKey: statusSuccess +instanceProps: + colored: true +``` + +```expect +✓ +``` + +```style +- selector: label("✓") + color: statusSuccess +``` + +## colored=false with explicit color + +```props +factory: createColoredIcon +glyph: "✓" +defaultLabel: "Success" +semanticColorKey: statusSuccess +instanceProps: + colored: false + color: brand +``` + +```style +- selector: label("✓") + color: brand +``` + +## colored omitted with explicit color + +```props +factory: createColoredIcon +glyph: "✗" +defaultLabel: "Error" +semanticColorKey: statusError +instanceProps: + color: textSecondary +``` + +```style +- selector: label("✗") + color: textSecondary +``` + +## no color props — inherits terminal foreground + +```props +factory: createColoredIcon +glyph: "!" +defaultLabel: "Warning" +semanticColorKey: statusWarning +``` + +```style +- selector: label("!") + color: null + description: Neither colored nor color is set; inherits terminal foreground. +``` + +## colored icon respects label override + +```props +factory: createColoredIcon +glyph: "✓" +defaultLabel: "Success" +semanticColorKey: statusSuccess +instanceProps: + colored: true + label: "Task complete" +``` + +```accessibility +announce: "Task complete" +aria-hidden: false +``` + +## colored icon respects decorative override + +```props +factory: createColoredIcon +glyph: "✓" +defaultLabel: "Success" +semanticColorKey: statusSuccess +instanceProps: + colored: true + decorative: true +``` + +```accessibility +aria-hidden: true +announce: null +``` + +--- + +# Pre-built semantic icon instances + +## IconSuccess renders check with success color + +```props +instance: IconSuccess +instanceProps: + colored: true +``` + +```expect +✓ +``` + +```style +- selector: label("✓") + color: statusSuccess +``` + +```accessibility +announce: "Success" +``` + +## IconError renders cross with error color + +```props +instance: IconError +instanceProps: + colored: true +``` + +```expect +✗ +``` + +```style +- selector: label("✗") + color: statusError +``` + +```accessibility +announce: "Error" +``` + +## IconWarning renders bang with warning color + +```props +instance: IconWarning +instanceProps: + colored: true +``` + +```expect +! +``` + +```style +- selector: label("!") + color: statusWarning +``` + +```accessibility +announce: "Warning" +``` + +## IconInfoCompleted renders filled circle with info color + +```props +instance: IconInfoCompleted +instanceProps: + colored: true +``` + +```expect +● +``` + +```style +- selector: label("●") + color: statusInfo +``` + +```accessibility +announce: "Completed" +``` + +## IconDisabled renders disabled icon with secondary color + +```props +instance: IconDisabled +instanceProps: + colored: true +``` + +```expect +⊘ +``` + +```style +- selector: label("⊘") + color: textSecondary +``` + +```accessibility +announce: "Disabled" +``` + +--- + +# Pre-built standard icon instances + +## IconPrompt renders chevron right + +```props +instance: IconPrompt +``` + +```expect +❯ +``` + +```accessibility +announce: "Prompt" +``` + +## IconInfoWorking renders half circle + +```props +instance: IconInfoWorking +``` + +```expect +◐ +``` + +```accessibility +announce: "In progress" +``` + +## IconInfoEmpty renders empty circle + +```props +instance: IconInfoEmpty +``` + +```expect +○ +``` + +```accessibility +announce: "Empty" +``` + +## IconArrowRight renders right arrow + +```props +instance: IconArrowRight +``` + +```expect +→ +``` + +```accessibility +announce: "Keyboard Right" +``` + +## IconArrowLeft renders left arrow + +```props +instance: IconArrowLeft +``` + +```expect +← +``` + +```accessibility +announce: "Keyboard Left" +``` + +## IconArrowUp renders up arrow + +```props +instance: IconArrowUp +``` + +```expect +↑ +``` + +```accessibility +announce: "Keyboard Up" +``` + +## IconArrowDown renders down arrow + +```props +instance: IconArrowDown +``` + +```expect +↓ +``` + +```accessibility +announce: "Keyboard Down" +``` + +## IconScrollbar renders scrollbar (decorative) + +```props +instance: IconScrollbar +``` + +```expect +▋ +``` + +```accessibility +aria-hidden: true +announce: null +``` + +## IconCheckboxChecked renders checked box + +```props +instance: IconCheckboxChecked +``` + +```expect +[✓] +``` + +```accessibility +announce: "Checked" +``` + +## IconCheckboxUnchecked renders unchecked box + +```props +instance: IconCheckboxUnchecked +``` + +```expect +[ ] +``` + +```accessibility +announce: "Unchecked" +``` + +## IconSeparatorWord renders dot separator (decorative) + +```props +instance: IconSeparatorWord +``` + +```expect +· +``` + +```accessibility +aria-hidden: true +announce: null +``` + +## IconSeparatorList renders bullet (decorative) + +```props +instance: IconSeparatorList +``` + +```expect +• +``` + +```accessibility +aria-hidden: true +announce: null +``` + +## IconNestingLast renders last-child connector (decorative) + +```props +instance: IconNestingLast +``` + +```expect +└ +``` + +```accessibility +aria-hidden: true +announce: null +``` + +## IconNestingMiddle renders middle-child connector (decorative) + +```props +instance: IconNestingMiddle +``` + +```expect +├ +``` + +```accessibility +aria-hidden: true +announce: null +``` + +## IconNestingSkip renders vertical line connector (decorative) + +```props +instance: IconNestingSkip +``` + +```expect +│ +``` + +```accessibility +aria-hidden: true +announce: null +``` + +--- + +# Semantic icon without colored prop + +## IconSuccess without colored uses no color + +```props +instance: IconSuccess +``` + +```style +- selector: label("✓") + color: null + description: Without colored=true, the semantic icon has no automatic color. +``` + +## IconSuccess with explicit color override + +```props +instance: IconSuccess +instanceProps: + color: brand +``` + +```style +- selector: label("✓") + color: brand +``` + +--- + +# Display name + +## createIcon sets displayName with label + +```props +factory: createIcon +glyph: "❯" +defaultLabel: "Prompt" +``` + +```meta +displayName: "Icon(Prompt)" +``` + +## createIcon sets displayName for decorative + +```props +factory: createIcon +glyph: "▋" +defaultLabel: "" +``` + +```meta +displayName: "Icon(decorative)" +``` + +## createColoredIcon sets displayName with label + +```props +factory: createColoredIcon +glyph: "✓" +defaultLabel: "Success" +semanticColorKey: statusSuccess +``` + +```meta +displayName: "Icon(Success)" +``` diff --git a/components/Input/Input.md b/components/Input/Input.md new file mode 100644 index 0000000..21fcc0e --- /dev/null +++ b/components/Input/Input.md @@ -0,0 +1,316 @@ +--- +kind: component +name: Input +description: Text input with cursor navigation, UNIX keybindings, multiline support, and masking. +version: 2 +category: input + +tokens: + colors: [textTertiary] + icons: [] + +props: + value: + type: string + required: true + description: Current text value (controlled). + + onChange: + type: callback(value: string) + required: true + description: Called when the text value changes. + + placeholder: + type: string + required: false + default: "" + description: Text displayed when value is empty. + + focus: + type: boolean + required: false + default: true + description: > + Whether the input listens to keyboard events. Combined with + terminal window focus to produce effective focus. + + mask: + type: string + required: false + description: > + Single character used to replace every character in the display. + Useful for password fields (e.g., mask: "*"). + + showCursor: + type: boolean + required: false + default: true + description: Whether to display the cursor with inverse video. + + onSubmit: + type: callback(value: string) + required: false + description: Called when Enter is pressed (without backslash continuation). + + onSave: + type: callback(value: string) + required: false + description: Called when Ctrl+S is pressed. + + cursorBlink: + type: boolean + required: false + default: false + description: Whether the cursor blinks on and off. + + cursorBlinkInterval: + type: number + required: false + default: 530 + description: Blink interval in milliseconds. + + maxLines: + type: number + required: false + default: 1 + description: > + Maximum number of visible lines. When greater than 1, enables + multiline editing with Shift+Enter for newlines. + + width: + type: number + required: false + default: 0 + description: > + Width for line wrapping. Required when maxLines > 1. + Defaults to unlimited (no wrapping) for single-line inputs. + + onUpArrow: + type: callback() + required: false + description: > + Called when Up arrow is pressed while the cursor is on the first line. + Used for history navigation or parent menu selection. + + onDownArrow: + type: callback() + required: false + description: > + Called when Down arrow is pressed while the cursor is on the last line. + Used for history navigation or parent menu selection. + + singleLine: + type: boolean + required: false + default: false + description: > + When true, prevents newline insertion (Shift+Enter, backslash+Enter) + and strips newlines from pasted text. + + highlightPastedText: + type: boolean + required: false + description: Reserved for API compatibility. Not currently used. + +types: + InputHandle: + description: > + Imperative handle exposed via ref. Allows programmatic text insertion + at the current cursor position (e.g., for right-click paste). + fields: + insertText: + type: callback(text: string) + required: true + description: Insert text at the current cursor position. + +keyboard: + # Character input + "": { action: "insert character at cursor" } + backspace: { action: "delete character before cursor" } + + # Cursor movement + "←": { action: "move cursor left one character" } + "→": { action: "move cursor right one character" } + "↑": { action: "move cursor up one line (or fire onUpArrow at first line)" } + "↓": { action: "move cursor down one line (or fire onDownArrow at last line)" } + home: { action: "move to start of current visual line" } + end: { action: "move to end of current visual line" } + ctrl+home: { action: "move to absolute start of all text" } + ctrl+end: { action: "move to absolute end of all text" } + + # UNIX bindings + ctrl+a: { action: "move to start of visual line (cycles backward)" } + ctrl+e: { action: "move to end of visual line (cycles forward)" } + ctrl+b: { action: "move cursor left", same_as: "←" } + ctrl+f: { action: "move cursor right", same_as: "→" } + ctrl+h: { action: "backspace", same_as: backspace } + ctrl+d: { action: "forward delete" } + ctrl+w: { action: "delete word before cursor" } + ctrl+u: { action: "clear line before cursor" } + ctrl+k: { action: "clear line after cursor" } + alt+left: { action: "move cursor one word left" } + alt+right: { action: "move cursor one word right" } + ctrl+left: { action: "move cursor one word left", same_as: "alt+left" } + ctrl+right: { action: "move cursor one word right", same_as: "alt+right" } + + # Submission and special + enter: { action: "submit value via onSubmit (or newline if preceded by backslash)" } + shift+enter: { action: "insert newline (multiline only, ignored in singleLine mode)" } + meta+enter: { action: "insert newline (alternative)", same_as: "shift+enter" } + ctrl+s: { action: "fire onSave callback" } + + # Ignored (passed through to parent) + ctrl+c: { action: "ignored — handled by parent" } + tab: { action: "ignored — handled by parent" } + shift+tab: { action: "ignored — handled by parent" } + escape: { action: "ignored — handled by parent" } + +accessibility: + role: textbox + properties: + aria-label: "Text input" + aria-multiline: "true when maxLines > 1" + states: + aria-disabled: "true when effective focus is lost" + announce: + on_mount: "Text input: {placeholder or empty}" + on_change: "Character inserted or deleted at position {cursor}" + screen_reader_adaptations: + - when: screen reader detected + change: "Cursor blink animation is disabled; cursor position is announced on movement" + +variants: + UncontrolledInput: + description: > + Convenience wrapper that manages its own state internally. + Accepts initialValue instead of value/onChange. + props_override: + value: { removed: true } + onChange: { required: false } + initialValue: + type: string + required: false + default: "" + description: Starting text value. + +dependencies: + tokens: + - name: textTertiary + kind: color + usage: "Placeholder text color" + required: false + components: [] +--- + +# Input + +A text input component with full UNIX keybinding support, cursor navigation, +multiline editing, masking, and placeholder display. + +## Visual rules + +- Text MUST be rendered in the default foreground color +- **Placeholder** text MUST use the `textTertiary` color token +- **Cursor** MUST be shown as inverse video on the character at the cursor position +- When the input is empty with a placeholder and cursor visible, the first + placeholder character MUST be rendered with inverse video +- When the input is empty without a placeholder and cursor visible, a single + inverse space MUST be rendered +- **Masked** text MUST replace every character with the mask character +- Cursor blink MUST toggle inverse video on/off at the configured interval +- Cursor MUST only be visible when the input has effective focus (prop focus AND + terminal window focus) + +## Rendering example + +### Focused with text + +``` +hello wor█d + ^ + inverse cursor at position 9 +``` + +### Empty with placeholder + +``` +█ype a message... +^ +inverse first char, rest in textTertiary +``` + +### Masked + +``` +●●●●●●●●█ + ^ + inverse cursor, all chars replaced with mask +``` + +### Multiline (maxLines: 3) + +``` +First line of text +Second line here +Third li█e visible +``` + +Lines beyond `maxLines` are scrolled out of view. The scroll offset adjusts +automatically to keep the cursor line visible. + +## Behavior + +### Focus + +Effective focus is the combination of the `focus` prop AND terminal window +focus. When effective focus is lost: + +- Keyboard input MUST be ignored +- Cursor MUST become invisible (no inverse video) +- Cursor blink MUST stop and reset to visible + +### Cursor navigation + +The cursor tracks a position within the text. In single-line mode, width is +treated as unlimited so all UNIX line-movement bindings operate on the entire +text. In multiline mode, the provided `width` drives visual line wrapping. + +### Multiline + +When `maxLines > 1`: + +- Text MUST wrap at the `width` boundary into visual lines +- Shift+Enter (or Meta+Enter) MUST insert a newline character +- Up/Down arrows MUST navigate between visual lines +- At the first line, Up arrow MUST fire `onUpArrow`; at the last line, Down arrow + MUST fire `onDownArrow` +- A scroll offset MUST keep the cursor line within the visible window + +### Backslash continuation + +When Enter is pressed and the character immediately before the cursor is a +backslash (`\`), the backslash MUST be removed and a newline MUST be inserted +instead of submitting. This mimics shell-style line continuation. This +behavior MUST be disabled in `singleLine` mode. + +### Single-line mode + +When `singleLine` is true: + +- Shift+Enter and Meta+Enter MUST be ignored +- Backslash+Enter MUST NOT insert a newline +- Pasted text MUST have all newline characters stripped + +## Dependencies + +| Dependency | Kind | Usage | Required | +| -------------- | ----- | ---------------------- | -------- | +| `textTertiary` | color | Placeholder text color | No | + +## Edge cases + +- If `focus` is true but the terminal window loses focus, input MUST be deactivated +- Cursor blink MUST reset to visible whenever focus changes +- An empty value with no placeholder and no cursor MUST render as empty text +- The `insertText` imperative handle MUST insert at the current cursor position + and MUST trigger an onChange notification diff --git a/components/Input/Input.preview.md b/components/Input/Input.preview.md new file mode 100644 index 0000000..dd659bc --- /dev/null +++ b/components/Input/Input.preview.md @@ -0,0 +1,32 @@ +--- +kind: preview +component: Input +version: 1 +--- + +## Default + +```props +placeholder: "Type here..." +``` + +## Multiline + +```props +placeholder: "Type here... (Shift+Enter for newlines)" +maxLines: 5 +``` + +## Masked + +```props +mask: "*" +placeholder: "Enter password..." +``` + +## Single line + +```props +singleLine: true +placeholder: "No newlines allowed..." +``` diff --git a/components/Input/Input.test.md b/components/Input/Input.test.md new file mode 100644 index 0000000..7004821 --- /dev/null +++ b/components/Input/Input.test.md @@ -0,0 +1,835 @@ +--- +kind: test +component: Input +version: 1 +--- + +# Input Tests — Basic Rendering + +## renders text value + +```props +value: "hello" +onChange: callback +``` + +```expect +hello +``` + +## renders empty with cursor + +```props +value: "" +onChange: callback +focus: true +showCursor: true +``` + +```expect +█ +``` + +## renders placeholder when empty + +```props +value: "" +placeholder: "Type here..." +onChange: callback +focus: true +showCursor: true +``` + +```expect +█ype here... +``` + +## renders placeholder without cursor when unfocused + +```props +value: "" +placeholder: "Type here..." +onChange: callback +focus: false +``` + +```expect +Type here... +``` + +## renders empty without placeholder or cursor + +```props +value: "" +onChange: callback +focus: false +showCursor: false +``` + +```expect + +``` + +--- + +# Input Tests — Placeholder Styling + +## placeholder uses textTertiary color + +```props +value: "" +placeholder: "Search..." +onChange: callback +focus: false +``` + +```style +- selector: label("Search...") + color: textTertiary +``` + +## placeholder partial text uses textTertiary after inverse first char + +```props +value: "" +placeholder: "Search..." +onChange: callback +focus: true +showCursor: true +``` + +```style +- selector: label("earch...") + color: textTertiary +``` + +--- + +# Input Tests — Cursor Display + +## shows inverse cursor at position + +```props +value: "abc" +onChange: callback +focus: true +showCursor: true +``` + +```expect +abc█ +``` + +## hides cursor when showCursor is false + +```props +value: "abc" +onChange: callback +focus: true +showCursor: false +``` + +```expect +abc +``` + +## hides cursor when focus is false + +```props +value: "abc" +onChange: callback +focus: false +showCursor: true +``` + +```expect +abc +``` + +--- + +# Input Tests — Masking + +## masks all characters + +```props +value: "secret" +onChange: callback +mask: "*" +``` + +```expect +****** +``` + +## masks with cursor + +```props +value: "pass" +onChange: callback +mask: "●" +focus: true +showCursor: true +``` + +```expect +●●●●█ +``` + +--- + +# Input Tests — Character Input + +## inserts character at cursor + +```props +value: "" +onChange: callback +focus: true +``` + +```input +"a" +``` + +```state +value: "a" +callback: onChange("a") +``` + +## inserts multiple characters sequentially + +```props +value: "" +onChange: callback +focus: true +``` + +```input +"h" "i" +``` + +```state +value: "hi" +callback: onChange("hi") +``` + +--- + +# Input Tests — Backspace and Delete + +## backspace deletes character before cursor + +```props +value: "abc" +onChange: callback +focus: true +``` + +```input +backspace +``` + +```state +value: "ab" +callback: onChange("ab") +``` + +## ctrl+h acts as backspace + +```props +value: "abc" +onChange: callback +focus: true +``` + +```input +ctrl+h +``` + +```state +value: "ab" +callback: onChange("ab") +``` + +## ctrl+d deletes character after cursor + +```props +value: "abc" +onChange: callback +focus: true +``` + +```input +home ctrl+d +``` + +```state +value: "bc" +callback: onChange("bc") +``` + +--- + +# Input Tests — Word Operations + +## ctrl+w deletes word before cursor + +```props +value: "hello world" +onChange: callback +focus: true +``` + +```input +ctrl+w +``` + +```state +value: "hello " +callback: onChange("hello ") +``` + +## ctrl+u clears line before cursor + +```props +value: "hello world" +onChange: callback +focus: true +``` + +```input +ctrl+u +``` + +```state +value: "" +callback: onChange("") +``` + +## ctrl+k clears line after cursor + +```props +value: "hello world" +onChange: callback +focus: true +``` + +```input +home ctrl+k +``` + +```state +value: "" +callback: onChange("") +``` + +--- + +# Input Tests — Cursor Movement + +## left arrow moves cursor left + +```props +value: "abc" +onChange: callback +focus: true +showCursor: true +``` + +```input +← +``` + +```expect +ab█c +``` + +## right arrow moves cursor right from middle + +```props +value: "abc" +onChange: callback +focus: true +showCursor: true +``` + +```input +← ← → +``` + +```expect +ab█c +``` + +## home moves to start of line + +```props +value: "hello" +onChange: callback +focus: true +showCursor: true +``` + +```input +home +``` + +```expect +█ello +``` + +## end moves to end of line + +```props +value: "hello" +onChange: callback +focus: true +showCursor: true +``` + +```input +home end +``` + +```expect +hello█ +``` + +## ctrl+a moves to start of line + +```props +value: "hello" +onChange: callback +focus: true +showCursor: true +``` + +```input +ctrl+a +``` + +```expect +█ello +``` + +## ctrl+e moves to end of line + +```props +value: "hello" +onChange: callback +focus: true +showCursor: true +``` + +```input +home ctrl+e +``` + +```expect +hello█ +``` + +## ctrl+b moves left + +```props +value: "abc" +onChange: callback +focus: true +showCursor: true +``` + +```input +ctrl+b +``` + +```expect +ab█c +``` + +## ctrl+f moves right + +```props +value: "abc" +onChange: callback +focus: true +showCursor: true +``` + +```input +ctrl+b ctrl+f +``` + +```expect +abc█ +``` + +## alt+left moves one word left + +```props +value: "hello world" +onChange: callback +focus: true +showCursor: true +``` + +```input +alt+left +``` + +```expect +hello █orld +``` + +## alt+right moves one word right + +```props +value: "hello world" +onChange: callback +focus: true +showCursor: true +``` + +```input +home alt+right +``` + +```expect +hello█ world +``` + +--- + +# Input Tests — Submission + +## enter fires onSubmit + +```props +value: "done" +onChange: callback +onSubmit: callback +focus: true +``` + +```input +enter +``` + +```state +callback: onSubmit("done") +``` + +## backslash-enter inserts newline instead of submitting + +```props +value: "line\\" +onChange: callback +onSubmit: callback +focus: true +``` + +```input +enter +``` + +```state +value: "line\n" +callback: onChange("line\n") +``` + +## backslash-enter is disabled in singleLine mode + +```props +value: "line\\" +onChange: callback +onSubmit: callback +focus: true +singleLine: true +``` + +```input +enter +``` + +```state +callback: onSubmit("line\\") +``` + +--- + +# Input Tests — Save + +## ctrl+s fires onSave + +```props +value: "text" +onChange: callback +onSave: callback +focus: true +``` + +```input +ctrl+s +``` + +```state +callback: onSave("text") +``` + +--- + +# Input Tests — Multiline + +## shift+enter inserts newline + +```props +value: "line1" +onChange: callback +focus: true +maxLines: 3 +width: 40 +``` + +```input +shift+enter +``` + +```state +value: "line1\n" +callback: onChange("line1\n") +``` + +## up arrow at first line fires onUpArrow + +```props +value: "first\nsecond" +onChange: callback +onUpArrow: callback +focus: true +maxLines: 3 +width: 40 +``` + +```input +ctrl+home ↑ +``` + +```state +callback: onUpArrow() +``` + +## down arrow at last line fires onDownArrow + +```props +value: "first\nsecond" +onChange: callback +onDownArrow: callback +focus: true +maxLines: 3 +width: 40 +``` + +```input +ctrl+end ↓ +``` + +```state +callback: onDownArrow() +``` + +## up arrow navigates within text + +```props +value: "first\nsecond" +onChange: callback +focus: true +maxLines: 3 +width: 40 +showCursor: true +``` + +```input +ctrl+end ↑ +``` + +```expect +first█ +second +``` + +## scrolls to keep cursor visible + +```props +value: "line1\nline2\nline3\nline4\nline5" +onChange: callback +focus: true +maxLines: 2 +width: 40 +showCursor: true +``` + +```input +ctrl+end +``` + +```expect +line4 +line5█ +``` + +--- + +# Input Tests — Single-Line Mode + +## shift+enter does nothing in singleLine mode + +```props +value: "text" +onChange: callback +focus: true +singleLine: true +``` + +```input +shift+enter +``` + +```state +value: "text" +``` + +## strips newlines from pasted text in singleLine mode + +```props +value: "" +onChange: callback +focus: true +singleLine: true +``` + +```input +"line1\nline2" +``` + +```state +value: "line1line2" +callback: onChange("line1line2") +``` + +--- + +# Input Tests — Cursor Blink + +## cursor blinks at configured interval + +```props +value: "text" +onChange: callback +focus: true +showCursor: true +cursorBlink: true +cursorBlinkInterval: 530 +``` + +```state +cursor_visible: true +``` + +After 530ms: + +```state +cursor_visible: false +``` + +After 1060ms: + +```state +cursor_visible: true +``` + +## cursor blink resets when focus changes + +```props +value: "text" +onChange: callback +focus: true +showCursor: true +cursorBlink: true +``` + +```state +cursor_visible: true +``` + +--- + +# Input Tests — Ignored Keys + +## tab is ignored and passed to parent + +```props +value: "text" +onChange: callback +focus: true +``` + +```input +tab +``` + +```state +value: "text" +``` + +## escape is ignored and passed to parent + +```props +value: "text" +onChange: callback +focus: true +``` + +```input +escape +``` + +```state +value: "text" +``` + +## ctrl+c is ignored and passed to parent + +```props +value: "text" +onChange: callback +focus: true +``` + +```input +ctrl+c +``` + +```state +value: "text" +``` + +--- + +# Input Tests — Imperative Handle + +## insertText inserts at cursor position + +```props +value: "helo" +onChange: callback +focus: true +``` + +```input +imperative:insertText("l") +``` + +```state +value: "helol" +callback: onChange("helol") +``` diff --git a/components/Link/Link.md b/components/Link/Link.md new file mode 100644 index 0000000..49a8d6f --- /dev/null +++ b/components/Link/Link.md @@ -0,0 +1,168 @@ +--- +kind: component +name: Link +description: Terminal hyperlink using the OSC 8 escape sequence. +version: 2 +category: navigation + +tokens: + colors: [] + icons: [] + +props: + url: + type: string + required: true + description: > + The URL the link points to. Used to construct the OSC 8 hyperlink + escape sequence. + + children: + type: slot + required: false + description: > + Display text for the link. When omitted, the URL itself is shown + as the display text. + + color: + type: SemanticColor + required: false + description: > + Text color — must be a semantic color token from the color system. + When omitted, the text inherits the surrounding color. + + bold: + type: boolean + required: false + description: Whether to render the text as bold. + +accessibility: + role: link + properties: + aria-label: "{display text} ({url})" + announce: + on_mount: "Link: {display text}" + on_change: "Link target changed to {url}" + screen_reader_adaptations: + - when: screen reader detected + change: > + OSC 8 escape sequences are omitted entirely. The link renders as + plain styled text (with color and bold applied) so that screen + reader output is not polluted with raw escape codes. + +security: + url_sanitization: + description: > + Before embedding the URL in the OSC 8 sequence, control characters + (ESC \x1b, BEL \x07, C1 ST \x9c) are stripped from the URL. This + prevents injection of escape sequences through malicious URLs. + stripped_characters: ["\x1b", "\x07", "\x9c"] + +dependencies: + tokens: [] + components: [] +--- + +# Link + +A terminal hyperlink component that uses the OSC 8 escape sequence to make +text clickable in supporting terminals. Falls back to plain styled text in +screen reader mode for accessibility. + +## Visual rules + +- Text color MUST be set by the `color` prop (any semantic color token) +- Text weight MUST be set by the `bold` prop +- When `children` is omitted, the URL MUST be displayed as the link text +- The OSC 8 escape sequence MUST wrap the display text: + `ESC]8;;BELESC]8;;BEL` +- In screen reader mode, only the styled display text MUST be rendered (no + escape sequences) + +## Rendering example + +### Standard mode + +Given: + +``` +url: "https://example.com" +children: "Click here" +color: markdownLink +``` + +Terminal output (with escape sequences): + +``` +\e]8;;https://example.com\aClick here\e]8;;\a +``` + +Visible to user: + +``` +Click here +^^^^^^^^^^ +clickable hyperlink in markdownLink color +``` + +### URL as display text + +Given: + +``` +url: "https://example.com" +``` + +Visible to user: + +``` +https://example.com +``` + +### Screen reader mode + +Given: + +``` +url: "https://example.com" +children: "Click here" +color: markdownLink +bold: true +``` + +Output (no escape sequences): + +``` +Click here +^^^^^^^^^^ +bold, markdownLink color, plain text +``` + +## Security + +URLs are sanitized before embedding in OSC 8 sequences. The following +control characters are removed: + +| Character | Hex | Name | +| --------- | ------ | -------------------- | +| ESC | `\x1b` | Escape | +| BEL | `\x07` | Bell | +| ST | `\x9c` | C1 String Terminator | + +This prevents an attacker from injecting arbitrary escape sequences via +a crafted URL. + +## Dependencies + +| Dependency | Kind | Usage | Required | +| ---------- | ---- | --------------------------- | -------- | +| _(none)_ | — | No fixed token dependencies | — | + +## Edge cases + +- A URL containing only control characters MUST be sanitized to an empty string, + producing a no-op hyperlink +- The `color` prop MAY accept any semantic color token; it MUST NOT be restricted + to a fixed set +- When both `children` and `color` are omitted, the link MUST render the URL as + unstyled text with the OSC 8 sequence diff --git a/components/Link/Link.preview.md b/components/Link/Link.preview.md new file mode 100644 index 0000000..9129312 --- /dev/null +++ b/components/Link/Link.preview.md @@ -0,0 +1,33 @@ +--- +kind: preview +component: Link +version: 1 +--- + +## Default + +```props +url: "https://github.com" +``` + +## With label color + +```props +url: "https://github.com" +color: markdownLink +``` + +## With brand color + +```props +url: "https://github.com" +color: brand +``` + +## Bold + +```props +url: "https://github.com" +color: markdownLink +bold: true +``` diff --git a/components/Link/Link.test.md b/components/Link/Link.test.md new file mode 100644 index 0000000..c45556c --- /dev/null +++ b/components/Link/Link.test.md @@ -0,0 +1,268 @@ +--- +kind: test +component: Link +version: 1 +--- + +# Link Tests — Basic Rendering + +## renders children as display text + +```props +url: "https://example.com" +children: "Click here" +``` + +```expect +Click here +``` + +## renders URL as display text when children omitted + +```props +url: "https://example.com" +``` + +```expect +https://example.com +``` + +## applies color prop + +```props +url: "https://example.com" +children: "Styled link" +color: markdownLink +``` + +```style +- selector: label("Styled link") + color: markdownLink +``` + +## applies bold prop + +```props +url: "https://example.com" +children: "Bold link" +bold: true +``` + +```style +- selector: label("Bold link") + bold: true +``` + +## applies both color and bold + +```props +url: "https://example.com" +children: "Fancy" +color: statusInfo +bold: true +``` + +```style +- selector: label("Fancy") + color: statusInfo + bold: true +``` + +## renders with no styling when color and bold omitted + +```props +url: "https://example.com" +children: "Plain" +``` + +```style +- selector: label("Plain") + bold: false +``` + +--- + +# Link Tests — OSC 8 Escape Sequence + +## wraps text in OSC 8 sequence + +```props +url: "https://example.com" +children: "Link" +``` + +```expect_raw +\e]8;;https://example.com\aLink\e]8;;\a +``` + +## uses URL as display text in OSC 8 when no children + +```props +url: "https://github.com" +``` + +```expect_raw +\e]8;;https://github.com\ahttps://github.com\e]8;;\a +``` + +--- + +# Link Tests — URL Sanitization + +## strips ESC character from URL + +```props +url: "https://evil.com\x1b]malicious" +children: "Safe" +``` + +```expect_raw +\e]8;;https://evil.com]malicious\aSafe\e]8;;\a +``` + +## strips BEL character from URL + +```props +url: "https://evil.com\x07inject" +children: "Safe" +``` + +```expect_raw +\e]8;;https://evil.cominject\aSafe\e]8;;\a +``` + +## strips C1 ST character from URL + +```props +url: "https://evil.com\x9cbreak" +children: "Safe" +``` + +```expect_raw +\e]8;;https://evil.combreak\aSafe\e]8;;\a +``` + +## strips multiple control characters from URL + +```props +url: "https://\x1b\x07\x9c.example.com" +children: "Clean" +``` + +```expect_raw +\e]8;;https://.example.com\aClean\e]8;;\a +``` + +## sanitized URL with all control characters produces empty href + +```props +url: "\x1b\x07\x9c" +children: "Empty" +``` + +```expect_raw +\e]8;;\aEmpty\e]8;;\a +``` + +--- + +# Link Tests — Accessibility + +## screen reader mode renders plain text without escape sequences + +```props +url: "https://example.com" +children: "Click here" +``` + +```accessibility +screen_reader: true +``` + +```expect +Click here +``` + +## screen reader mode preserves color + +```props +url: "https://example.com" +children: "Styled" +color: markdownLink +``` + +```accessibility +screen_reader: true +``` + +```style +- selector: label("Styled") + color: markdownLink +``` + +## screen reader mode preserves bold + +```props +url: "https://example.com" +children: "Bold" +bold: true +``` + +```accessibility +screen_reader: true +``` + +```style +- selector: label("Bold") + bold: true +``` + +## screen reader mode shows URL when children omitted + +```props +url: "https://example.com" +``` + +```accessibility +screen_reader: true +``` + +```expect +https://example.com +``` + +--- + +# Link Tests — Edge Cases + +## handles empty children (renders URL) + +```props +url: "https://example.com" +``` + +```expect +https://example.com +``` + +## handles URL with special characters + +```props +url: "https://example.com/path?q=hello&lang=en#section" +children: "Search" +``` + +```expect +Search +``` + +## handles very long URL + +```props +url: "https://example.com/a/very/deeply/nested/path/that/goes/on/and/on" +children: "Deep link" +``` + +```expect +Deep link +``` diff --git a/components/Metric/Metric.md b/components/Metric/Metric.md new file mode 100644 index 0000000..d8a5120 --- /dev/null +++ b/components/Metric/Metric.md @@ -0,0 +1,153 @@ +--- +kind: component +name: Metric +description: > + Displays a grid of character options with effort-level meters for visual + comparison of glyph rendering at different fill states. +version: 2 +category: display + +tokens: + colors: [textPrimary, textSecondary, textTertiary] + icons: [] + +types: + MetricChar: + fields: + code: + type: string + required: true + description: > + The Unicode code point label for the character (e.g. "U+25A0"). + char: + type: string | array + required: true + description: > + The rendered character(s). A single string repeats the same glyph + across all effort levels. An array provides progressive variants + where each element maps to its corresponding effort level index. + + EffortLevel: + description: > + Union type representing an effort level label. + values: ["low", "mid", "high", "max"] + +props: + chars: + type: array + required: true + description: Character options to display, one per row. + + levels: + type: array + required: false + default: ["low", "mid", "high", "max"] + description: > + Effort levels to render as columns. Defaults to all four levels. + + activeColor: + type: string + required: false + default: textPrimary + description: > + Semantic color token for active (filled) indicators. + + inactiveColor: + type: string + required: false + default: textTertiary + description: > + Semantic color token for inactive (dimmed) indicators. + +dependencies: + tokens: + - name: textPrimary + kind: color + usage: "Active indicator color (default)" + required: true + - name: textSecondary + kind: color + usage: "Header labels and code point labels" + required: true + - name: textTertiary + kind: color + usage: "Inactive indicator color (default)" + required: true + components: [] + +accessibility: + role: img + properties: + aria-label: "Character metric grid" + announce: + on_mount: "Character metric grid with {n} characters across {m} effort levels" + screen_reader_adaptations: + - when: screen reader detected + change: "Render metric data as a text table with character names and effort levels" +--- + +# Metric + +A diagnostic grid that displays Unicode character options alongside effort-level +meters. Each row shows a character's code point, its rendered glyph, and a +series of columns — one per effort level — where the glyph is repeated with +progressive fill coloring. This helps visually compare how different glyphs +align with text at varying density levels. + +## Visual rules + +- The **header row** labels ("Code", "Char", and each level name) MUST use `textSecondary` +- **Code point** labels in each row MUST use `textSecondary` +- **Character glyphs** in the Char column MUST use the default foreground +- In effort-level columns, characters at or below the column's level index MUST use `activeColor` +- Characters above the column's level index MUST use `inactiveColor` +- Level labels beside each column MUST use `textSecondary` +- Columns MUST be separated by a 2-character gap +- Rows MUST be separated by a 1-line gap + +## Rendering example + +Given: + +``` +chars: [ + { code: "U+2580", char: "▀" }, + { code: "U+28xx", char: ["⣀", "⣤", "⣶", "⣿"] } +] +levels: ["low", "mid", "high", "max"] +``` + +Output (active = `textPrimary`, inactive = `textTertiary`): + +``` +Code Char low mid high max + ↓ ↓ ↓ ↓ +U+2580 ▀ ▀▀▀▀ low ▀▀▀▀ mid ▀▀▀▀ high ▀▀▀▀ max + ^--- ^^-- ^^^- ^^^^ + active/ active/ active/ all active + rest inact rest inact rest inact + +U+28xx ⣀⣤⣶⣿ ⣀⣤⣶⣿ low ⣀⣤⣶⣿ mid ⣀⣤⣶⣿ high ⣀⣤⣶⣿ max + ^--- ^^-- ^^^- ^^^^ +``` + +## Progressive vs uniform characters + +- **Uniform** (`char: "▀"`): The same glyph is repeated once per effort level. + Fill coloring progresses left to right — earlier positions light up first. +- **Progressive** (`char: ["⣀", "⣤", "⣶", "⣿"]`): Each effort level uses its + own glyph variant from the array. The Char column shows all variants joined. + +## Edge cases + +- If `chars` is empty, only the header row MUST render +- If `char` is a progressive array shorter than `levels`, missing positions MUST render as a space +- Custom `levels` arrays MUST change both the column count and the header labels + +## Dependencies + +| Dependency | Kind | Usage | Required | +| --------------- | ----- | ----------------------------------- | -------- | +| `textPrimary` | color | Active indicator color (default) | Yes | +| `textSecondary` | color | Header labels and code point labels | Yes | +| `textTertiary` | color | Inactive indicator color (default) | Yes | diff --git a/components/Metric/Metric.preview.md b/components/Metric/Metric.preview.md new file mode 100644 index 0000000..ac08024 --- /dev/null +++ b/components/Metric/Metric.preview.md @@ -0,0 +1,30 @@ +--- +kind: preview +component: Metric +version: 1 +--- + +## Default + +```props +chars: + - code: "U+25A0" + char: "■" + - code: "U+2588" + char: "█" + - code: "U+28xx" + char: ["⣀", "⣤", "⣶", "⣿"] +``` + +## Highlighted + +```props +chars: + - code: "U+25A0" + char: "■" + - code: "U+2588" + char: "█" + - code: "U+28xx" + char: ["⣀", "⣤", "⣶", "⣿"] +activeColor: selected +``` diff --git a/components/Metric/Metric.test.md b/components/Metric/Metric.test.md new file mode 100644 index 0000000..214d77b --- /dev/null +++ b/components/Metric/Metric.test.md @@ -0,0 +1,125 @@ +--- +kind: test +component: Metric +version: 1 +--- + +# Metric Tests + +## renders header row with default levels + +```props +chars: [] +levels: ["low", "mid", "high", "max"] +``` + +```expect +Code Char low mid high max +``` + +```style +- selector: label("Code") + color: textSecondary +- selector: label("Char") + color: textSecondary +- selector: label("low") + color: textSecondary +``` + +## renders a uniform character row + +```props +chars: [{ code: "U+2580", char: "▀" }] +``` + +```expect +Code Char low mid high max + +U+2580 ▀ ▀▀▀▀ low ▀▀▀▀ mid ▀▀▀▀ high ▀▀▀▀ max +``` + +## renders a progressive character row + +```props +chars: [{ code: "U+28xx", char: ["⣀", "⣤", "⣶", "⣿"] }] +``` + +```expect +Code Char low mid high max + +U+28xx ⣀⣤⣶⣿ ⣀⣤⣶⣿ low ⣀⣤⣶⣿ mid ⣀⣤⣶⣿ high ⣀⣤⣶⣿ max +``` + +## progressive char column shows all variants joined + +```props +chars: [{ code: "U+28xx", char: ["⣀", "⣤", "⣶", "⣿"] }] +``` + +```style +- selector: label("⣀⣤⣶⣿") + color: null +``` + +## uniform active/inactive coloring in low column + +```props +chars: [{ code: "U+2580", char: "▀" }] +``` + +```style +- selector: item(0) + description: "In the 'low' column, first char is active, rest inactive" +- selector: indicator(0) + color: textPrimary +``` + +## progressive active/inactive coloring progresses per column + +```props +chars: [{ code: "U+28xx", char: ["⣀", "⣤", "⣶", "⣿"] }] +``` + +```style +- selector: item(0) + description: > + In each effort column, characters at or below the column index use + activeColor (textPrimary), characters above use inactiveColor (textTertiary). +``` + +## custom levels changes column count + +```props +chars: [{ code: "U+2580", char: "▀" }] +levels: ["low", "max"] +``` + +```expect +Code Char low max + +U+2580 ▀ ▀▀ low ▀▀ max +``` + +## code column uses textSecondary + +```props +chars: [{ code: "U+2580", char: "▀" }] +``` + +```style +- selector: label("U+2580") + color: textSecondary +``` + +## level labels use textSecondary + +```props +chars: [{ code: "U+2580", char: "▀" }] +``` + +```style +- selector: label(" low") + color: textSecondary +- selector: label(" mid") + color: textSecondary +``` diff --git a/components/QrCode/QrCode.md b/components/QrCode/QrCode.md new file mode 100644 index 0000000..ea4ce66 --- /dev/null +++ b/components/QrCode/QrCode.md @@ -0,0 +1,109 @@ +--- +kind: component +name: QrCode +description: > + Renders a QR code as compact Unicode block characters in the terminal. +version: 2 +category: display + +tokens: + colors: [] + icons: [] + +props: + data: + type: string + required: true + description: > + The data (URL, text, etc.) to encode as a QR code. + +dependencies: + tokens: [] + components: [] + +accessibility: + role: img + properties: + aria-label: "QR code for {data}" + announce: + on_mount: "QR code linking to {data}" + screen_reader_adaptations: + - when: screen reader detected + change: "Render the data URL as plain text instead of the QR grid" +--- + +# QrCode + +Encodes arbitrary string data into a QR code and renders it using Unicode +half-block characters. Each text row represents two QR module rows, roughly +halving the vertical footprint compared to a full-block approach. The result +is a compact, scannable code that works in any terminal with Unicode support. + +## Visual rules + +- MUST use the terminal's default foreground color (no semantic color tokens) +- Dark modules MUST be rendered with block characters; light modules MUST be spaces +- A quiet zone of 1 module MUST surround the QR grid (required by QR spec for scanners) +- A centered placeholder region SHOULD be reserved in the middle of the code for + embedding a mascot or logo art when the QR size is large enough + +## Encoding approach + +The component uses **Unicode half-block characters** to pack two vertical +QR module rows into a single terminal row: + +| Top module | Bottom module | Character | +| ---------- | ------------- | ---------------- | +| dark | dark | `█` (full block) | +| dark | light | `▀` (upper half) | +| light | dark | `▄` (lower half) | +| light | light | ` ` (space) | + +This 2:1 vertical compression makes QR codes practical for terminal display. + +## Rendering example + +Given `data: "https://example.com"`: + +``` + ▄▄▄▄▄▄▄ ▄ ▄ ▄▄▄▄▄▄▄ + █ ▄▄▄ █ █▄▀ █ ▄▄▄ █ + █ ███ █ ▀▄▀▄ █ ███ █ + █▄▄▄▄▄█ ▄▀▄▀ █▄▄▄▄▄█ + ▄▄ ▄▄▄▄▀▄▀▄▄ ▄ ▄▄ + ▄▄█▀▀▄▄ ▀█▀▄██▄▄▀▀ + ▄▄▄▄▄▄▄ ▀▀▄▄▀█▄█ ▄ + █ ▄▄▄ █ █▄▀ ▄ ▀█▀ + █ ███ █ ▄▀▀▀▄█▀▄██ + █▄▄▄▄▄█ █ ▀▀ ▀▄▀▀ +``` + +(Exact output depends on the QR encoder — this is illustrative.) + +## Error correction + +The QR code is generated with **High (H)** error correction level. This allows +up to ~30% of the code to be damaged or obscured (e.g. by the center mascot +placeholder) while remaining scannable. + +## Center placeholder + +A rectangular region in the center of the QR grid is cleared (rendered as spaces) +to accommodate an embedded mascot or logo graphic. The placeholder: + +- Is sized to fit within the QR data area (max 10 columns × 8 rows of modules) +- Is vertically aligned to text-row boundaries (even row start) +- Only embeds art when the placeholder is large enough to contain it +- Works because the High error correction level compensates for the cleared modules + +## Edge cases + +- Very short data produces a small QR version — the placeholder MAY not fit and + MUST be omitted in that case +- The quiet zone MUST be present regardless of data length +- Data MUST be encoded as-is — the component MUST NOT perform URL validation or normalization + +## Dependencies + +This component has no token or component dependencies. It uses the terminal's +default foreground color. diff --git a/components/QrCode/QrCode.preview.md b/components/QrCode/QrCode.preview.md new file mode 100644 index 0000000..7c35fd4 --- /dev/null +++ b/components/QrCode/QrCode.preview.md @@ -0,0 +1,17 @@ +--- +kind: preview +component: QrCode +version: 1 +--- + +## Short URL + +```props +data: "https://github.com" +``` + +## Long URL + +```props +data: "https://github.com/github/copilot-agent-runtime/tasks/abc-123" +``` diff --git a/components/QrCode/QrCode.test.md b/components/QrCode/QrCode.test.md new file mode 100644 index 0000000..d197536 --- /dev/null +++ b/components/QrCode/QrCode.test.md @@ -0,0 +1,159 @@ +--- +kind: test +component: QrCode +version: 1 +--- + +# QrCode Tests + +## renders a QR code for a URL + +```props +data: "https://github.com" +``` + +```expect +(output is a multi-line block of █ ▀ ▄ and space characters) +``` + +## output contains only block characters and spaces + +```props +data: "hello" +``` + +```expect +(every character in the output is one of: █ ▀ ▄ or space) +``` + +## output has consistent line width + +```props +data: "test" +``` + +```expect +(all lines have equal character width) +``` + +--- + +# Half-block encoding tests + +These verify the cell-rendering logic that maps two vertical QR modules +to a single terminal character. + +## both dark modules produce full block + +```renderCell +top: true +bottom: true +expect: "█" +``` + +## top dark, bottom light produces upper half block + +```renderCell +top: true +bottom: false +expect: "▀" +``` + +## top light, bottom dark produces lower half block + +```renderCell +top: false +bottom: true +expect: "▄" +``` + +## both light modules produce space + +```renderCell +top: false +bottom: false +expect: " " +``` + +--- + +# Placeholder bounds tests + +These verify the center placeholder region calculation. + +## placeholder is centered in the QR grid + +```placeholderBounds +size: 25 +margin: 1 +expect: + colStart: centered within size + rowStart: centered within size + width: ≤ 10 + height: ≤ 8 + rowStart_parity: matches margin parity +``` + +## placeholder respects maximum dimensions + +```placeholderBounds +size: 40 +margin: 1 +expect: + width: 10 + height: 8 +``` + +## small QR sizes shrink the placeholder + +```placeholderBounds +size: 6 +margin: 1 +expect: + width: 6 + height: 6 +``` + +## rowStart aligns to margin parity for text-row boundaries + +```placeholderBounds +size: 25 +margin: 1 +expect: + rowStart_parity: odd +``` + +```placeholderBounds +size: 25 +margin: 2 +expect: + rowStart_parity: even +``` + +--- + +# Quiet zone tests + +## output includes 1-module quiet zone margin + +```props +data: "test" +``` + +```expect +(first row and first column of each line contain quiet zone spacing) +``` + +--- + +# Error correction tests + +## uses High error correction level + +```props +data: "https://github.com" +``` + +```expect +(QR is generated with error correction level H, tolerating ~30% obscured modules) +``` diff --git a/components/Select/Select.md b/components/Select/Select.md new file mode 100644 index 0000000..74609aa --- /dev/null +++ b/components/Select/Select.md @@ -0,0 +1,324 @@ +--- +kind: component +name: Select +description: Keyboard-navigable selection list with automatic numbering and accessibility. +version: 2 +category: input + +tokens: + colors: [selected, textSecondary, textOnBackgroundSecondary, statusSuccess] + icons: [iconPrompt, iconSuccess] + +types: + SelectItem: + generic: T + fields: + label: { type: string, required: true, description: "Display text for this option" } + value: { type: T, required: true, description: "Backing value returned on selection" } + current: { type: boolean, required: false, description: "Marks this item as the currently active/persisted choice" } + +props: + items: + type: array> + required: true + description: List of selectable options. + + onSelect: + type: callback(item: SelectItem) → void + required: true + description: Fires when the user confirms a selection (Enter or number key). + + onEscape: + type: callback() → void + required: false + description: > + Fires when Escape is pressed and no escapeItem is provided. + Mutually exclusive intent with escapeItem — use one or the other. + + escapeItem: + type: SelectItem + required: false + description: > + A special item appended to the end of the list. Pressing Escape selects + this item. Rendered with "(Esc)" suffix. + + onHighlight: + type: callback(item: SelectItem) → void + required: false + description: Fires when the highlighted item changes during navigation. + + initialItem: + type: T + required: false + description: > + Value to match for initial highlight position. If not found or not + provided, highlight starts at the first item. + + extraHints: + type: record + required: false + description: > + Additional hints merged into the default HintBar. Inserted between + navigation hints and action hints. + + hideHints: + type: boolean + required: false + default: false + description: When true, the built-in HintBar is not rendered. + +states: + initial: focused + definitions: + focused: + description: > + List is visible and accepting keyboard input. One item is always + highlighted (there is no "unfocused" idle state — the component + is interactive from mount). + transitions: + select: selected + escape: dismissed + selected: + description: User confirmed a choice via Enter or number key. + terminal: true + emits: onSelect + dismissed: + description: User pressed Escape. + terminal: true + emits: onSelect(escapeItem) or onEscape + +keyboard: + "↑": + action: Move highlight up + wrap: false + note: Stops at first item (no wrapping) + "↓": + action: Move highlight down + wrap: false + note: Stops at last item (no wrapping) + k: + action: Move highlight up + same_as: "↑" + note: Vim binding + j: + action: Move highlight down + same_as: "↓" + note: Vim binding + enter: + action: Confirm highlighted item → fires onSelect + escape: + action: > + If escapeItem provided: select it via onSelect. + If onEscape provided: call onEscape. + Otherwise: no action. + ctrl+g: + action: Cancel (alternative) + same_as: escape + "1-9": + action: > + Directly select item by number (1-indexed). Immediately fires onSelect + without requiring Enter confirmation. + +accessibility: + role: listbox + properties: + aria-label: "Selection list" + states: + aria-selected: "true for the highlighted item" + announce: + on_mount: "Select: {count} items" + on_change: "Item {index} of {total}: {label}" + screen_reader_adaptations: + - when: screen reader detected + change: "Current item indicator changes from ✓ glyph to (current) text suffix" + +composition: + children: + - component: HintBar + slot: footer + optional: true + hide_prop: hideHints + default_props: + hints: + up-down: "to navigate" + enter: "to select" + esc: "to cancel" + merge_prop: extraHints + note: > + extraHints entries are merged between "up-down" and "enter". + "esc" hint only appears when escapeItem or onEscape is provided. + +dependencies: + tokens: + - name: selected + kind: color + usage: "Highlighted item indicator and text" + required: true + - name: textSecondary + kind: color + usage: "HintBar text" + required: true + - name: textOnBackgroundSecondary + kind: color + usage: "Unhighlighted item text" + required: true + - name: statusSuccess + kind: color + usage: "Current item glyph color" + required: false + - name: iconPrompt + kind: icon + usage: "Selection indicator glyph" + required: true + - name: iconSuccess + kind: icon + usage: "Current item indicator glyph" + required: false + components: + - name: HintBar + usage: "Footer keyboard navigation hints" + required: false +--- + +# Select + +A vertical selection list with keyboard navigation. Items are automatically numbered +(1., 2., 3., ...) and the highlighted item is indicated with `iconPrompt`. + +## Visual rules + +- **Highlighted item**: MUST show `iconPrompt` glyph + `selected` color token for both indicator and text +- **Unhighlighted items**: MUST use 2-space indent (same width as indicator) + `textOnBackgroundSecondary` color token +- **Current item**: MUST append `iconSuccess` glyph in `statusSuccess` color token after the label +- **Escape item**: MUST be appended as last item with "(Esc)" suffix after label +- **Current + escape**: MUST show both "✓ (Esc)" suffixes +- Items MUST be numbered starting at 1, prefixed as `{n}. {label}` + +## Rendering example + +Given: + +``` +items: [ + { label: "Alpha", value: "a" }, + { label: "Beta", value: "b", current: true }, + { label: "Gamma", value: "c" } +] +escapeItem: { label: "Cancel", value: "cancel" } +``` + +Initial render (first item highlighted): + +``` +❯ 1. Alpha + 2. Beta ✓ + 3. Gamma + 4. Cancel (Esc) +↑↓ to navigate · Enter to select · Esc to cancel +``` + +After pressing ↓: + +``` + 1. Alpha +❯ 2. Beta ✓ + 3. Gamma + 4. Cancel (Esc) +↑↓ to navigate · Enter to select · Esc to cancel +``` + +## Behavior + +### Number key selection + +Pressing a number key (1-9) MUST immediately select the corresponding item +without requiring Enter. + +### Escape handling priority + +1. If `escapeItem` is provided → `onSelect(escapeItem)` MUST be called +2. Else if `onEscape` is provided → `onEscape()` MUST be called +3. Else → the keystroke MUST be discarded + +### Initial highlight + +If `initialItem` matches a value in `items`, that item MUST start highlighted. +Otherwise, the first item MUST be highlighted. + +## Dependencies + +| Dependency | Kind | Usage | Required | +| --------------------------- | --------- | ----------------------------------- | -------- | +| `selected` | color | Highlighted item indicator and text | Yes | +| `textSecondary` | color | HintBar text | Yes | +| `textOnBackgroundSecondary` | color | Unhighlighted item text | Yes | +| `statusSuccess` | color | Current item glyph color | No | +| `iconPrompt` | icon | Selection indicator glyph | Yes | +| `iconSuccess` | icon | Current item indicator glyph | No | +| `HintBar` | component | Footer keyboard navigation hints | No | + +## Edge cases + +- **Empty items array**: MUST render only the HintBar (or nothing if `hideHints` is true) +- **Single item**: MUST render normally; ↑↓ MUST have no effect +- **Number key out of range**: MUST be ignored (e.g., pressing 5 with only 3 items) +- **initialItem not found**: MUST fall back to first item + +--- + +# SelectWithTextInput (variant) + +A variant where the escape/reject item has an inline text input. When highlighted, +the escape item's label becomes a placeholder and the user can type feedback. + +## Additional props (variant-specific) + +```yaml +escapeItemWithTextInput: + type: SelectItem + required: true + description: > + The item that becomes a text input when highlighted. Its label is used + as placeholder text. +``` + +## Additional keyboard (variant-specific) + +```yaml +"↑" (in text input): Navigate back up to the list items +enter (in text input): Submit text value → fires onSelect(escapeItem, textValue) +escape: Always fires onSelect(escapeItem, textValue) regardless of focus +``` + +## Rendering example (variant) + +Given: + +``` +items: [{ label: "Looks good", value: "approve" }] +escapeItemWithTextInput: { label: "Request changes...", value: "reject" } +``` + +Initial (first item highlighted): + +``` +❯ 1. Looks good + 2. Request changes... (Esc to stop) +↑↓ to navigate · Enter to select · Esc to cancel +``` + +After pressing ↓ (text input activates): + +``` + 1. Looks good +❯ 2. |Request changes... (Esc to stop) +↑↓ to navigate · Enter to select · Esc to cancel +``` + +The cursor appears at position 0, placeholder text shown. As user types: + +``` + 1. Looks good +❯ 2. Please fix the auth bug| (Esc to stop) +↑↓ to navigate · Enter to select · Esc to cancel +``` diff --git a/components/Select/Select.preview.md b/components/Select/Select.preview.md new file mode 100644 index 0000000..f865e7b --- /dev/null +++ b/components/Select/Select.preview.md @@ -0,0 +1,78 @@ +--- +kind: preview +component: Select +version: 1 +--- + +## Basic + +```props +items: + - label: "Alpha" + value: "alpha" + - label: "Beta" + value: "beta" + - label: "Gamma" + value: "gamma" +escapeItem: + label: "Cancel" + value: "cancel" +``` + +## With current item + +```props +items: + - label: "Alpha" + value: "alpha" + - label: "Beta" + value: "beta" + current: true + - label: "Gamma" + value: "gamma" +escapeItem: + label: "Cancel" + value: "cancel" +``` + +## With text input + +```props +items: + - label: "Alpha" + value: "alpha" + - label: "Beta" + value: "beta" +escapeItemWithTextInput: + label: "Something else..." + value: "other" +``` + +## Scrolling + +```props +items: + - label: "Alpha" + value: "alpha" + - label: "Beta" + value: "beta" + - label: "Gamma" + value: "gamma" + - label: "Delta" + value: "delta" + - label: "Epsilon" + value: "epsilon" + - label: "Zeta" + value: "zeta" + - label: "Eta" + value: "eta" + - label: "Theta" + value: "theta" + - label: "Iota" + value: "iota" + - label: "Kappa" + value: "kappa" +escapeItem: + label: "Cancel" + value: "cancel" +``` diff --git a/components/Select/Select.test.md b/components/Select/Select.test.md new file mode 100644 index 0000000..5f67c1c --- /dev/null +++ b/components/Select/Select.test.md @@ -0,0 +1,507 @@ +--- +kind: test +component: Select +version: 1 +--- + +# Select Tests + +## renders numbered items with first highlighted + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } + - { label: "Gamma", value: "c" } +``` + +```expect +❯ 1. Alpha + 2. Beta + 3. Gamma +↑↓ to navigate · Enter to select · Esc to cancel +``` + +## moves highlight down on arrow key + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } + - { label: "Gamma", value: "c" } +``` + +```input +↓ +``` + +```expect + 1. Alpha +❯ 2. Beta + 3. Gamma +↑↓ to navigate · Enter to select · Esc to cancel +``` + +## moves highlight up on arrow key + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } + - { label: "Gamma", value: "c" } +``` + +```input +↓ ↓ ↑ +``` + +```expect + 1. Alpha +❯ 2. Beta + 3. Gamma +↑↓ to navigate · Enter to select · Esc to cancel +``` + +## does not wrap past first item + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } +``` + +```input +↑ +``` + +```expect +❯ 1. Alpha + 2. Beta +↑↓ to navigate · Enter to select · Esc to cancel +``` + +## does not wrap past last item + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } +``` + +```input +↓ ↓ ↓ +``` + +```expect + 1. Alpha +❯ 2. Beta +↑↓ to navigate · Enter to select · Esc to cancel +``` + +## selects item on Enter + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } +``` + +```input +↓ enter +``` + +```state +after: selected +selected_value: "b" +``` + +## selects item by number key + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } + - { label: "Gamma", value: "c" } +``` + +```input +2 +``` + +```state +after: selected +selected_value: "b" +``` + +## ignores out-of-range number key + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } +``` + +```input +5 +``` + +```expect +❯ 1. Alpha + 2. Beta +↑↓ to navigate · Enter to select · Esc to cancel +``` + +## shows current indicator on marked item + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b", current: true } + - { label: "Gamma", value: "c" } +``` + +```expect +❯ 1. Alpha + 2. Beta ✓ + 3. Gamma +↑↓ to navigate · Enter to select · Esc to cancel +``` + +## appends escape item with Esc suffix + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } +escapeItem: { label: "Cancel", value: "cancel" } +``` + +```expect +❯ 1. Alpha + 2. Beta + 3. Cancel (Esc) +↑↓ to navigate · Enter to select · Esc to cancel +``` + +## escape key selects escape item + +```props +items: + - { label: "Alpha", value: "a" } +escapeItem: { label: "Cancel", value: "cancel" } +``` + +```input +escape +``` + +```state +after: dismissed +selected_value: "cancel" +``` + +## ctrl+g also triggers escape + +```props +items: + - { label: "Alpha", value: "a" } +escapeItem: { label: "Cancel", value: "cancel" } +``` + +```input +ctrl+g +``` + +```state +after: dismissed +selected_value: "cancel" +``` + +## escape calls onEscape when no escapeItem + +```props +items: + - { label: "Alpha", value: "a" } +onEscape: callback +``` + +```input +escape +``` + +```state +after: dismissed +callback_fired: onEscape +``` + +## respects initialItem for starting highlight + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } + - { label: "Gamma", value: "c" } +initialItem: "b" +``` + +```expect + 1. Alpha +❯ 2. Beta + 3. Gamma +↑↓ to navigate · Enter to select · Esc to cancel +``` + +## falls back to first item when initialItem not found + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } +initialItem: "nonexistent" +``` + +```expect +❯ 1. Alpha + 2. Beta +↑↓ to navigate · Enter to select · Esc to cancel +``` + +## hides HintBar when hideHints is true + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } +hideHints: true +``` + +```expect +❯ 1. Alpha + 2. Beta +``` + +## shows extra hints merged into HintBar + +```props +items: + - { label: "Alpha", value: "a" } +extraHints: { "s": "to search" } +``` + +```expect +❯ 1. Alpha +↑↓ to navigate · s to search · Enter to select +``` + +## vim j/k bindings work for navigation + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } + - { label: "Gamma", value: "c" } +``` + +```input +j j +``` + +```expect + 1. Alpha + 2. Beta +❯ 3. Gamma +↑↓ to navigate · Enter to select · Esc to cancel +``` + +```input +k +``` + +```expect + 1. Alpha +❯ 2. Beta + 3. Gamma +↑↓ to navigate · Enter to select · Esc to cancel +``` + +--- + +# Style assertions + +## highlighted item uses selected color + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } +``` + +```style +- selector: indicator(0) + icon: iconPrompt + color: selected +- selector: item(0) + color: selected +- selector: item(1) + color: textOnBackgroundSecondary +``` + +## current item uses statusSuccess color + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b", current: true } +``` + +```style +- selector: item(1) + contains: "✓" + color: statusSuccess +``` + +## highlighted current item uses statusSuccess for indicator + +```props +items: + - { label: "Alpha", value: "a", current: true } +``` + +```style +- selector: indicator(0) + icon: iconPrompt + color: statusSuccess +- selector: item(0) + color: statusSuccess +``` + +--- + +# Accessibility assertions + +## screen reader replaces check glyph with text + +```props +items: + - { label: "Beta", value: "b", current: true } +screen_reader: true +``` + +```expect +❯ 1. Beta (current) +↑↓ to navigate · Enter to select · Esc to cancel +``` + +--- + +# SelectWithTextInput variant tests + +## renders with text input escape item + +```props +variant: SelectWithTextInput +items: + - { label: "Looks good", value: "approve" } +escapeItemWithTextInput: { label: "Request changes...", value: "reject" } +``` + +```expect +❯ 1. Looks good + 2. Request changes... (Esc to stop) +↑↓ to navigate · Enter to select · Esc to cancel +``` + +## activates text input when escape item highlighted + +```props +variant: SelectWithTextInput +items: + - { label: "Looks good", value: "approve" } +escapeItemWithTextInput: { label: "Request changes...", value: "reject" } +``` + +```input +↓ +``` + +```expect + 1. Looks good +❯ 2. |Request changes... (Esc to stop) +↑↓ to navigate · Enter to select · Esc to cancel +``` + +## typing replaces placeholder in text input + +```props +variant: SelectWithTextInput +items: + - { label: "Looks good", value: "approve" } +escapeItemWithTextInput: { label: "Request changes...", value: "reject" } +``` + +```input +↓ "Fix the bug" +``` + +```expect + 1. Looks good +❯ 2. Fix the bug| (Esc to stop) +↑↓ to navigate · Enter to select · Esc to cancel +``` + +## up arrow from text input returns to list + +```props +variant: SelectWithTextInput +items: + - { label: "Looks good", value: "approve" } +escapeItemWithTextInput: { label: "Request changes...", value: "reject" } +``` + +```input +↓ ↑ +``` + +```expect +❯ 1. Looks good + 2. Request changes... (Esc to stop) +↑↓ to navigate · Enter to select · Esc to cancel +``` + +## enter in text input submits with text value + +```props +variant: SelectWithTextInput +items: + - { label: "Looks good", value: "approve" } +escapeItemWithTextInput: { label: "Request changes...", value: "reject" } +``` + +```input +↓ "Fix it" enter +``` + +```state +after: selected +selected_value: "reject" +text_value: "Fix it" +``` + +## escape always submits escape item with current text + +```props +variant: SelectWithTextInput +items: + - { label: "Looks good", value: "approve" } +escapeItemWithTextInput: { label: "Request changes...", value: "reject" } +``` + +```input +↓ "some text" escape +``` + +```state +after: dismissed +selected_value: "reject" +text_value: "some text" +``` diff --git a/components/SelectAutocomplete/SelectAutocomplete.md b/components/SelectAutocomplete/SelectAutocomplete.md new file mode 100644 index 0000000..1b5db8a --- /dev/null +++ b/components/SelectAutocomplete/SelectAutocomplete.md @@ -0,0 +1,359 @@ +--- +kind: component +name: SelectAutocomplete +description: Single-select list with inline search input, fuzzy filtering, and keyboard navigation. +version: 2 +category: input + +tokens: + colors: [selected, statusSuccess, textSecondary, textTertiary] + icons: [iconSuccess] + +types: + SelectAutocompleteItem: + generic: T + fields: + value: { type: T, required: true, description: "Backing value returned on selection" } + label: { type: string, required: true, description: "Display text (also used as filter match target)" } + current: { type: boolean, required: false, description: "Marks this item as the currently active/persisted choice" } + + SelectAutocompleteRenderContext: + generic: T + fields: + item: { type: SelectAutocompleteItem, required: true, description: "The item being rendered" } + isHighlighted: { type: boolean, required: true, description: "Whether this item is the highlighted row" } + index: { type: number, required: true, description: "Index in the filtered list" } + searchTerm: { type: string, required: true, description: "Current text in the search input" } + +props: + items: + type: array> + required: true + description: Items to display in the list. Filtered by the search input. + + onSelect: + type: callback(item: SelectAutocompleteItem) → void + required: true + description: Fires when the user confirms a selection with Enter. + + onEscape: + type: callback() → void + required: false + description: > + Fires on Escape when search is empty and no escapeItem is provided. + Mutually exclusive intent with escapeItem. + + escapeItem: + type: SelectAutocompleteItem + required: false + description: > + A special item appended after all filtered results. Never filtered out by + search. Rendered with "(Esc)" suffix. Pressing Escape (when search is + empty) selects this item via onSelect. + + renderItem: + type: callback(context: SelectAutocompleteRenderContext) → ReactNode + required: false + description: > + Custom render function for each item row. When omitted, the default + renderer is used (indicator + label + current marker). + + searchPlaceholder: + type: string + required: false + default: '"Type to filter..."' + description: Placeholder text shown in the search input when empty. + + fuzzy: + type: boolean + required: false + default: true + description: > + When true, uses fuzzy matching (scored and ranked by relevance). + When false, uses simple case-insensitive substring matching. + + onHighlight: + type: callback(item: SelectAutocompleteItem) → void + required: false + description: > + Fires when the highlighted item changes due to navigation or filtering. + Renamed from onHighlightedChange for consistency with Select. + + onLeftArrow: + type: callback() → void + required: false + description: Fires when the left arrow key is pressed (passthrough for parent navigation). + + onRightArrow: + type: callback() → void + required: false + description: Fires when the right arrow key is pressed (passthrough for parent navigation). + + afterHints: + type: ReactNode + required: false + description: Optional content rendered between the item list and the hint bar. + + extraHints: + type: record + required: false + description: > + Additional hints merged into the HintBar before the default action hints. + Consolidated from former additionalHints prop for consistency with Select. + + hideHints: + type: boolean + required: false + default: false + description: When true, the built-in HintBar is not rendered. + +states: + initial: focused + definitions: + focused: + description: > + Search input is active and the list is visible. One item is always + highlighted. The user can type to filter, navigate with arrows, or + press Enter/Escape. + transitions: + select: selected + escape_clear: focused + escape_close: dismissed + selected: + description: User confirmed a choice via Enter. + terminal: true + emits: onSelect + dismissed: + description: User pressed Escape with empty search. + terminal: true + emits: onSelect(escapeItem) or onEscape + +keyboard: + "↑": + action: Move highlight up + wrap: true + note: Wraps from first item to last + "↓": + action: Move highlight down + wrap: true + note: Wraps from last item to first + enter: + action: Confirm highlighted item → fires onSelect + note: No-op when the list is empty + escape: + action: > + Two-stage: if search has text, clears the search input and resets + the filter (stays in focused state). If search is already empty, + selects escapeItem or fires onEscape. + ctrl+g: + action: Same as escape + same_as: escape + backspace: + action: Delete last character from search input + "←": + action: Fires onLeftArrow callback (passthrough) + note: Does not affect internal state + "→": + action: Fires onRightArrow callback (passthrough) + note: Does not affect internal state + text: + action: > + Appends typed characters to the search input. Filtering runs after + each keystroke. + +accessibility: + role: combobox + properties: + aria-label: "Search and select" + aria-expanded: "true when dropdown visible" + aria-activedescendant: "ID of highlighted item" + states: + aria-selected: "true for highlighted item in results list" + announce: + on_mount: "Search: type to filter {count} items" + on_change: "Result {index} of {filtered_count}: {label}" + screen_reader_adaptations: + - when: screen reader detected + change: > + Current item indicator changes from "✓" glyph to " (current)" text + suffix. + +dependencies: + tokens: + - name: selected + kind: color + usage: "Highlighted item text and indicator" + required: true + - name: statusSuccess + kind: color + usage: "Current item marker glyph" + required: false + - name: textSecondary + kind: color + usage: "Escape item suffix and no-results message" + required: false + - name: textTertiary + kind: color + usage: "Search placeholder text" + required: false + - name: iconSuccess + kind: icon + usage: "Current item marker glyph (✓)" + required: false + components: + - name: HintBar + usage: "Footer keyboard navigation hints" + required: false + dependents: [] + +composition: + children: + - component: SearchInput + slot: header + description: > + Inline text input at the top of the component. Shows a block cursor + (inverse space character). When empty, displays the placeholder. + - component: HintBar + slot: footer + optional: true + hide_prop: hideHints + default_props: + hints: + up-down: "to navigate" + enter: "to select" + esc: "to cancel" + merge_prop: extraHints + note: > + "esc" hint only appears when escapeItem or onEscape is provided. + extraHints are merged between "up-down" and "enter". +--- + +# SelectAutocomplete + +A searchable single-select list. A text input at the top filters the items below +using fuzzy matching (default) or substring matching. Items are navigated with +arrow keys and confirmed with Enter. + +## Visual rules + +- **Search input**: MUST render block cursor as inverse space character. When empty, + placeholder text MUST appear in `textTertiary` after the cursor. +- **Highlighted item**: MUST use `iconPrompt` glyph (`❯`) + `selected` color for both + indicator and text +- **Unhighlighted items**: MUST use 2-space indent + default text color +- **Current item**: MUST append `iconSuccess` glyph in `statusSuccess` color after label +- **Escape item**: MUST be appended as last item with "(Esc)" suffix in `textSecondary` +- **No results message**: MUST show "No matches for \"{searchTerm}\"" in `textSecondary` + when the search produces zero matching items +- One blank line MUST separate the search input from the item list +- One blank line MUST separate the item list from the HintBar/afterHints area + +## Filtering behavior + +### Fuzzy mode (default, `fuzzy: true`) + +- Characters typed into the search input are matched fuzzily against item labels +- Results are ranked by match score — best matches appear first +- The escapeItem is excluded from filtering and always appears last + +### Substring mode (`fuzzy: false`) + +- Simple case-insensitive substring match against item labels +- Results retain their original order (no scoring/reranking) +- The escapeItem is excluded from filtering and always appears last + +### Filter reset + +- The highlight resets to the first item when the filtered list changes +- If the current highlight index exceeds the new filtered list length, it clamps + to the last item + +## Rendering example + +Given: + +``` +items: [ + { label: "JavaScript", value: "js" }, + { label: "TypeScript", value: "ts", current: true }, + { label: "Python", value: "py" }, + { label: "Rust", value: "rs" } +] +escapeItem: { label: "Cancel", value: "cancel" } +searchPlaceholder: "Search languages..." +``` + +Initial render (empty search): + +``` + Search languages... +❯ JavaScript + TypeScript ✓ + Python + Rust + Cancel (Esc) +↑↓ to navigate · Enter to select · Esc to cancel +``` + +After typing "sc": + +``` +sc +❯ JavaScript + TypeScript ✓ + Cancel (Esc) +↑↓ to navigate · Enter to select · Esc to cancel +``` + +After typing "xyz" (no matches): + +``` +xyz +No matches for "xyz" + Cancel (Esc) +↑↓ to navigate · Enter to select · Esc to cancel +``` + +## Behavior + +### Two-stage Escape + +1. **First press** (search has text): MUST clear the search input and restore full list +2. **Second press** (search is empty): + - If `escapeItem` provided → MUST call `onSelect(escapeItem)` + - Else if `onEscape` provided → MUST call `onEscape()` + - Otherwise → MUST NOT take any action + +### Arrow passthrough + +Left and right arrow keys MUST NOT be consumed by the component. They MUST fire +`onLeftArrow` / `onRightArrow` callbacks, enabling parent-level navigation +(e.g., switching between TabBar tabs). + +### Highlight tracking + +When `onHighlight` is provided, it MUST fire whenever the highlighted item +changes — whether from arrow key navigation or from the filtered list changing +due to search input. + +## Edge cases + +- **Empty items array**: MUST render only the search input, "No matches" message (if + search has text), escapeItem (if provided), and HintBar +- **Single item**: MUST render normally; ↑↓ MUST wrap to itself +- **All items filtered out**: MUST show "No matches" message; escapeItem (if present) + MUST remain visible and become highlighted +- **Custom renderItem**: MUST override the entire row rendering; receives full context + including highlight state, index, and search term +- **Very long search term**: MUST NOT truncate — text extends as typed + +## Dependencies + +| Dependency | Kind | Usage | Required | +| --------------- | --------- | ----------------------------------------- | -------- | +| `selected` | color | Highlighted item text and indicator | Yes | +| `statusSuccess` | color | Current item marker glyph | No | +| `textSecondary` | color | Escape item suffix and no-results message | No | +| `textTertiary` | color | Search placeholder text | No | +| `iconSuccess` | icon | Current item marker glyph (✓) | No | +| `HintBar` | component | Footer keyboard navigation hints | No | diff --git a/components/SelectAutocomplete/SelectAutocomplete.preview.md b/components/SelectAutocomplete/SelectAutocomplete.preview.md new file mode 100644 index 0000000..f6398ed --- /dev/null +++ b/components/SelectAutocomplete/SelectAutocomplete.preview.md @@ -0,0 +1,38 @@ +--- +kind: preview +component: SelectAutocomplete +version: 1 +--- + +## Basic + +```props +items: + - label: "Alpha" + value: "alpha" + - label: "Beta" + value: "beta" + - label: "Gamma" + value: "gamma" +escapeItem: + label: "Cancel" + value: "cancel" +searchPlaceholder: "Search options..." +``` + +## With current item + +```props +items: + - label: "Alpha" + value: "alpha" + - label: "Beta" + value: "beta" + current: true + - label: "Gamma" + value: "gamma" +escapeItem: + label: "Cancel" + value: "cancel" +searchPlaceholder: "Search..." +``` diff --git a/components/SelectAutocomplete/SelectAutocomplete.test.md b/components/SelectAutocomplete/SelectAutocomplete.test.md new file mode 100644 index 0000000..939eff5 --- /dev/null +++ b/components/SelectAutocomplete/SelectAutocomplete.test.md @@ -0,0 +1,525 @@ +--- +kind: test +component: SelectAutocomplete +version: 1 +--- + +# SelectAutocomplete Tests + +## renders items with search input and first highlighted + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } + - { label: "Gamma", value: "c" } +searchPlaceholder: "Search..." +``` + +```expect + Search... +❯ Alpha + Beta + Gamma +↑↓ to navigate · Enter to select +``` + +## typing filters items with fuzzy match + +```props +items: + - { label: "JavaScript", value: "js" } + - { label: "TypeScript", value: "ts" } + - { label: "Python", value: "py" } +fuzzy: true +``` + +```input +"sc" +``` + +```expect +sc +❯ JavaScript + TypeScript +↑↓ to navigate · Enter to select +``` + +## typing filters items with substring match + +```props +items: + - { label: "JavaScript", value: "js" } + - { label: "TypeScript", value: "ts" } + - { label: "Python", value: "py" } +fuzzy: false +``` + +```input +"Script" +``` + +```expect +Script +❯ JavaScript + TypeScript +↑↓ to navigate · Enter to select +``` + +## shows no-results message when search matches nothing + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } +``` + +```input +"xyz" +``` + +```expect +xyz +No matches for "xyz" +↑↓ to navigate · Enter to select +``` + +## escape item remains visible when all items filtered out + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } +escapeItem: { label: "Cancel", value: "cancel" } +``` + +```input +"xyz" +``` + +```expect +xyz +No matches for "xyz" +❯ Cancel (Esc) +↑↓ to navigate · Enter to select · Esc to cancel +``` + +## moves highlight down with wrapping + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } +``` + +```input +↓ ↓ ↓ +``` + +```expect + Type to filter... + Alpha +❯ Beta +↑↓ to navigate · Enter to select +``` + +## moves highlight up with wrapping + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } +``` + +```input +↑ +``` + +```expect + Type to filter... + Alpha +❯ Beta +↑↓ to navigate · Enter to select +``` + +## selects item on Enter + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } +``` + +```input +↓ enter +``` + +```state +after: selected +selected_value: "b" +``` + +## enter is no-op when list is empty + +```props +items: + - { label: "Alpha", value: "a" } +``` + +```input +"zzz" enter +``` + +```expect +zzz +No matches for "zzz" +↑↓ to navigate · Enter to select +``` + +## escape clears search on first press + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } +``` + +```input +"test" escape +``` + +```expect + Type to filter... +❯ Alpha + Beta +↑↓ to navigate · Enter to select +``` + +## escape closes on second press when search already empty + +```props +items: + - { label: "Alpha", value: "a" } +onEscape: callback +``` + +```input +escape +``` + +```state +after: dismissed +callback_fired: onEscape +``` + +## escape selects escapeItem when provided + +```props +items: + - { label: "Alpha", value: "a" } +escapeItem: { label: "Cancel", value: "cancel" } +``` + +```input +escape +``` + +```state +after: dismissed +selected_value: "cancel" +``` + +## ctrl+g behaves like escape + +```props +items: + - { label: "Alpha", value: "a" } +onEscape: callback +``` + +```input +ctrl+g +``` + +```state +after: dismissed +callback_fired: onEscape +``` + +## two-stage escape: clear then close + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } +escapeItem: { label: "Cancel", value: "cancel" } +``` + +```input +"test" +``` + +```expect +test +No matches for "test" +❯ Cancel (Esc) +↑↓ to navigate · Enter to select · Esc to cancel +``` + +```input +escape +``` + +```expect + Type to filter... +❯ Alpha + Beta + Cancel (Esc) +↑↓ to navigate · Enter to select · Esc to cancel +``` + +```input +escape +``` + +```state +after: dismissed +selected_value: "cancel" +``` + +## backspace removes last character from search + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } +``` + +```input +"abc" backspace +``` + +```expect +ab +❯ Alpha + Beta +↑↓ to navigate · Enter to select +``` + +## shows current indicator on marked item + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b", current: true } +``` + +```expect + Type to filter... +❯ Alpha + Beta ✓ +↑↓ to navigate · Enter to select +``` + +## appends escape item with Esc suffix + +```props +items: + - { label: "Alpha", value: "a" } +escapeItem: { label: "Cancel", value: "cancel" } +``` + +```expect + Type to filter... +❯ Alpha + Cancel (Esc) +↑↓ to navigate · Enter to select · Esc to cancel +``` + +## highlight resets to first item after filter change + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } + - { label: "Gamma", value: "c" } +``` + +```input +↓ ↓ +``` + +```expect + Type to filter... + Alpha + Beta +❯ Gamma +↑↓ to navigate · Enter to select +``` + +```input +"Al" +``` + +```expect +Al +❯ Alpha +↑↓ to navigate · Enter to select +``` + +## left arrow fires onLeftArrow + +```props +items: + - { label: "Alpha", value: "a" } +onLeftArrow: callback +``` + +```input +← +``` + +```state +callback_fired: onLeftArrow +``` + +## right arrow fires onRightArrow + +```props +items: + - { label: "Alpha", value: "a" } +onRightArrow: callback +``` + +```input +→ +``` + +```state +callback_fired: onRightArrow +``` + +## hides HintBar when hideHints is true + +```props +items: + - { label: "Alpha", value: "a" } +hideHints: true +``` + +```expect + Type to filter... +❯ Alpha +``` + +## shows additional and extra hints in HintBar + +```props +items: + - { label: "Alpha", value: "a" } +additionalHints: { "←→": "to switch tabs" } +``` + +```expect + Type to filter... +❯ Alpha +↑↓ to navigate · ←→ to switch tabs · Enter to select +``` + +--- + +# Style assertions + +## highlighted item uses selected color + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } +``` + +```style +- selector: indicator(0) + icon: iconPrompt + color: selected +- selector: item(0) + color: selected +- selector: item(1) + color: null +``` + +## current item uses statusSuccess color + +```props +items: + - { label: "Alpha", value: "a", current: true } +``` + +```style +- selector: item(0) + contains: "✓" + color: statusSuccess +- selector: indicator(0) + color: statusSuccess +``` + +## search placeholder uses textTertiary + +```props +items: + - { label: "Alpha", value: "a" } +searchPlaceholder: "Filter..." +``` + +```style +- selector: component("SearchInput") + placeholder_color: textTertiary +``` + +## escape item suffix uses textSecondary + +```props +items: + - { label: "Alpha", value: "a" } +escapeItem: { label: "Cancel", value: "cancel" } +``` + +```style +- selector: item(1) + contains: "(Esc)" + color: textSecondary +``` + +## no-results message uses textSecondary + +```props +items: + - { label: "Alpha", value: "a" } +``` + +```input +"zzz" +``` + +```style +- selector: label("No matches") + color: textSecondary +``` + +--- + +# Accessibility assertions + +## screen reader replaces check glyph with text + +```props +items: + - { label: "Beta", value: "b", current: true } +screen_reader: true +``` + +```expect + Type to filter... +❯ Beta (current) +↑↓ to navigate · Enter to select +``` diff --git a/components/TabBar/TabBar.md b/components/TabBar/TabBar.md new file mode 100644 index 0000000..a9fa5c6 --- /dev/null +++ b/components/TabBar/TabBar.md @@ -0,0 +1,238 @@ +--- +kind: component +name: TabBar +description: Horizontal tab bar with responsive carousel overflow and keyboard navigation. +version: 2 +category: navigation + +tokens: + colors: [selected, textSecondary] + icons: [iconArrowLeft, iconArrowRight] + +types: + TabItem: + generic: T + fields: + value: { type: T, required: true, description: "Unique identifier for this tab" } + label: { type: string, required: true, description: "Display label for the tab" } + + TabNavigationKeys: + description: Which key sets drive tab navigation. + enum: [all, arrow-only, tab-only] + +props: + items: + type: array> + required: true + description: Array of tab items to display. + + selectedIndex: + type: number + required: true + description: Index of the currently selected tab. + + onSelect: + type: callback(item: TabItem, index: number) → void + required: false + description: Fires when Enter is pressed on the focused tab. + + onNavigate: + type: callback(index: number) → void + required: false + description: > + Fires when the user navigates to a different tab. The parent controls + selectedIndex — this component is controlled (not self-managing). + + enableKeyboardNavigation: + type: boolean + required: false + default: false + description: > + Deprecated. Enables arrow-key navigation. Prefer navigationKeys for + explicit control. When set without navigationKeys, behaves as "arrow-only". + + navigationKeys: + type: TabNavigationKeys + required: false + description: > + Which keys trigger tab navigation. Setting this prop implicitly enables + keyboard navigation. When omitted, falls back to enableKeyboardNavigation. + "arrow-only" — left/right arrow keys. + "tab-only" — Tab (next) and Shift+Tab (previous). + "all" — both arrow keys and Tab/Shift+Tab. + + suffix: + type: ReactNode + required: false + description: Optional content rendered after the last visible tab (e.g., a "[1/5]" counter). + + suffixWidth: + type: number + required: false + default: 0 + description: > + Character width of the suffix. Required for accurate responsive layout + calculation — the carousel algorithm subtracts this from available width. + + label: + type: string + required: false + description: Accessible label for the tab bar (used in screen reader output). + + loop: + type: boolean + required: false + default: true + description: > + Whether navigation wraps from last tab to first (and vice versa). + When false, navigation stops at the boundaries. + +keyboard: + "←": + action: Navigate to previous tab (fires onNavigate) + enabled_when: 'navigationKeys is "arrow-only" or "all"' + wrap: loop prop + "→": + action: Navigate to next tab (fires onNavigate) + enabled_when: 'navigationKeys is "arrow-only" or "all"' + wrap: loop prop + tab: + action: Navigate to next tab + enabled_when: 'navigationKeys is "tab-only" or "all"' + wrap: loop prop + shift+tab: + action: Navigate to previous tab + enabled_when: 'navigationKeys is "tab-only" or "all"' + wrap: loop prop + enter: + action: Confirm current tab → fires onSelect + +accessibility: + role: tablist + properties: + aria-label: "Tab navigation" + states: + aria-selected: "true for the currently selected tab" + announce: + on_mount: "{label}: current tab: {selectedLabel}, {otherLabel1}, {otherLabel2}" + on_change: "Tab {selectedLabel} selected, {index} of {count}" + screen_reader_adaptations: + - when: screen reader detected + change: > + Carousel windowing is disabled — all tabs are rendered as a flat text + line. Arrow indicators are hidden. Format is a single line: + "{label}: current tab: {selected}, {other1}, {other2} {suffix}" + - when: screen reader detected + change: > + Tab chrome (brackets, inverse colors) is replaced with plain text labels + so screen readers encounter clean readable text. + +dependencies: + tokens: + - name: selected + kind: color + usage: "Selected tab text and inverse background" + required: true + - name: textSecondary + kind: color + usage: "Unselected tab labels and overflow arrows" + required: true + - name: iconArrowLeft + kind: icon + usage: "Left overflow indicator glyph" + required: false + - name: iconArrowRight + kind: icon + usage: "Right overflow indicator glyph" + required: false + components: [] + dependents: [] + +breakpoints: + any: + description: > + The carousel algorithm adapts to any terminal width. It measures how many + tabs fit in available space (terminal width minus suffix minus arrow + indicators) and shows a sliding window centered on the selected tab. +--- + +# TabBar + +A horizontal tab bar for switching between views. Tabs are laid out in a single +row. When all tabs cannot fit, a carousel mode activates with arrow indicators +showing hidden tabs in each direction. + +## Visual rules + +- **Selected tab**: MUST render with `selected` color and inverse text, wrapped in + brackets: `[Label]` +- **Unselected tabs**: MUST render in `textSecondary` color without brackets +- **Tab gap**: MUST use two character spaces between each tab +- **Left overflow indicator**: MUST show `iconArrowLeft` glyph in `textSecondary` followed + by a space, only when hidden tabs exist to the left +- **Right overflow indicator**: MUST show space followed by `iconArrowRight` glyph in + `textSecondary`, only when hidden tabs exist to the right +- **Suffix**: MUST render after the rightmost visible tab (and after the right arrow + if present) + +## Behavior + +The carousel MUST keep the selected tab visible at all times: + +1. **Measure available width**: terminal width − suffix width − safety margin. +2. **First pass**: calculate how many tabs fit without reserving arrow space. +3. **Check overflow**: determine if left/right arrows would be needed. +4. **Second pass**: if arrows are needed, MUST subtract their widths and recalculate. +5. **Window**: a sliding window of visible tabs MUST be centered on the selected tab. + When the window would extend past the start or end of the list, it MUST be clamped + to the boundary. +6. Selected tab width MUST include bracket padding (+2 chars). Unselected tabs MUST NOT + have extra padding. + +## Rendering example + +Given 5 tabs, selected index 1, enough space for 3: + +``` +← [Beta] Gamma Delta → +``` + +All tabs visible (no overflow): + +``` +Alpha [Beta] Gamma +``` + +With suffix: + +``` +Alpha [Beta] Gamma [1/3] +``` + +## Rendering example (screen reader) + +Given the same 5 tabs with label "Files": + +``` +Files: current tab: Beta, Alpha, Gamma, Delta, Epsilon +``` + +## Edge cases + +- **Empty items**: MUST render nothing (no arrows, no suffix) +- **Single tab**: MUST render selected tab only, no arrows, no carousel +- **All tabs fit**: MUST NOT render arrows; all tabs MUST be visible +- **loop=false at boundaries**: navigation MUST stop; left arrow at index 0 and right + arrow at last index MUST have no effect +- **loop=true (default)**: navigation MUST wrap — going past the last tab returns to + the first, and vice versa +- **Very narrow terminal**: MAY show only the selected tab with both arrows + +## Dependencies + +| Dependency | Kind | Usage | Required | +| ---------------- | ----- | ----------------------------------------- | -------- | +| `selected` | color | Selected tab text and inverse background | Yes | +| `textSecondary` | color | Unselected tab labels and overflow arrows | Yes | +| `iconArrowLeft` | icon | Left overflow indicator glyph | No | +| `iconArrowRight` | icon | Right overflow indicator glyph | No | diff --git a/components/TabBar/TabBar.preview.md b/components/TabBar/TabBar.preview.md new file mode 100644 index 0000000..002f1be --- /dev/null +++ b/components/TabBar/TabBar.preview.md @@ -0,0 +1,67 @@ +--- +kind: preview +component: TabBar +version: 1 +--- + +## Display only + +```props +items: + - label: "Overview" + value: "overview" + - label: "Details" + value: "details" + - label: "Settings" + value: "settings" + - label: "About" + value: "about" +selectedIndex: 1 +``` + +## Arrow navigation + +```props +items: + - label: "index.ts" + value: "1" + - label: "utils.ts" + value: "2" + - label: "config.ts" + value: "3" + - label: "types.ts" + value: "4" + - label: "test.ts" + value: "5" +selectedIndex: 0 +navigationKeys: "arrow-only" +``` + +## Tab navigation + +```props +items: + - label: "Overview" + value: "1" + - label: "Details" + value: "2" + - label: "Settings" + value: "3" +selectedIndex: 0 +navigationKeys: "tab-only" +``` + +## No loop + +```props +items: + - label: "First" + value: "1" + - label: "Second" + value: "2" + - label: "Third" + value: "3" +selectedIndex: 0 +navigationKeys: "all" +loop: false +``` diff --git a/components/TabBar/TabBar.test.md b/components/TabBar/TabBar.test.md new file mode 100644 index 0000000..3bab38f --- /dev/null +++ b/components/TabBar/TabBar.test.md @@ -0,0 +1,424 @@ +--- +kind: test +component: TabBar +version: 1 +--- + +# TabBar Tests + +## renders all tabs when they fit + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } + - { label: "Gamma", value: "c" } +selectedIndex: 0 +terminalWidth: 80 +``` + +```expect +[Alpha] Beta Gamma +``` + +## selected tab uses inverse styling + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } +selectedIndex: 1 +terminalWidth: 80 +``` + +```expect +Alpha [Beta] +``` + +```style +- selector: tab(0) + color: textSecondary +- selector: tab(1) + color: selected + inverse: true +``` + +## navigates right on arrow key + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } + - { label: "Gamma", value: "c" } +selectedIndex: 0 +navigationKeys: "arrow-only" +onNavigate: callback +terminalWidth: 80 +``` + +```input +→ +``` + +```state +callback_fired: onNavigate +callback_args: [1] +``` + +## navigates left on arrow key + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } + - { label: "Gamma", value: "c" } +selectedIndex: 2 +navigationKeys: "arrow-only" +onNavigate: callback +terminalWidth: 80 +``` + +```input +← +``` + +```state +callback_fired: onNavigate +callback_args: [1] +``` + +## wraps from last to first when loop is true + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } +selectedIndex: 1 +navigationKeys: "arrow-only" +onNavigate: callback +loop: true +terminalWidth: 80 +``` + +```input +→ +``` + +```state +callback_fired: onNavigate +callback_args: [0] +``` + +## wraps from first to last when loop is true + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } +selectedIndex: 0 +navigationKeys: "arrow-only" +onNavigate: callback +loop: true +terminalWidth: 80 +``` + +```input +← +``` + +```state +callback_fired: onNavigate +callback_args: [1] +``` + +## does not wrap when loop is false + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } +selectedIndex: 1 +navigationKeys: "arrow-only" +onNavigate: callback +loop: false +terminalWidth: 80 +``` + +```input +→ +``` + +```expect +Alpha [Beta] +``` + +## fires onSelect on Enter + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } +selectedIndex: 1 +navigationKeys: "arrow-only" +onSelect: callback +terminalWidth: 80 +``` + +```input +enter +``` + +```state +callback_fired: onSelect +callback_args: [{ label: "Beta", value: "b" }, 1] +``` + +## tab key navigates when navigationKeys is tab-only + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } + - { label: "Gamma", value: "c" } +selectedIndex: 0 +navigationKeys: "tab-only" +onNavigate: callback +terminalWidth: 80 +``` + +```input +tab +``` + +```state +callback_fired: onNavigate +callback_args: [1] +``` + +## shift+tab navigates backward when navigationKeys is tab-only + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } + - { label: "Gamma", value: "c" } +selectedIndex: 2 +navigationKeys: "tab-only" +onNavigate: callback +terminalWidth: 80 +``` + +```input +shift+tab +``` + +```state +callback_fired: onNavigate +callback_args: [1] +``` + +## arrow keys ignored when navigationKeys is tab-only + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } +selectedIndex: 0 +navigationKeys: "tab-only" +onNavigate: callback +terminalWidth: 80 +``` + +```input +→ +``` + +```expect +[Alpha] Beta +``` + +## all mode accepts both arrows and tab + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } + - { label: "Gamma", value: "c" } +selectedIndex: 0 +navigationKeys: "all" +onNavigate: callback +terminalWidth: 80 +``` + +```input +tab +``` + +```state +callback_fired: onNavigate +callback_args: [1] +``` + +--- + +# Carousel overflow tests + +## shows arrows when tabs overflow + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } + - { label: "Gamma", value: "c" } + - { label: "Delta", value: "d" } + - { label: "Epsilon", value: "e" } +selectedIndex: 2 +terminalWidth: 30 +``` + +```expect +← Beta [Gamma] Delta → +``` + +## shows only right arrow at start + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } + - { label: "Gamma", value: "c" } + - { label: "Delta", value: "d" } + - { label: "Epsilon", value: "e" } +selectedIndex: 0 +terminalWidth: 30 +``` + +```expect +[Alpha] Beta Gamma → +``` + +## shows only left arrow at end + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } + - { label: "Gamma", value: "c" } + - { label: "Delta", value: "d" } + - { label: "Epsilon", value: "e" } +selectedIndex: 4 +terminalWidth: 30 +``` + +```expect +← Gamma Delta [Epsilon] +``` + +## arrow indicators use textSecondary + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } + - { label: "Gamma", value: "c" } + - { label: "Delta", value: "d" } + - { label: "Epsilon", value: "e" } +selectedIndex: 2 +terminalWidth: 30 +``` + +```style +- selector: component("IconArrowLeft") + color: textSecondary +- selector: component("IconArrowRight") + color: textSecondary +``` + +## suffix reduces available tab space + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } + - { label: "Gamma", value: "c" } + - { label: "Delta", value: "d" } +selectedIndex: 0 +suffixWidth: 6 +terminalWidth: 40 +``` + +```style +- selector: table + note: suffix width is subtracted from available space before carousel calculation +``` + +## narrow terminal shows only selected tab + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } + - { label: "Gamma", value: "c" } +selectedIndex: 1 +terminalWidth: 12 +``` + +```expect +← [Beta] → +``` + +--- + +# Accessibility assertions + +## screen reader shows all tabs as flat text + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } + - { label: "Gamma", value: "c" } +selectedIndex: 1 +label: "Files" +screen_reader: true +terminalWidth: 30 +``` + +```expect +Files: current tab: Beta, Alpha, Gamma +``` + +## screen reader omits arrow indicators + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } + - { label: "Gamma", value: "c" } + - { label: "Delta", value: "d" } + - { label: "Epsilon", value: "e" } +selectedIndex: 2 +screen_reader: true +terminalWidth: 30 +``` + +```expect +current tab: Gamma, Alpha, Beta, Delta, Epsilon +``` + +## screen reader renders suffix after tab list + +```props +items: + - { label: "Alpha", value: "a" } + - { label: "Beta", value: "b" } +selectedIndex: 0 +label: "Files" +suffix: "[1/2]" +screen_reader: true +terminalWidth: 80 +``` + +```expect +Files: current tab: Alpha, Beta [1/2] +``` diff --git a/components/Table/Table.md b/components/Table/Table.md new file mode 100644 index 0000000..f5c0849 --- /dev/null +++ b/components/Table/Table.md @@ -0,0 +1,177 @@ +--- +kind: component +name: Table +description: Auto-sizing tabular data display with word wrapping, theme-aware borders, and per-cell coloring. +version: 2 +category: display + +tokens: + colors: [textPrimary, borderNeutral] + +types: + TableCell: + description: > + A single cell value. Either a plain string (uses default text color) + or a [text, color] tuple for per-cell coloring with semantic color tokens. + union: + - string + - "[text: string, color: SemanticColor]" + + TableAlignment: + description: Column text alignment. + enum: [left, center, right] + +props: + rows: + type: array> + required: true + description: > + Data rows. Each inner array is one row; each element is a TableCell. + Rows may have unequal lengths — missing cells render as empty. + + headers: + type: array + required: false + description: > + Optional header row rendered bold. Participates in column-width calculation + alongside data rows. + + width: + type: number + required: false + description: > + Maximum table width in columns. Capped at the detected container width. + When omitted, the table fills all available horizontal space. + + borderStyle: + type: '"single" | "none"' + required: false + default: '"single"' + description: > + Border rendering style. "single" draws box-drawing characters around cells; + "none" removes all border and padding characters, using two-space column gaps. + + align: + type: array + required: false + description: > + Per-column text alignment. Indices correspond to column positions. + Unspecified columns default to "left". + +accessibility: + role: table + properties: + aria-label: "Data table" + announce: + on_mount: "Table with {row_count} rows and {col_count} columns" + screen_reader_adaptations: + - when: screen reader detected + change: > + Borders are forced to "none". Box-drawing characters produce noise + in linear reading; removing them yields clean tab-separated text + that assistive tools can parse. + +dependencies: + tokens: + - name: textPrimary + kind: color + usage: "Body text and header text" + required: true + - name: borderNeutral + kind: color + usage: "Box-drawing border characters" + required: false + components: [] + dependents: [] +--- + +# Table + +Renders tabular data with automatically sized columns, word wrapping, and +theme-aware borders. Supports optional headers and per-cell semantic coloring. + +## Visual rules + +- **Body text** MUST use `textPrimary` color +- **Borders** (when `borderStyle` is "single") MUST use `borderNeutral` color +- **Headers** MUST be rendered **bold** within the same `textPrimary` color +- **Per-cell coloring**: when a cell is a `[text, color]` tuple, the text MUST render + in the specified semantic color instead of `textPrimary` +- **No-border mode** ("none"): borders, padding, and separator lines MUST be removed; + columns MUST be separated by two spaces + +## Layout rules + +Column widths are computed automatically to fill the resolved width: + +1. **Resolved width** = `min(width prop, available container width)`; when `width` + is omitted, resolved width equals available container width. +2. **Available container width** is detected via layout measurement. A minimum of + 20 columns is enforced. +3. Column widths are distributed proportionally based on the longest content in each + column (across both headers and data rows). +4. Words exceeding a column's content width are broken at the column boundary to + prevent truncation. +5. Word wrapping happens on word boundaries when possible. +6. In "single" border mode, each cell has 1-character left and right padding. + In "none" border mode, padding is zero. + +## Rendering example + +Given: + +``` +headers: ["Command", "Description"] +rows: [ + ["/help", "Show available commands"], + ["/theme", "Change color theme"] +] +borderStyle: "single" +``` + +``` +┌──────────┬────────────────────────┐ +│ Command │ Description │ +├──────────┼────────────────────────┤ +│ /help │ Show available commands│ +│ /theme │ Change color theme │ +└──────────┴────────────────────────┘ +``` + +With `borderStyle: "none"`: + +``` +Command Description +/help Show available commands +/theme Change color theme +``` + +## Rendering example (per-cell coloring) + +Given: + +``` +rows: [ + [["ERR_TIMEOUT", statusError], "Connection timed out"], + ["OK_200", "Success response"] +] +``` + +"ERR_TIMEOUT" renders in `statusError` color; all other cells use `textPrimary`. + +## Edge cases + +- **Empty rows array**: MUST render an empty container (no visible output) +- **Zero columns** (all rows empty): MUST render an empty container +- **Ragged rows** (unequal lengths): missing cells MUST render as empty strings +- **Long words**: MUST be broken at column boundary rather than truncated with ellipsis +- **Narrow terminal** (< 20 columns): minimum width of 20 MUST be enforced +- **Headers without data rows**: headers MUST NOT be rendered (requires at least one data row) +- **Container resizing**: column widths MUST recalculate when terminal width changes + +## Dependencies + +| Dependency | Kind | Usage | Required | +| --------------- | ----- | ----------------------------- | -------- | +| `textPrimary` | color | Body text and header text | Yes | +| `borderNeutral` | color | Box-drawing border characters | No | diff --git a/components/Table/Table.preview.md b/components/Table/Table.preview.md new file mode 100644 index 0000000..de7715e --- /dev/null +++ b/components/Table/Table.preview.md @@ -0,0 +1,46 @@ +--- +kind: preview +component: Table +version: 1 +--- + +## Basic + +```props +headers: ["Command", "Description"] +rows: + - ["/help", "Show all commands"] + - ["/theme", "Change color theme"] + - ["/clear", "Clear conversation"] +``` + +## Borderless key-value + +```props +rows: + - ["Type", "stdio"] + - ["Status", "Connected"] + - ["Model", "GPT-4"] +borderStyle: "none" +``` + +## Right-aligned numbers + +```props +headers: ["Metric", "Value", "Unit"] +rows: + - ["Latency", "42", "ms"] + - ["Tokens", "1234", "tok"] + - ["Cost", "0.03", "USD"] +align: ["left", "right", "left"] +``` + +## Width-constrained + +```props +headers: ["Error", "Message"] +rows: + - ["E001", "Missing required field 'name' in configuration"] + - ["E002", "Connection timeout after 30 seconds"] +width: 60 +``` diff --git a/components/Table/Table.test.md b/components/Table/Table.test.md new file mode 100644 index 0000000..b8c025c --- /dev/null +++ b/components/Table/Table.test.md @@ -0,0 +1,189 @@ +--- +kind: test +component: Table +version: 1 +--- + +# Table Tests + +## renders basic rows without headers + +```props +rows: + - ["/help", "Show commands"] + - ["/theme", "Change theme"] +borderStyle: "none" +``` + +```expect +/help Show commands +/theme Change theme +``` + +## renders headers in bold + +```props +headers: ["Command", "Description"] +rows: + - ["/help", "Show commands"] +borderStyle: "none" +``` + +```expect +Command Description +/help Show commands +``` + +```style +- selector: row(header) + bold: true +``` + +## renders single borders with borderNeutral color + +```props +headers: ["Name"] +rows: + - ["Alpha"] +borderStyle: "single" +``` + +```style +- selector: border + color: borderNeutral +``` + +## renders body text in textPrimary + +```props +rows: + - ["Alpha", "Beta"] +borderStyle: "none" +``` + +```style +- selector: cell(0, 0) + color: textPrimary +- selector: cell(0, 1) + color: textPrimary +``` + +## applies per-cell semantic color + +```props +rows: + - [["ERR", "statusError"], "Timed out"] +borderStyle: "none" +``` + +```style +- selector: cell(0, 0) + color: statusError +- selector: cell(0, 1) + color: textPrimary +``` + +## handles ragged rows with missing cells + +```props +rows: + - ["Alpha", "Beta", "Gamma"] + - ["Delta"] +borderStyle: "none" +``` + +```expect +Alpha Beta Gamma +Delta +``` + +## renders nothing for empty rows + +```props +rows: [] +``` + +```expect + +``` + +## wraps long words at column boundary + +```props +rows: + - ["Supercalifragilisticexpialidocious", "Short"] +width: 30 +borderStyle: "none" +``` + +```expect +Supercalifragilisticex Short +pialidocious +``` + +## respects per-column alignment + +```props +headers: ["Left", "Right"] +rows: + - ["a", "b"] +align: ["left", "right"] +borderStyle: "none" +``` + +```style +- selector: column(0) + align: left +- selector: column(1) + align: right +``` + +## removes borders when borderStyle is none + +```props +rows: + - ["Alpha", "Beta"] +borderStyle: "none" +``` + +```expect +Alpha Beta +``` + +## caps width at the width prop + +```props +rows: + - ["Alpha", "Beta"] +width: 25 +borderStyle: "single" +``` + +```style +- selector: table + max_width: 25 +``` + +--- + +# Accessibility assertions + +## screen reader forces borders off + +```props +rows: + - ["Alpha", "Beta"] +headers: ["Col1", "Col2"] +borderStyle: "single" +screen_reader: true +``` + +```style +- selector: border + visible: false +``` + +```expect +Col1 Col2 +Alpha Beta +``` diff --git a/components/TextHeading/TextHeading.md b/components/TextHeading/TextHeading.md new file mode 100644 index 0000000..5684365 --- /dev/null +++ b/components/TextHeading/TextHeading.md @@ -0,0 +1,85 @@ +--- +kind: component +name: TextHeading +description: Renders bold subheading text with an optional error variant. +version: 2 +category: display + +tokens: + colors: [textSecondary, statusError] + icons: [] + +props: + type: + type: string + required: false + description: > + Optional variant. When set to "error", the heading uses `statusError` + color instead of the default `textSecondary`. + + children: + type: string + required: true + description: The text content to display. + +dependencies: + tokens: + - name: textSecondary + kind: color + usage: "Default heading text color" + required: true + - name: statusError + kind: color + usage: "Error variant heading color" + required: false + components: [] + +accessibility: + role: heading + properties: + aria-level: "2" + aria-label: "{children}" + announce: + on_mount: "Heading: {children}" + screen_reader_adaptations: + - when: screen reader detected + change: "No visual adaptations needed; text content is already readable" +--- + +# TextHeading + +A styled subheading used for section titles and secondary labels. Always bold, +defaulting to `textSecondary` color. The `"error"` variant switches to +`statusError` for error-context headings. + +## Visual rules + +- Text MUST be rendered **bold** +- Default color MUST be `textSecondary` +- When `type` is `"error"`, color MUST switch to `statusError` +- There MUST NOT be additional decoration, padding, or prefix + +## Rendering example + +Default: + +``` +Background Subagents +^^^^^^^^^^^^^^^^^^^^ +bold / textSecondary +``` + +Error variant (`type: "error"`): + +``` +Something went wrong +^^^^^^^^^^^^^^^^^^^^ +bold / statusError +``` + +## Dependencies + +| Dependency | Kind | Usage | Required | +| --------------- | ----- | --------------------------- | -------- | +| `textSecondary` | color | Default heading text color | Yes | +| `statusError` | color | Error variant heading color | No | diff --git a/components/TextHeading/TextHeading.preview.md b/components/TextHeading/TextHeading.preview.md new file mode 100644 index 0000000..56f9fda --- /dev/null +++ b/components/TextHeading/TextHeading.preview.md @@ -0,0 +1,18 @@ +--- +kind: preview +component: TextHeading +version: 1 +--- + +## Default + +```props +text: "Section Heading" +``` + +## Error + +```props +text: "Error Details" +type: error +``` diff --git a/components/TextHeading/TextHeading.test.md b/components/TextHeading/TextHeading.test.md new file mode 100644 index 0000000..ce2bdc2 --- /dev/null +++ b/components/TextHeading/TextHeading.test.md @@ -0,0 +1,52 @@ +--- +kind: test +component: TextHeading +version: 1 +--- + +# TextHeading Tests + +## renders children as bold text + +```props +children: "Background Subagents" +``` + +```expect +Background Subagents +``` + +```style +- selector: label("Background Subagents") + bold: true + color: textSecondary +``` + +## renders error variant + +```props +type: "error" +children: "Something went wrong" +``` + +```expect +Something went wrong +``` + +```style +- selector: label("Something went wrong") + bold: true + color: statusError +``` + +## defaults to textSecondary when type is omitted + +```props +children: "Section Title" +``` + +```style +- selector: label("Section Title") + bold: true + color: textSecondary +``` diff --git a/components/TextSpinner/TextSpinner.md b/components/TextSpinner/TextSpinner.md new file mode 100644 index 0000000..a4cc3c1 --- /dev/null +++ b/components/TextSpinner/TextSpinner.md @@ -0,0 +1,224 @@ +--- +kind: component +name: TextSpinner +description: Animated spinner icon with optional shimmer-gradient text label. +version: 2 +category: feedback + +tokens: + colors: [textSecondary] + icons: [CIRCLE_FILLED, SPINNER_FILLED, SPINNER_RING, SPINNER_CIRCLE] + +props: + text: + type: string + required: false + description: > + Optional label displayed alongside the spinner. When present, the text + receives a shimmer gradient wave animation that sweeps across its characters. + + icon: + type: boolean + required: false + default: true + description: > + Whether to show the animated spinner icon. When false, only the shimmer + text label is rendered. + + variant: + type: TextSpinnerVariant + required: false + default: "default" + description: > + Color variant controlling the gradient ramp used for both the spinner icon + and the shimmer text. Each variant maps to a different chromatic ramp. + + loop: + type: number | false + required: false + description: > + Controls shimmer repetition. undefined (default) loops infinitely, + false runs a single cycle then stops, a positive number runs exactly + N cycles then stops. Zero cycles means the animation never starts. + + onAnimationEnd: + type: callback() → void + required: false + description: > + Called when a finite shimmer loop completes. Only fires when loop is + false or a number. Not called for infinite loops. + +types: + TextSpinnerVariant: + description: Predefined color pairings for the shimmer gradient effect. + values: ["default", "brand", "info", "selected", "placeholder"] + semantics: + default: Neutral gray ramp + brand: Primary brand chromatic ramp + info: Informational blue chromatic ramp + selected: Selection/focus chromatic ramp + placeholder: Dimmer neutral ramp (lower contrast than default) + +animation: + spinner: + frames: [CIRCLE_FILLED, SPINNER_FILLED, SPINNER_RING, SPINNER_CIRCLE] + pattern: bounce + description: > + Frames play in a bounce pattern: ●→◉→◎→○→◎→◉→●→… + The spinner advances at 1/3 the tick rate of the shimmer wave + (SPINNER_TICK_DIVISOR = 3), creating a slower pulsing effect relative + to the text animation. + disable_when: [screen_reader] + + shimmer: + description: > + A gradient wave sweeps left-to-right across the text. The wave head + is the brightest color from the variant gradient; trailing characters + fade through decreasing intensities (comet-tail effect). After the + wave exits the text, a pause occurs before the next cycle. + gradient_length: 8 + pattern: sweep-left-to-right with pause + timing: + normal: { interval_ms: 100, pause_ms: 1000 } + alt_screen: { interval_ms: 30, pause_ms: 300 } + disable_when: [screen_reader] + +accessibility: + role: status + properties: + aria-label: "Loading indicator" + aria-live: polite + announce: + on_mount: "Loading" + on_change: "{text}" + screen_reader_adaptations: + - when: screen reader detected + change: > + Animation is completely disabled. Text is rendered in the variant's + rest color as static text. The spinner icon shows the first frame + (CIRCLE_FILLED) in rest color. + - when: screen reader detected + change: > + If loop is finite, onAnimationEnd fires immediately (animation is + considered instantly complete). + +dependencies: + tokens: + - name: textSecondary + kind: color + usage: "Fallback text color" + required: false + - name: CIRCLE_FILLED + kind: icon + usage: "Spinner frame glyph (●)" + required: true + - name: SPINNER_FILLED + kind: icon + usage: "Spinner frame glyph (◉)" + required: true + - name: SPINNER_RING + kind: icon + usage: "Spinner frame glyph (◎)" + required: true + - name: SPINNER_CIRCLE + kind: icon + usage: "Spinner frame glyph (○)" + required: true + components: [] + dependents: [] +--- + +# TextSpinner + +An animated spinner with optional shimmer text label. The spinner icon bounces +through frame glyphs while its color pulses through the variant gradient. When +text is provided, a gradient wave sweeps across each character creating a +comet-tail shimmer effect. + +## Visual rules + +- The spinner icon is `aria-hidden` (decorative) — it MUST NOT be announced +- Each variant MUST define a **rest color** and an **8-color gradient** array +- At rest (animation done or paused), all text and the icon MUST use the rest color +- During animation, the wave head MUST use the brightest gradient color (index 0) + and trailing characters MUST fade through the gradient (indices 1–7) +- The spinner icon color MUST pulse through the same gradient at 1/3 speed +- A space MUST separate the spinner icon from the label text +- When `icon` is false and no `text` is provided, the component MUST render nothing +- When `icon` is true and no `text` is provided, the spinner icon MUST be followed by a single space + +## Rendering modes + +Three rendering modes based on prop combinations: + +| icon | text | Result | +| ----- | ------- | ------------------------------------- | +| true | present | Spinner icon + space + shimmer text | +| true | absent | Spinner icon + space (icon-only mode) | +| false | present | Shimmer text only (no icon) | + +## Rendering example + +Given `text="Loading…"`, `variant="default"`, at mid-animation: + +``` +● Loading… +^ ^^^^^^^^ +| shimmer gradient wave (comet-tail fading left) +spinner icon (bouncing through ●◉◎○ frames) +``` + +At rest (animation complete): + +``` +● Loading… +``` + +All characters in the variant's rest color. + +## Animation behavior + +### Shimmer wave + +The shimmer treats each character as a position. The wave head advances one +position per tick. Characters behind the head receive decreasing gradient +colors (distance 0 = brightest, distance 7 = dimmest). Characters outside +the gradient window use the rest color. + +After the wave exits the text (head > textLength + gradientLength), a pause +period occurs before the next cycle begins. + +### Spinner icon bounce + +The spinner icon frames cycle in a bounce pattern through `[●, ◉, ◎, ○]`: +forward then reverse, creating smooth pulsing. The frame advances every +3 ticks (SPINNER_TICK_DIVISOR), making it visually slower than the shimmer. + +### Timing presets + +- **Normal mode**: 100ms interval, 1000ms pause between cycles +- **Alt-screen mode**: 30ms interval, 300ms pause (faster for immersive UIs) + +### Loop control + +- `loop` omitted: infinite animation +- `loop={false}`: single cycle, then static at rest color; fires `onAnimationEnd` +- `loop={N}`: exactly N cycles, then static; fires `onAnimationEnd` +- `loop={0}`: no animation ever starts; fires `onAnimationEnd` immediately + +## Edge cases + +- **Text changes**: animation MUST reset to tick 0 and cycle 0 when `text` prop changes +- **Screen reader detected**: animation MUST NOT start; text MUST be static in rest color; + finite loops MUST fire `onAnimationEnd` immediately +- **Empty text with icon=false**: MUST render nothing (returns null) + +## Dependencies + +| Dependency | Kind | Usage | Required | +| ---------------- | ----- | ----------------------- | -------- | +| `textSecondary` | color | Fallback text color | No | +| `CIRCLE_FILLED` | icon | Spinner frame glyph (●) | Yes | +| `SPINNER_FILLED` | icon | Spinner frame glyph (◉) | Yes | +| `SPINNER_RING` | icon | Spinner frame glyph (◎) | Yes | +| `SPINNER_CIRCLE` | icon | Spinner frame glyph (○) | Yes | diff --git a/components/TextSpinner/TextSpinner.preview.md b/components/TextSpinner/TextSpinner.preview.md new file mode 100644 index 0000000..3e6df03 --- /dev/null +++ b/components/TextSpinner/TextSpinner.preview.md @@ -0,0 +1,45 @@ +--- +kind: preview +component: TextSpinner +version: 1 +--- + +## Default + +```props +text: "Loading" +``` + +## Icon only + +```props + +``` + +## Label only + +```props +text: "Unlimited reqs." +icon: false +``` + +## Placeholder + +```props +text: "Waiting for input" +variant: "placeholder" +``` + +## Brand + +```props +text: "Thinking" +variant: "brand" +``` + +## Info + +```props +text: "Compacting conversation history" +variant: "info" +``` diff --git a/components/TextSpinner/TextSpinner.test.md b/components/TextSpinner/TextSpinner.test.md new file mode 100644 index 0000000..fddea64 --- /dev/null +++ b/components/TextSpinner/TextSpinner.test.md @@ -0,0 +1,404 @@ +--- +kind: test +component: TextSpinner +version: 1 +--- + +# TextSpinner rendering tests + +## renders icon-only by default + +```props +{} +``` + +```expect +● +``` + +## renders icon with text label + +```props +text: "Loading" +``` + +```expect +● Loading +``` + +## renders text only when icon is false + +```props +text: "Please wait" +icon: false +``` + +```expect +Please wait +``` + +## renders nothing when icon is false and no text + +```props +icon: false +``` + +```expect + +``` + +## icon followed by space when no text + +```props +icon: true +``` + +```expect +● +``` + +--- + +# Variant tests + +## default variant uses neutral colors + +```props +text: "Working" +variant: "default" +``` + +```style +- selector: label("Working") + color: variant-rest(default) +``` + +## brand variant uses brand ramp + +```props +text: "Working" +variant: "brand" +``` + +```style +- selector: label("Working") + color: variant-rest(brand) +``` + +## info variant uses info ramp + +```props +text: "Working" +variant: "info" +``` + +```style +- selector: label("Working") + color: variant-rest(info) +``` + +## selected variant uses selected ramp + +```props +text: "Working" +variant: "selected" +``` + +```style +- selector: label("Working") + color: variant-rest(selected) +``` + +## placeholder variant uses dimmer neutral ramp + +```props +text: "Working" +variant: "placeholder" +``` + +```style +- selector: label("Working") + color: variant-rest(placeholder) +``` + +--- + +# Animation behavior tests + +## spinner icon bounces through frames + +The spinner icon cycles in a bounce pattern: ●→◉→◎→○→◎→◉→●… +Each frame change occurs every SPINNER_TICK_DIVISOR (3) ticks. + +```props +text: "Test" +``` + +```animation +target: spinner-icon +frame_sequence: ["●", "◉", "◎", "○", "◎", "◉", "●"] +pattern: bounce +tick_divisor: 3 +``` + +## shimmer wave sweeps left to right + +The gradient wave head starts at position 0 and advances one position per tick. +Characters behind the head show fading gradient colors (comet-tail). +Characters ahead of the head show the rest color. + +```props +text: "AB" +variant: "default" +``` + +```animation +target: shimmer +total_positions: 10 +description: > + textLength(2) + gradientLength(8) = 10 total positions. + Wave head advances from 0 to 9, then a pause period occurs. +``` + +## shimmer pauses between cycles + +```props +text: "Hi" +variant: "default" +``` + +```animation +target: shimmer +pause: + normal_ms: 1000 + alt_screen_ms: 300 +description: > + After the wave exits the text (head > totalPositions), + rest color is shown for the pause duration before the next cycle. +``` + +## animation runs faster in alt-screen mode + +```props +text: "Fast" +``` + +```animation +timing: + normal: { interval_ms: 100, pause_ms: 1000 } + alt_screen: { interval_ms: 30, pause_ms: 300 } +``` + +--- + +# Loop control tests + +## infinite loop by default (loop omitted) + +```props +text: "Forever" +``` + +```animation +loop: infinite +description: Animation cycles indefinitely; onAnimationEnd never fires. +``` + +## single cycle when loop is false + +```props +text: "Once" +loop: false +onAnimationEnd: callback +``` + +```animation +loop: 1 +fires_onAnimationEnd: true +description: > + Runs exactly one cycle, then stops at rest color. + onAnimationEnd fires after the first cycle completes. +``` + +## exact N cycles when loop is a number + +```props +text: "Thrice" +loop: 3 +onAnimationEnd: callback +``` + +```animation +loop: 3 +fires_onAnimationEnd: true +description: > + Runs exactly 3 cycles, then stops at rest color. + onAnimationEnd fires after the third cycle completes. +``` + +## zero cycles — animation never starts + +```props +text: "Static" +loop: 0 +onAnimationEnd: callback +``` + +```animation +loop: 0 +fires_onAnimationEnd: true +description: > + Animation never starts. Text is immediately rendered in rest color. + onAnimationEnd fires immediately on mount. +``` + +--- + +# Text change behavior + +## animation resets when text changes + +```props +text: "First" +``` + +```animation +description: Animation is running at some tick > 0. +``` + +```props +text: "Second" +``` + +```animation +tick: 0 +cycle: 0 +description: > + When text prop changes, tick resets to 0, cycle count resets to 0, + and the animation starts fresh from the beginning. +``` + +--- + +# Accessibility tests + +## screen reader: animation disabled, text static + +```props +text: "Loading data" +``` + +```accessibility +screen_reader: true +announce: "Loading data" +animation: disabled +description: > + When a screen reader is detected, no animation runs. Text is rendered + in the variant rest color as static content. +``` + +## screen reader: spinner shows first frame at rest + +```props +icon: true +``` + +```accessibility +screen_reader: true +spinner_frame: "●" +spinner_color: rest +description: > + Spinner icon shows CIRCLE_FILLED (first frame) in rest color. + Icon is aria-hidden and not announced. +``` + +## screen reader: finite loop fires onAnimationEnd immediately + +```props +text: "Quick" +loop: false +onAnimationEnd: callback +``` + +```accessibility +screen_reader: true +fires_onAnimationEnd: true +timing: immediate +description: > + When screen reader is detected and loop is finite, + onAnimationEnd fires immediately (no animation occurs). +``` + +## spinner icon is aria-hidden + +```props +text: "Working" +icon: true +``` + +```accessibility +- selector: spinner-icon + aria-hidden: true + description: > + The spinner icon glyph is always aria-hidden="true" since it is + purely decorative. Only the text label carries semantic content. +``` + +--- + +# Style assertions + +## spinner icon receives gradient color during animation + +```props +text: "Test" +variant: "default" +``` + +```style +- selector: spinner-icon + color: variant-gradient(default) + description: > + During animation, the spinner icon color pulses through + the variant's gradient array at 1/3 the shimmer tick rate. +``` + +## text characters receive wave gradient colors + +```props +text: "Hello" +variant: "default" +``` + +```style +- selector: label("H") + color: variant-gradient(default) + description: > + Characters at and near the wave head receive gradient colors. + The head position gets the brightest color (gradient[0]), + trailing characters fade through gradient[1..7]. +- selector: label("o") + color: variant-rest(default) + description: > + Characters far from the wave head (outside gradient window) + use the rest color. +``` + +## at rest all text uses rest color + +```props +text: "Done" +variant: "brand" +loop: false +``` + +```style +- selector: label("Done") + color: variant-rest(brand) + description: > + After animation completes, all characters use the variant rest color. +- selector: spinner-icon + color: variant-rest(brand) + description: > + Spinner icon also returns to rest color and shows first frame (●). +``` diff --git a/components/TextTitle/TextTitle.md b/components/TextTitle/TextTitle.md new file mode 100644 index 0000000..4065fe0 --- /dev/null +++ b/components/TextTitle/TextTitle.md @@ -0,0 +1,80 @@ +--- +kind: component +name: TextTitle +description: Renders bold primary heading text with an optional error variant. +version: 2 +category: display + +tokens: + colors: [statusError] + icons: [] + +props: + type: + type: string + required: false + description: > + Optional variant. When set to "error", the title uses `statusError` + color instead of the terminal default foreground. + + children: + type: string + required: true + description: The text content to display. + +dependencies: + tokens: + - name: statusError + kind: color + usage: "Error variant title color" + required: false + components: [] + +accessibility: + role: heading + properties: + aria-level: "1" + aria-label: "{children}" + announce: + on_mount: "Title: {children}" + screen_reader_adaptations: + - when: screen reader detected + change: "No visual adaptations needed; text content is already readable" +--- + +# TextTitle + +A primary heading for top-level section titles. Always bold, using the +terminal's default foreground color. The `"error"` variant overrides the +color to `statusError`. + +## Visual rules + +- Text MUST be rendered **bold** +- Default color MUST be the terminal's native foreground (no explicit color token) +- When `type` is `"error"`, color MUST switch to `statusError` +- There MUST NOT be additional decoration, padding, or prefix + +## Rendering example + +Default: + +``` +Background Tasks +^^^^^^^^^^^^^^^^ +bold / default foreground +``` + +Error variant (`type: "error"`): + +``` +Error +^^^^^ +bold / statusError +``` + +## Dependencies + +| Dependency | Kind | Usage | Required | +| ------------- | ----- | ------------------------- | -------- | +| `statusError` | color | Error variant title color | No | diff --git a/components/TextTitle/TextTitle.preview.md b/components/TextTitle/TextTitle.preview.md new file mode 100644 index 0000000..baebc4a --- /dev/null +++ b/components/TextTitle/TextTitle.preview.md @@ -0,0 +1,18 @@ +--- +kind: preview +component: TextTitle +version: 1 +--- + +## Default + +```props +text: "Welcome to TUIkit" +``` + +## Error + +```props +text: "Something went wrong" +type: error +``` diff --git a/components/TextTitle/TextTitle.test.md b/components/TextTitle/TextTitle.test.md new file mode 100644 index 0000000..5641c1f --- /dev/null +++ b/components/TextTitle/TextTitle.test.md @@ -0,0 +1,51 @@ +--- +kind: test +component: TextTitle +version: 1 +--- + +# TextTitle Tests + +## renders children as bold text + +```props +children: "Background Tasks" +``` + +```expect +Background Tasks +``` + +```style +- selector: label("Background Tasks") + bold: true +``` + +## renders error variant with statusError color + +```props +type: "error" +children: "Error" +``` + +```expect +Error +``` + +```style +- selector: label("Error") + bold: true + color: statusError +``` + +## uses no explicit color when type is omitted + +```props +children: "Title" +``` + +```style +- selector: label("Title") + bold: true + color: null +``` diff --git a/components/TimelineItem/TimelineItem.md b/components/TimelineItem/TimelineItem.md new file mode 100644 index 0000000..946160a --- /dev/null +++ b/components/TimelineItem/TimelineItem.md @@ -0,0 +1,396 @@ +--- +kind: component +name: TimelineItem +description: A configurable timeline entry with status icon, title, description, sub-items, and expandable content. +version: 2 +category: display + +tokens: + colors: + [ + textPrimary, + textSecondary, + textTertiary, + statusSuccess, + statusError, + statusWarning, + statusInfo, + brand, + selected, + borderNeutral, + ] + icons: [CIRCLE_FILLED, CIRCLE_EMPTY, CROSS, WARNING, CHILD_LAST, CHILD_MIDDLE, CHILD_SKIP] + +props: + title: + type: string + required: true + description: > + Bold title text displayed after the status icon (e.g., tool name, action label). + + variant: + type: TimelineItemVariant + required: true + description: > + Visual variant controlling the icon glyph and color theme. + Can be a named variant string or a custom variant object. + + nested: + type: boolean + required: false + default: false + description: > + When true, hides the status icon, making this item appear as a child + of a parent timeline entry. Used for nested/indented entries. + + description: + type: string | ReactNode + required: false + description: > + Short inline text shown next to the title on the same line. + Strings are rendered in the variant's description color and truncated + with ellipsis at the terminal edge. Use ReactNode for rich inline + content (e.g., diff stats with colored counts). + + descriptionMultiline: + type: boolean + required: false + default: false + description: > + When true, description wraps across multiple lines instead of truncating. + Title and description are stacked vertically. Useful for prose-style + entries (e.g., error messages, info notices). + + subItems: + type: array + required: false + description: > + Detail lines rendered below the header with tree connectors (├/└). + Each sub-item can be a plain string or a ReactNode for rich content. + String sub-items are word-wrapped to fit the terminal width. + + expanded: + type: boolean + required: false + default: false + description: > + Whether the children content area is visible. Only relevant when + children are provided. + + children: + type: ReactNode + required: false + description: > + Content rendered inside a bordered box when expanded is true. + The box uses a left border in borderNeutral color with padding. + +types: + TimelineItemVariantName: + description: Predefined visual variant names for TimelineItem. + values: ["loading", "success", "error", "warning", "info", "muted", "brand", "selected"] + + TimelineItemCustomVariant: + description: > + Custom variant for edge cases where named variants don't fit. + Use sparingly — prefer named variants for consistency. + fields: + icon: + type: IconGlyph + required: false + default: CIRCLE_FILLED + description: Icon glyph to display + iconColor: + type: SemanticColor + required: true + description: Color for the icon + titleColor: + type: SemanticColor + required: false + default: terminal foreground + description: Color for the title text + descriptionColor: + type: SemanticColor + required: false + default: textSecondary + description: Color for the description text + subItemColor: + type: SemanticColor + required: false + default: textSecondary + description: Color for sub-item text + backgroundColor: + type: SemanticColor + required: false + description: Background color for the entire item (transparent if omitted) + + TimelineItemVariant: + description: Either a named variant string or a custom variant object. + union: [TimelineItemVariantName, TimelineItemCustomVariant] + + TimelineSubItem: + description: > + A sub-item entry — either a plain string (rendered with variant color) + or a ReactNode for rich inline content. Tree connectors are prepended + automatically. + union: [string, ReactNode] + +accessibility: + role: listitem + properties: + aria-label: "Timeline entry" + announce: + on_mount: "{variant}: {title}" + on_change: "{title} — {description}" + screen_reader_adaptations: + - when: screen reader detected + change: > + Icon glyphs are replaced with text prefixes indicating variant + (e.g., "[success]", "[error]"). Tree connectors are replaced with + indentation for clean linear reading. + +dependencies: + tokens: + - name: textPrimary + kind: color + usage: "Expanded sub-item text color" + required: true + - name: textSecondary + kind: color + usage: "Default description and sub-item text" + required: true + - name: textTertiary + kind: color + usage: "Muted variant icon and sub-item color" + required: false + - name: statusSuccess + kind: color + usage: "Success variant icon color" + required: false + - name: statusError + kind: color + usage: "Error variant icon and sub-item color" + required: false + - name: statusWarning + kind: color + usage: "Warning variant icon color" + required: false + - name: statusInfo + kind: color + usage: "Info variant icon color" + required: false + - name: brand + kind: color + usage: "Brand variant icon color" + required: false + - name: selected + kind: color + usage: "Selected variant icon color" + required: false + - name: borderNeutral + kind: color + usage: "Tree connectors and expanded content border" + required: true + - name: CIRCLE_FILLED + kind: icon + usage: "Icon for success, info, brand, selected variants" + required: true + - name: CIRCLE_EMPTY + kind: icon + usage: "Icon for loading and muted variants" + required: true + - name: CROSS + kind: icon + usage: "Icon for error variant" + required: false + - name: WARNING + kind: icon + usage: "Icon for warning variant" + required: false + - name: CHILD_LAST + kind: icon + usage: "Last sub-item tree connector (└)" + required: true + - name: CHILD_MIDDLE + kind: icon + usage: "Middle sub-item tree connector (├)" + required: false + - name: CHILD_SKIP + kind: icon + usage: "Continuation connector for non-last sub-items (│)" + required: true + components: [] + dependents: [] + +variants: + loading: + icon: CIRCLE_EMPTY + iconColor: textSecondary + titleColor: terminal foreground + descriptionColor: textSecondary + subItemColor: textSecondary + + success: + icon: CIRCLE_FILLED + iconColor: statusSuccess + titleColor: terminal foreground + descriptionColor: textSecondary + subItemColor: textSecondary + + error: + icon: CROSS + iconColor: statusError + titleColor: terminal foreground + descriptionColor: textSecondary + subItemColor: statusError + + warning: + icon: WARNING + iconColor: statusWarning + titleColor: terminal foreground + descriptionColor: textSecondary + subItemColor: textSecondary + + info: + icon: CIRCLE_FILLED + iconColor: statusInfo + titleColor: terminal foreground + descriptionColor: textSecondary + subItemColor: textSecondary + + muted: + icon: CIRCLE_EMPTY + iconColor: textTertiary + titleColor: terminal foreground + descriptionColor: textSecondary + subItemColor: textTertiary + + brand: + icon: CIRCLE_FILLED + iconColor: brand + titleColor: terminal foreground + descriptionColor: textSecondary + subItemColor: textSecondary + + selected: + icon: CIRCLE_FILLED + iconColor: selected + titleColor: terminal foreground + descriptionColor: textSecondary + subItemColor: textSecondary +--- + +# TimelineItem + +A configurable timeline entry primitive. Renders a status icon, bold title, +optional inline description, optional sub-items with tree connectors (├/└), +and an optional expandable bordered content area. + +## Visual rules + +- The **icon** MUST be rendered with the variant's `iconColor` and MUST be `aria-hidden` +- A space MUST separate the icon from the title +- The **title** MUST always be **bold**, colored with the variant's `titleColor` + (terminal foreground when unset) +- The **description** MUST follow the title on the same line, in `descriptionColor` +- String descriptions MUST be truncated with ellipsis at the terminal edge; + newlines in strings MUST be replaced with spaces +- When `descriptionMultiline` is true, title and description MUST stack vertically + and description MUST wrap instead of truncating +- When `nested` is true, the icon MUST be hidden entirely (no gutter space) +- **Sub-items** MUST be indented 2 columns and prefixed with tree connectors: + - Last sub-item MUST use `└` (CHILD_LAST) + - Non-last sub-items MUST use `│` (CHILD_SKIP) connector + - Tree connectors MUST be rendered in `borderNeutral` color and MUST be `aria-hidden` +- String sub-items MUST be word-wrapped to `terminalWidth - 4` characters +- When content is expanded, string sub-items MUST use `textPrimary` instead of `subItemColor` +- **Expanded content** MUST be rendered in a box with a left border in `borderNeutral`, + with left padding and vertical padding, appearing above sub-items +- ReactNode sub-items that are non-last MUST be wrapped with a continuous left border + (│ connector); last ReactNode sub-items MUST show └ prefix + +## Layout structure + +``` +[icon] [title] [description] ← header row + ┊ ← 2-col indent + │ ┌─────────────────────┐ + │ │ expanded content │ ← bordered box (when expanded=true) + │ └─────────────────────┘ + ├ sub-item 1 ← tree connector + text + └ sub-item 2 (last) ← last uses └ +``` + +## Rendering example + +Given `variant="success"`, `title="Grep"`, `description='"pattern" in *.ts'`, +`subItems=["5 files found"]`: + +``` +● Grep "pattern" in *.ts + └ 5 files found +``` + +Given `variant="error"`, `title="Build"`, `subItems=["Error on line 42", "Missing import"]`: + +``` +✗ Build + ├ Error on line 42 + └ Missing import +``` + +## Variant reference + +| Name | Icon | Icon Color | Sub-item Color | +| -------- | ---- | ------------- | -------------- | +| loading | ○ | textSecondary | textSecondary | +| success | ● | statusSuccess | textSecondary | +| error | ✗ | statusError | statusError | +| warning | ! | statusWarning | textSecondary | +| info | ● | statusInfo | textSecondary | +| muted | ○ | textTertiary | textTertiary | +| brand | ● | brand | textSecondary | +| selected | ● | selected | textSecondary | + +## Custom variants + +Custom variants allow specifying each color field individually. They should +be used sparingly for one-off cases (e.g., mode-specific tinting). Omitted +fields fall back to sensible defaults (CIRCLE_FILLED icon, terminal foreground +title, textSecondary description/sub-items, no background). + +## Edge cases + +- **Empty title**: when title is an empty string, title text MUST NOT be rendered + but the icon and description MUST still appear +- **Description with newlines**: in single-line mode (default), newlines MUST be + replaced with spaces before truncation +- **ReactNode description**: MUST be rendered as-is (no truncation or newline handling) +- **ReactNode sub-items**: non-last items MUST get a continuous left border for + multi-line content; last items MUST get a └ prefix with flex layout +- **No sub-items or children**: only the header row MUST render +- **Expanded without children**: content box MUST NOT appear (guarded by children != null) +- **Sub-item wrapping**: string sub-items MUST be hard-wrapped at terminal width minus + the indent and connector space (4 columns total); continuation lines + MUST use the same connector style (│ or space depending on position) + +## Dependencies + +| Dependency | Kind | Usage | Required | +| --------------- | ----- | ------------------------------------------------- | -------- | +| `textPrimary` | color | Expanded sub-item text color | Yes | +| `textSecondary` | color | Default description and sub-item text | Yes | +| `textTertiary` | color | Muted variant icon and sub-item color | No | +| `statusSuccess` | color | Success variant icon color | No | +| `statusError` | color | Error variant icon and sub-item color | No | +| `statusWarning` | color | Warning variant icon color | No | +| `statusInfo` | color | Info variant icon color | No | +| `brand` | color | Brand variant icon color | No | +| `selected` | color | Selected variant icon color | No | +| `borderNeutral` | color | Tree connectors and expanded content border | Yes | +| `CIRCLE_FILLED` | icon | Icon for success, info, brand, selected variants | Yes | +| `CIRCLE_EMPTY` | icon | Icon for loading and muted variants | Yes | +| `CROSS` | icon | Icon for error variant | No | +| `WARNING` | icon | Icon for warning variant | No | +| `CHILD_LAST` | icon | Last sub-item tree connector (└) | Yes | +| `CHILD_MIDDLE` | icon | Middle sub-item tree connector (├) | No | +| `CHILD_SKIP` | icon | Continuation connector for non-last sub-items (│) | Yes | diff --git a/components/TimelineItem/TimelineItem.preview.md b/components/TimelineItem/TimelineItem.preview.md new file mode 100644 index 0000000..09a7283 --- /dev/null +++ b/components/TimelineItem/TimelineItem.preview.md @@ -0,0 +1,73 @@ +--- +kind: preview +component: TimelineItem +version: 1 +--- + +## Loading + +```props +variant: "loading" +title: "Grep" +description: '"pattern" in *.ts' +``` + +## Success + +```props +variant: "success" +title: "Grep" +description: '"pattern" in *.ts' +subItems: + - "5 files found" +``` + +## Error + +```props +variant: "error" +title: "Bash" +description: "npm run build" +subItems: + - "Exit code 1" +``` + +## Warning + +```props +variant: "warning" +title: "Bash" +description: "rm -rf /" +subItems: + - "Rejected by you." +``` + +## Info + +```props +variant: "info" +title: "Compacted" +description: "Removed 42 messages" +``` + +## Muted + +```props +variant: "muted" +title: "Read" +description: "src/index.ts" +subItems: + - "24 lines" +``` + +## With multiple sub-items + +```props +variant: "success" +title: "Edit" +description: "src/auth.ts" +subItems: + - "Added JWT validation" + - "Removed deprecated handler" + - "+12 -8 lines" +``` diff --git a/components/TimelineItem/TimelineItem.test.md b/components/TimelineItem/TimelineItem.test.md new file mode 100644 index 0000000..c7864ea --- /dev/null +++ b/components/TimelineItem/TimelineItem.test.md @@ -0,0 +1,564 @@ +--- +kind: test +component: TimelineItem +version: 1 +--- + +# Named variant rendering + +## renders loading variant + +```props +title: "Fetching" +variant: "loading" +``` + +```expect +○ Fetching +``` + +```style +- selector: indicator(0) + color: textSecondary +``` + +## renders success variant + +```props +title: "Grep" +variant: "success" +``` + +```expect +● Grep +``` + +```style +- selector: indicator(0) + color: statusSuccess +``` + +## renders error variant + +```props +title: "Build" +variant: "error" +``` + +```expect +✗ Build +``` + +```style +- selector: indicator(0) + color: statusError +``` + +## renders warning variant + +```props +title: "Lint" +variant: "warning" +``` + +```expect +! Lint +``` + +```style +- selector: indicator(0) + color: statusWarning +``` + +## renders info variant + +```props +title: "Status" +variant: "info" +``` + +```expect +● Status +``` + +```style +- selector: indicator(0) + color: statusInfo +``` + +## renders muted variant + +```props +title: "Skipped" +variant: "muted" +``` + +```expect +○ Skipped +``` + +```style +- selector: indicator(0) + color: textTertiary +``` + +## renders brand variant + +```props +title: "Copilot" +variant: "brand" +``` + +```expect +● Copilot +``` + +```style +- selector: indicator(0) + color: brand +``` + +## renders selected variant + +```props +title: "Active" +variant: "selected" +``` + +```expect +● Active +``` + +```style +- selector: indicator(0) + color: selected +``` + +--- + +# Title and description + +## renders title with inline description + +```props +title: "Grep" +variant: "success" +description: '"pattern" in *.ts' +``` + +```expect +● Grep "pattern" in *.ts +``` + +## title is bold + +```props +title: "Build" +variant: "success" +``` + +```style +- selector: label("Build") + bold: true +``` + +## description uses descriptionColor + +```props +title: "Grep" +variant: "success" +description: "found 5 matches" +``` + +```style +- selector: label("found 5 matches") + color: textSecondary +``` + +## description newlines replaced with spaces in single-line mode + +```props +title: "Read" +variant: "info" +description: "line one\nline two" +``` + +```expect +● Read line one line two +``` + +## empty title renders icon and description only + +```props +title: "" +variant: "info" +description: "An informational message" +``` + +```expect +● An informational message +``` + +--- + +# Multiline description + +## description wraps when descriptionMultiline is true + +```props +title: "Error" +variant: "error" +description: "This is a long error message that should wrap to multiple lines" +descriptionMultiline: true +``` + +```style +- selector: label("Error") + bold: true +- selector: label("This is a long error message that should wrap to multiple lines") + color: textSecondary + wrap: true +``` + +## multiline stacks title and description vertically + +```props +title: "Notice" +variant: "info" +description: "Details below the title" +descriptionMultiline: true +``` + +```expect +● Notice + Details below the title +``` + +--- + +# Nested mode + +## nested hides the icon + +```props +title: "Child entry" +variant: "success" +nested: true +``` + +```expect +Child entry +``` + +--- + +# Sub-items + +## renders single sub-item with last connector + +```props +title: "Grep" +variant: "success" +subItems: ["5 files found"] +``` + +```expect +● Grep + └ 5 files found +``` + +## renders multiple sub-items with tree connectors + +```props +title: "Build" +variant: "error" +subItems: ["Error on line 42", "Missing import"] +``` + +```expect +✗ Build + │ Error on line 42 + └ Missing import +``` + +## renders three sub-items + +```props +title: "Scan" +variant: "info" +subItems: ["file1.ts", "file2.ts", "file3.ts"] +``` + +```expect +● Scan + │ file1.ts + │ file2.ts + └ file3.ts +``` + +## sub-item connectors use borderNeutral color + +```props +title: "Test" +variant: "success" +subItems: ["result 1", "result 2"] +``` + +```style +- selector: label("│") + color: borderNeutral + aria-hidden: true +- selector: label("└") + color: borderNeutral + aria-hidden: true +``` + +## error variant sub-items use statusError color + +```props +title: "Compile" +variant: "error" +subItems: ["type error in foo.ts"] +``` + +```style +- selector: label("type error in foo.ts") + color: statusError +``` + +## muted variant sub-items use textTertiary color + +```props +title: "Old" +variant: "muted" +subItems: ["stale data"] +``` + +```style +- selector: label("stale data") + color: textTertiary +``` + +## non-error sub-items use textSecondary color + +```props +title: "Fetch" +variant: "success" +subItems: ["200 OK"] +``` + +```style +- selector: label("200 OK") + color: textSecondary +``` + +--- + +# Expanded content + +## renders bordered content box when expanded + +```props +title: "Details" +variant: "info" +expanded: true +children: "Expanded content here" +``` + +```style +- selector: component("content-box") + borderLeft: true + borderColor: borderNeutral + description: > + Expanded content is rendered in a box with left border only, + in borderNeutral color, with left padding and vertical padding. +``` + +## content hidden when expanded is false + +```props +title: "Details" +variant: "info" +expanded: false +children: "Should not appear" +``` + +```expect +● Details +``` + +## content box appears above sub-items + +```props +title: "Tool" +variant: "success" +expanded: true +children: "Output data" +subItems: ["summary line"] +``` + +```style +description: > + When both expanded content and sub-items are present, + the bordered content box renders above the sub-items. +``` + +## expanded content promotes sub-item color to textPrimary + +```props +title: "Expanded" +variant: "success" +expanded: true +children: "Content" +subItems: ["detail line"] +``` + +```style +- selector: label("detail line") + color: textPrimary + description: > + When content is expanded, string sub-item text color is + promoted from subItemColor to textPrimary for better contrast. +``` + +--- + +# Custom variant + +## renders custom variant with explicit colors + +```props +title: "Custom" +variant: + iconColor: statusWarning + icon: "◐" + titleColor: textPrimary +``` + +```expect +◐ Custom +``` + +```style +- selector: indicator(0) + color: statusWarning +- selector: label("Custom") + color: textPrimary + bold: true +``` + +## custom variant defaults icon to CIRCLE_FILLED + +```props +title: "Default Icon" +variant: + iconColor: brand +``` + +```expect +● Default Icon +``` + +## custom variant defaults descriptionColor to textSecondary + +```props +title: "Desc" +variant: + iconColor: statusInfo +description: "info text" +``` + +```style +- selector: label("info text") + color: textSecondary +``` + +## custom variant with background color + +```props +title: "Highlighted" +variant: + iconColor: brand + backgroundColor: backgroundSecondary +``` + +```style +- selector: component("TimelineItem") + backgroundColor: backgroundSecondary +``` + +--- + +# Icon accessibility + +## icon is aria-hidden + +```props +title: "Test" +variant: "success" +``` + +```accessibility +- selector: indicator(0) + aria-hidden: true + description: > + The status icon glyph is always aria-hidden since it is + decorative. The variant meaning is conveyed through text context. +``` + +## tree connectors are aria-hidden + +```props +title: "Test" +variant: "success" +subItems: ["detail"] +``` + +```accessibility +- selector: label("└") + aria-hidden: true + description: > + Tree connectors (├, └, │) are decorative and aria-hidden. +``` + +--- + +# Edge cases + +## no sub-items or children — only header + +```props +title: "Simple" +variant: "info" +``` + +```expect +● Simple +``` + +## expanded true but no children — no content box + +```props +title: "Empty" +variant: "info" +expanded: true +``` + +```expect +● Empty +``` + +## sub-items with description + +```props +title: "Grep" +variant: "success" +description: "found matches" +subItems: ["file1.ts", "file2.ts"] +``` + +```expect +● Grep found matches + │ file1.ts + └ file2.ts +``` diff --git a/demo.md b/demo.md new file mode 100644 index 0000000..aff6ce7 --- /dev/null +++ b/demo.md @@ -0,0 +1,84 @@ +--- +kind: demo +name: TUIkit Preview +description: > + Interactive component preview app that showcases all TUIkit components. + Mirrors the `/tuikit` preview system — a searchable component picker with + per-component multi-variant screens built from .preview.md specs. +version: 2 +--- + +## Architecture + +The demo app is a **component preview browser**: + +``` +┌─────────────────────────────────────────────┐ +│ TUIkit Preview — {target name} │ ← TextTitle +├─────────────────────────────────────────────┤ +│ ▸ Search components... │ ← SelectAutocomplete picker +│ │ +│ Colors HintBar │ +│ Icons Select │ +│ Breakpoints SelectAutocomplete │ +│ TextTitle Input │ +│ TextHeading TabBar │ +│ Link Dialog │ +│ TextSpinner Table │ +│ TimelineItem QrCode │ +│ Metric │ +│ │ +├─────────────────────────────────────────────┤ +│ ↑↓ navigate · enter select · q quit │ ← HintBar +└─────────────────────────────────────────────┘ +``` + +When a component is selected, the picker is replaced by that component's +preview screen showing all its variants. Pressing **Escape** returns to +the picker. + +## Navigation + +- **Picker screen**: Root screen shows a searchable list of all components + using the **SelectAutocomplete** component. Typing filters the list. +- **Preview screen**: Selecting a component renders all its variants stacked + vertically, each with a heading label. Pressing **Escape** returns to picker. +- **Quit**: Pressing **q** on the picker screen exits the app. + +## Preview screens + +Each component's preview screen is defined by its `.preview.md` file. The +demo app MUST render every variant listed in the preview spec, in order, +with the variant heading as a label above each one. + +For token previews (colors, icons, breakpoints), render the token values +in a readable grid or list format. + +## Header (always visible) + +- **TextTitle**: `"TUIkit Preview — {target name}"` where target name is + the language/framework (e.g., "Go + Bubbletea", "Bun + Ink", + "Rust + Ratatui", "C# + Spectre.Console") +- Below the title, show breadcrumb: `"Home"` on picker, `"Home > {Name}"` on preview + +## HintBar (always visible at bottom) + +Contextual based on current screen: + +- **Picker screen**: `↑↓ navigate · enter select · / search · q quit` +- **Preview screen**: `esc back · q quit` +- **Interactive previews** (Select, Input, TabBar): component-specific + hints (e.g., `↑↓ navigate · enter select · esc back`) + +## Verification checklist + +Before considering the demo complete, verify: + +1. All components appear in the picker (one entry per component/token) +2. Selecting each component renders its preview variants without errors +3. Escape returns from preview to picker +4. q quits the app from the picker screen +5. Search/filter works in the picker +6. HintBar updates contextually +7. All previews use semantic color tokens (no hardcoded colors) +8. Interactive components respond to keyboard input diff --git a/lint.ts b/lint.ts new file mode 100644 index 0000000..ff3e2cd --- /dev/null +++ b/lint.ts @@ -0,0 +1,721 @@ +#!/usr/bin/env bun +/** + * TUIkit spec linter + * + * Validates component, token, and test specs against the schema. + * Checks frontmatter structure, naming conventions, RFC 2119 usage, + * dependencies, accessibility, and cross-references. + * + * Usage: + * bun lint.ts # lint all specs + * bun lint.ts --component HintBar # lint one component + * bun lint.ts --fix # show suggested fixes + */ + +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { dirname, join, relative } from "node:path"; + +// biome-ignore lint/suspicious/noConsole: CLI tool — stdout is the interface +const log = (...args: unknown[]) => console.log(...args); + +// ── Paths ────────────────────────────────────────────────────────────────── + +const SPECS_DIR = dirname(new URL(import.meta.url).pathname); +const TOKENS_DIR = join(SPECS_DIR, "tokens"); +const COMPONENTS_DIR = join(SPECS_DIR, "components"); +const TARGETS_DIR = join(SPECS_DIR, "targets"); + +// ── Types ────────────────────────────────────────────────────────────────── + +type Severity = "error" | "warn"; + +interface Diagnostic { + file: string; + severity: Severity; + rule: string; + message: string; + fix?: string; +} + +interface ParsedSpec { + path: string; + relativePath: string; + frontmatter: Record; + body: string; + bodySections: string[]; +} + +// ── Parsing ──────────────────────────────────────────────────────────────── + +function readFile(path: string): string { + return readFileSync(path, "utf-8"); +} + +function parseFrontmatter(content: string): Record { + const match = content.match(/^---\n([\s\S]*?)\n---/); + if (!match) return {}; + + const yaml = match[1]; + const result: Record = {}; + const lines = yaml.split("\n"); + let currentKey = ""; + + for (const line of lines) { + // Top-level key + const topMatch = line.match(/^(\w[\w_-]*)\s*:/); + if (topMatch && !line.startsWith(" ") && !line.startsWith("\t")) { + currentKey = topMatch[1]; + const value = line.slice(line.indexOf(":") + 1).trim(); + if (value && !value.startsWith(">") && !value.startsWith("|")) { + // Try to parse simple values + if (value === "true") result[currentKey] = true; + else if (value === "false") result[currentKey] = false; + else if (/^\d+$/.test(value)) result[currentKey] = Number.parseInt(value, 10); + else if (value.startsWith("[")) result[currentKey] = value; + else result[currentKey] = value; + } else { + result[currentKey] = {}; + } + } else if (currentKey && (line.startsWith(" ") || line.startsWith("\t"))) { + // Nested content — mark as object if not already + if (typeof result[currentKey] !== "object") { + result[currentKey] = {}; + } + } + } + + return result; +} + +function parseSpec(path: string): ParsedSpec { + const content = readFile(path); + const fmMatch = content.match(/^---\n[\s\S]*?\n---\n/); + const body = fmMatch ? content.slice(fmMatch[0].length) : content; + const bodySections = [...body.matchAll(/^## (.+)$/gm)].map((m) => m[1]); + + return { + path, + relativePath: relative(SPECS_DIR, path), + frontmatter: parseFrontmatter(content), + body, + bodySections, + }; +} + +// ── Validation rules ─────────────────────────────────────────────────────── + +const VALID_CATEGORIES = ["input", "display", "navigation", "layout", "feedback"]; +const RFC2119_KEYWORDS = /\b(MUST NOT|MUST|SHOULD NOT|SHOULD|MAY)\b/; +const INFORMAL_WORDS = /\b(always|never|should(?!\s+NOT))\b/i; + +function lintComponentSpec(spec: ParsedSpec, diagnostics: Diagnostic[]): void { + const fm = spec.frontmatter; + const file = spec.relativePath; + + // ── Required frontmatter fields ──────────────────────────────────── + + if (!fm.kind) { + diagnostics.push({ file, severity: "error", rule: "fm-kind", message: "Missing required field: kind" }); + } else if (fm.kind !== "component") { + diagnostics.push({ + file, + severity: "error", + rule: "fm-kind", + message: `kind MUST be "component", got "${fm.kind}"`, + }); + } + + if (!fm.name) { + diagnostics.push({ file, severity: "error", rule: "fm-name", message: "Missing required field: name" }); + } else if (typeof fm.name === "string" && !/^[A-Z][a-zA-Z0-9]+$/.test(fm.name)) { + diagnostics.push({ + file, + severity: "warn", + rule: "fm-name-case", + message: `name "${fm.name}" SHOULD be PascalCase`, + }); + } + + if (!fm.description) { + diagnostics.push({ + file, + severity: "error", + rule: "fm-description", + message: "Missing required field: description", + }); + } + + if (fm.version === undefined) { + diagnostics.push({ file, severity: "error", rule: "fm-version", message: "Missing required field: version" }); + } else if (typeof fm.version !== "number") { + diagnostics.push({ + file, + severity: "error", + rule: "fm-version-type", + message: `version MUST be a number, got "${typeof fm.version}"`, + }); + } + + if (!fm.category) { + diagnostics.push({ + file, + severity: "error", + rule: "fm-category", + message: "Missing required field: category", + }); + } else if (typeof fm.category === "string" && !VALID_CATEGORIES.includes(fm.category)) { + diagnostics.push({ + file, + severity: "error", + rule: "fm-category-value", + message: `category "${fm.category}" MUST be one of: ${VALID_CATEGORIES.join(", ")}`, + }); + } + + // ── Dependencies (required in schema v2) ─────────────────────────── + + if (!fm.dependencies) { + diagnostics.push({ + file, + severity: "error", + rule: "fm-dependencies", + message: "Missing required field: dependencies", + fix: "Add dependencies: section listing tokens and components used", + }); + } + + // ── Tokens cross-reference ───────────────────────────────────────── + + if (fm.tokens && fm.dependencies) { + // Both exist — we could validate they match, but the YAML parsing + // is too shallow here. Just check both sections are present. + } else if (fm.tokens && !fm.dependencies) { + diagnostics.push({ + file, + severity: "warn", + rule: "fm-tokens-deps-sync", + message: "Has tokens: but no dependencies: — dependencies MUST list the same tokens", + }); + } + + // ── Props validation ─────────────────────────────────────────────── + + if (!fm.props) { + diagnostics.push({ file, severity: "warn", rule: "fm-props", message: "No props defined" }); + } + + // ── Accessibility (required for interactive, recommended for all) ── + + const hasStates = !!fm.states; + const hasKeyboard = !!fm.keyboard; + + if (!fm.accessibility) { + if (hasStates || hasKeyboard) { + diagnostics.push({ + file, + severity: "error", + rule: "fm-a11y-interactive", + message: "Interactive component (has states/keyboard) MUST define accessibility section", + fix: "Add accessibility: with role, announce.on_mount, and screen_reader_adaptations", + }); + } else { + diagnostics.push({ + file, + severity: "warn", + rule: "fm-a11y-display", + message: "Display component SHOULD define accessibility: with at least role", + }); + } + } + + // ── Body sections ────────────────────────────────────────────────── + + const sections = spec.bodySections; + + if (!sections.includes("Visual rules")) { + diagnostics.push({ + file, + severity: "error", + rule: "body-visual-rules", + message: 'Missing required body section: "## Visual rules"', + }); + } + + if (!sections.includes("Rendering example")) { + diagnostics.push({ + file, + severity: "error", + rule: "body-rendering", + message: 'Missing required body section: "## Rendering example"', + }); + } + + if (!sections.includes("Dependencies")) { + diagnostics.push({ + file, + severity: "error", + rule: "body-dependencies", + message: 'Missing required body section: "## Dependencies"', + fix: "Add a ## Dependencies table matching the frontmatter dependencies section", + }); + } + + if ((hasStates || hasKeyboard) && !sections.some((s) => s === "Behavior" || s.startsWith("Behavior"))) { + diagnostics.push({ + file, + severity: "warn", + rule: "body-behavior", + message: 'Interactive component SHOULD have a "## Behavior" section', + }); + } + + // ── RFC 2119 conformance ─────────────────────────────────────────── + + lintRFC2119(spec, diagnostics); +} + +function lintTokenSpec(spec: ParsedSpec, diagnostics: Diagnostic[]): void { + const fm = spec.frontmatter; + const file = spec.relativePath; + + if (!fm.kind || fm.kind !== "token") { + diagnostics.push({ file, severity: "error", rule: "fm-kind", message: 'kind MUST be "token"' }); + } + + if (!fm.name) { + diagnostics.push({ file, severity: "error", rule: "fm-name", message: "Missing required field: name" }); + } + + if (!fm.description) { + diagnostics.push({ + file, + severity: "error", + rule: "fm-description", + message: "Missing required field: description", + }); + } + + if (fm.version === undefined) { + diagnostics.push({ file, severity: "error", rule: "fm-version", message: "Missing required field: version" }); + } +} + +function lintTestSpec(spec: ParsedSpec, diagnostics: Diagnostic[]): void { + const fm = spec.frontmatter; + const file = spec.relativePath; + + if (!fm.kind || fm.kind !== "test") { + diagnostics.push({ file, severity: "error", rule: "fm-kind", message: 'kind MUST be "test"' }); + } + + if (!fm.component) { + diagnostics.push({ + file, + severity: "error", + rule: "fm-component", + message: "Missing required field: component", + }); + } else if (typeof fm.component === "string") { + // Verify the referenced component spec exists + const componentDir = join(COMPONENTS_DIR, fm.component); + const componentSpec = join(componentDir, `${fm.component}.md`); + if (!existsSync(componentSpec)) { + diagnostics.push({ + file, + severity: "error", + rule: "fm-component-ref", + message: `References component "${fm.component}" but ${fm.component}/${fm.component}.md does not exist`, + }); + } + } + + if (fm.version === undefined) { + diagnostics.push({ file, severity: "error", rule: "fm-version", message: "Missing required field: version" }); + } + + // Check test has at least one ## heading (test case) + if (spec.bodySections.length === 0) { + diagnostics.push({ + file, + severity: "warn", + rule: "test-empty", + message: "Test spec has no test cases (## headings)", + }); + } +} + +function lintTargetSpec(spec: ParsedSpec, diagnostics: Diagnostic[]): void { + const fm = spec.frontmatter; + const file = spec.relativePath; + + if (!fm.kind || fm.kind !== "target") { + diagnostics.push({ file, severity: "error", rule: "fm-kind", message: 'kind MUST be "target"' }); + } + + for (const field of ["name", "language", "runtime"]) { + if (!fm[field]) { + diagnostics.push({ + file, + severity: "error", + rule: `fm-${field}`, + message: `Missing required field: ${field}`, + }); + } + } + + if (!fm.framework) { + diagnostics.push({ + file, + severity: "error", + rule: "fm-framework", + message: "Missing required field: framework", + }); + } + + // Check required body sections + const requiredSections = [ + "Architecture pattern", + "Type mapping", + "Callback translation", + "State machine translation", + "Token access", + "Composition", + "Test pattern", + "Key mapping", + "Dependencies", + "Demo CLI", + ]; + + for (const section of requiredSections) { + if (!spec.bodySections.some((s) => s.toLowerCase().includes(section.toLowerCase()))) { + diagnostics.push({ + file, + severity: "warn", + rule: "target-section", + message: `Missing recommended body section: "${section}"`, + }); + } + } +} + +// ── RFC 2119 linting ─────────────────────────────────────────────────────── + +function lintRFC2119(spec: ParsedSpec, diagnostics: Diagnostic[]): void { + const file = spec.relativePath; + const body = spec.body; + + // Find sections that MUST use RFC 2119 keywords + const normativeSections = ["Visual rules", "Behavior", "Edge cases"]; + + for (const sectionName of normativeSections) { + const sectionRegex = new RegExp(`^## ${sectionName}\\b.*$`, "m"); + const sectionMatch = body.match(sectionRegex); + if (!sectionMatch) continue; + + const sectionStart = body.indexOf(sectionMatch[0]) + sectionMatch[0].length; + const nextSection = body.slice(sectionStart).match(/^## /m); + const sectionEnd = nextSection ? sectionStart + (nextSection.index ?? 0) : body.length; + const sectionText = body.slice(sectionStart, sectionEnd); + + // Check for RFC 2119 keywords + const hasRFC2119 = RFC2119_KEYWORDS.test(sectionText); + + if (!hasRFC2119) { + diagnostics.push({ + file, + severity: "warn", + rule: "rfc2119-missing", + message: `Section "${sectionName}" has no RFC 2119 keywords (MUST/SHOULD/MAY)`, + fix: 'Replace informal language ("always", "should", "never") with MUST/SHOULD/MAY', + }); + } + + // Check for informal language that should be replaced + const bulletLines = sectionText.split("\n").filter((l) => l.trim().startsWith("-") || l.trim().startsWith("*")); + for (const line of bulletLines) { + if (INFORMAL_WORDS.test(line) && !RFC2119_KEYWORDS.test(line)) { + diagnostics.push({ + file, + severity: "warn", + rule: "rfc2119-informal", + message: `"${sectionName}" contains informal language: "${line.trim().slice(0, 80)}..."`, + fix: "Replace with RFC 2119 keyword (MUST/SHOULD/MAY)", + }); + } + } + } +} + +// ── Cross-spec validation ────────────────────────────────────────────────── + +function lintCrossReferences(componentSpecs: ParsedSpec[], tokenSpecs: ParsedSpec[], diagnostics: Diagnostic[]): void { + // Collect all known token names from token specs + const knownTokens = new Set(); + for (const spec of tokenSpecs) { + const content = readFile(spec.path); + // Extract token names from frontmatter (top-level keys under tokens: or groups:) + const tokenBlock = content.match(/^tokens:\n([\s\S]*?)(?=\n\w|\n---|Z)/m); + if (tokenBlock) { + const tokenNames = [...tokenBlock[1].matchAll(/^\s{4}(\w+):/gm)].map((m) => m[1]); + for (const name of tokenNames) knownTokens.add(name); + } + const groupBlock = content.match(/^groups:\n([\s\S]*?)(?=\n[a-z]|\n---|Z)/m); + if (groupBlock) { + // Icon glyph names (SCREAMING_CASE) under glyphs: + const glyphs = [...groupBlock[1].matchAll(/^\s+(\w+):\s*\{\s*char:/gm)].map((m) => m[1]); + for (const g of glyphs) knownTokens.add(g); + // Semantic aliases (camelCase) under semantic_aliases: + const aliasLines = [...groupBlock[1].matchAll(/^\s+(icon\w+):\s*\w+/gm)].map((m) => m[1]); + for (const a of aliasLines) knownTokens.add(a); + } + } + + // Collect all component names (for future dependency validation) + const _knownComponents = new Set(componentSpecs.map((s) => s.frontmatter.name as string).filter(Boolean)); + + // Validate each component's token references + for (const spec of componentSpecs) { + const content = readFile(spec.path); + const file = spec.relativePath; + + // Extract color tokens from tokens.colors: [...] + const colorMatch = content.match(/colors:\s*\[([^\]]*)\]/); + if (colorMatch && colorMatch[1].trim()) { + const colors = colorMatch[1].split(",").map((s) => s.trim()); + for (const color of colors) { + if (color && !knownTokens.has(color)) { + diagnostics.push({ + file, + severity: "warn", + rule: "xref-token", + message: `References unknown color token "${color}"`, + }); + } + } + } + + // Extract icon tokens from tokens.icons: [...] + const iconMatch = content.match(/icons:\s*\[([^\]]*)\]/); + if (iconMatch && iconMatch[1].trim()) { + const icons = iconMatch[1].split(",").map((s) => s.trim()); + for (const icon of icons) { + if (icon && !knownTokens.has(icon)) { + diagnostics.push({ + file, + severity: "warn", + rule: "xref-token", + message: `References unknown icon token "${icon}"`, + }); + } + } + } + } +} + +// ── File naming validation ───────────────────────────────────────────────── + +function lintFileNaming(diagnostics: Diagnostic[]): void { + if (!existsSync(COMPONENTS_DIR)) return; + + for (const dir of readdirSync(COMPONENTS_DIR)) { + const dirPath = join(COMPONENTS_DIR, dir); + if (!statSync(dirPath).isDirectory()) continue; + + const relDir = relative(SPECS_DIR, dirPath); + + // Directory name should be PascalCase + if (!/^[A-Z][a-zA-Z0-9]+$/.test(dir)) { + diagnostics.push({ + file: relDir, + severity: "warn", + rule: "naming-dir", + message: `Component directory "${dir}" SHOULD be PascalCase`, + }); + } + + // Must have {Name}.md + const specFile = join(dirPath, `${dir}.md`); + if (!existsSync(specFile)) { + diagnostics.push({ + file: relDir, + severity: "error", + rule: "naming-spec", + message: `Missing spec file: ${dir}.md`, + }); + } + + // Must have {Name}.test.md + const testFile = join(dirPath, `${dir}.test.md`); + if (!existsSync(testFile)) { + diagnostics.push({ + file: relDir, + severity: "error", + rule: "naming-test", + message: `Missing test file: ${dir}.test.md`, + }); + } + } +} + +// ── Discovery + orchestration ────────────────────────────────────────────── + +function discoverAndLint(componentFilter?: string): Diagnostic[] { + const diagnostics: Diagnostic[] = []; + + // Lint file naming + lintFileNaming(diagnostics); + + // Discover and lint token specs + const tokenSpecs: ParsedSpec[] = []; + if (existsSync(TOKENS_DIR)) { + for (const file of readdirSync(TOKENS_DIR).filter((f) => f.endsWith(".md") && !f.includes(".preview."))) { + const spec = parseSpec(join(TOKENS_DIR, file)); + tokenSpecs.push(spec); + lintTokenSpec(spec, diagnostics); + } + } + + // Discover and lint component specs + test specs + const componentSpecs: ParsedSpec[] = []; + if (existsSync(COMPONENTS_DIR)) { + for (const dir of readdirSync(COMPONENTS_DIR)) { + if (componentFilter && dir !== componentFilter) continue; + + const dirPath = join(COMPONENTS_DIR, dir); + if (!statSync(dirPath).isDirectory()) continue; + + const specPath = join(dirPath, `${dir}.md`); + if (existsSync(specPath)) { + const spec = parseSpec(specPath); + componentSpecs.push(spec); + lintComponentSpec(spec, diagnostics); + } + + const testPath = join(dirPath, `${dir}.test.md`); + if (existsSync(testPath)) { + const spec = parseSpec(testPath); + lintTestSpec(spec, diagnostics); + } + } + } + + // Discover and lint target specs + if (existsSync(TARGETS_DIR) && !componentFilter) { + for (const file of readdirSync(TARGETS_DIR).filter((f) => f.endsWith(".md"))) { + const spec = parseSpec(join(TARGETS_DIR, file)); + lintTargetSpec(spec, diagnostics); + } + } + + // Cross-spec validation + if (!componentFilter) { + lintCrossReferences(componentSpecs, tokenSpecs, diagnostics); + } + + return diagnostics; +} + +// ── Output ───────────────────────────────────────────────────────────────── + +function formatDiagnostics(diagnostics: Diagnostic[], showFix: boolean): void { + if (diagnostics.length === 0) { + log("\n✅ All specs valid.\n"); + return; + } + + const errors = diagnostics.filter((d) => d.severity === "error"); + const warnings = diagnostics.filter((d) => d.severity === "warn"); + + // Group by file + const byFile = new Map(); + for (const d of diagnostics) { + const list = byFile.get(d.file) ?? []; + list.push(d); + byFile.set(d.file, list); + } + + log(""); + for (const [file, diags] of [...byFile.entries()].sort((a, b) => a[0].localeCompare(b[0]))) { + log(`📄 ${file}`); + for (const d of diags) { + const icon = d.severity === "error" ? " ✖" : " ⚠"; + log(`${icon} [${d.rule}] ${d.message}`); + if (showFix && d.fix) { + log(` 💡 ${d.fix}`); + } + } + log(""); + } + + log("───────────────────────────────────────"); + log(` ${errors.length} error(s), ${warnings.length} warning(s)`); + log(""); + + if (errors.length > 0) { + process.exitCode = 1; + } +} + +// ── CLI ──────────────────────────────────────────────────────────────────── + +function usage(): void { + log(` +TUIkit spec linter — validate specs against the schema. + +Usage: + bun lint.ts Lint all specs + bun lint.ts --component HintBar Lint one component + bun lint.ts --fix Show suggested fixes + +Rules checked: + fm-kind kind field is present and valid + fm-name name is present and PascalCase + fm-name-case name follows PascalCase convention + fm-description description is present + fm-version version is present and numeric + fm-version-type version is a number + fm-category category is present and valid + fm-category-value category is one of the allowed values + fm-dependencies dependencies section is present (required v2) + fm-props props section is present + fm-a11y-interactive interactive components have accessibility + fm-a11y-display display components should have accessibility + fm-component test spec references a component + fm-component-ref referenced component exists + fm-framework target spec has framework defined + body-visual-rules "## Visual rules" section exists + body-rendering "## Rendering example" section exists + body-dependencies "## Dependencies" section exists + body-behavior interactive components have behavior section + rfc2119-missing normative sections use RFC 2119 keywords + rfc2119-informal informal language in normative sections + xref-token token references resolve to known tokens + naming-dir component directory is PascalCase + naming-spec {Name}.md exists in component directory + naming-test {Name}.test.md exists in component directory + target-section target spec has recommended body sections + test-empty test spec has at least one test case +`); +} + +function parseArgs(argv: string[]): { component?: string; fix: boolean } { + let component: string | undefined; + let fix = false; + + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--component" && argv[i + 1]) { + component = argv[++i]; + } else if (argv[i] === "--fix") { + fix = true; + } else if (argv[i] === "help" || argv[i] === "--help" || argv[i] === "-h") { + usage(); + process.exit(0); + } + } + + return { component, fix }; +} + +const args = parseArgs(process.argv.slice(2)); +const diagnostics = discoverAndLint(args.component); +formatDiagnostics(diagnostics, args.fix); diff --git a/targets/bun.md b/targets/bun.md new file mode 100644 index 0000000..3228e0b --- /dev/null +++ b/targets/bun.md @@ -0,0 +1,290 @@ +--- +kind: target +name: bun +language: TypeScript +runtime: Bun 1.1+ +framework: + name: Ink + version: ">=5.0" + url: https://github.com/vadimdemedes/ink + paradigm: React (declarative, virtual DOM, hooks) +styling: + name: Chalk + version: ">=5.0" + url: https://github.com/chalk/chalk + role: ANSI color encoding (hex → SGR codes) +testing: + runner: bun test + framework: bun:test (Jest-compatible) + helper: ink-testing-library + +output: + root: src/tuikit + structure: + tokens/colors.ts: SemanticColors type + resolveTokens() + tokens/icons.ts: Icon constants + semantic aliases + tokens/breakpoints.ts: Breakpoint constants + getBreakpoint() + components/{Name}/{Name}.tsx: Component function + components/{Name}/{Name}.test.tsx: Tests + hooks/useColors.ts: React hook for color access + hooks/useBreakpoint.ts: React hook for responsive breakpoints + index.ts: Public re-exports +--- + +# Bun Target — Ink + React + +## Architecture pattern + +This target uses the **same paradigm** as the original TUIkit (React + Ink) but +runs on the Bun runtime instead of Node.js. Components are functional React +components with hooks. + +```tsx +import { Box, Text } from "ink"; +import { useColors } from "../hooks/useColors"; + +export function HintBar({ hints, separator = " · " }: HintBarProps) { + const colors = useColors(); + const entries = Object.entries(hints).filter(([, v]) => Boolean(v)); + + return ( + + {entries.map(([key, label], i) => ( + + {i > 0 && separator} + + {formatKey(key)} + {" "} + {label} + + ))} + + ); +} +``` + +## Type mapping + +| Spec type | TypeScript type | +| ------------------------- | -------------------------------------- | +| `string` | `string` | +| `number` | `number` | +| `boolean` | `boolean` | +| `array` | `T[]` | +| `record` | `Record` | +| `callback(args) → void` | `(args) => void` | +| `T` (generic) | `` generic parameter | +| `string \| false \| null` | `string \| false \| null \| undefined` | +| `SelectItem` | `interface SelectItem { ... }` | +| `color \| undefined` | `SemanticColor \| undefined` | +| `SemanticColor` | Branded `string` type | +| `IconGlyph` | Branded `string` type | + +## Callback translation + +Callbacks map directly to React props — no translation needed: + +```tsx +// Spec: onSelect: callback(item: SelectItem) → void +// Bun/Ink: Direct prop +interface SelectProps { + onSelect: (item: SelectItem) => void; +} +``` + +## State machine translation + +Spec states map to `useState` + conditional logic: + +```tsx +type State = "focused" | "selected" | "dismissed"; + +function Select({ items, onSelect }: SelectProps) { + const [state, setState] = useState("focused"); + const [highlighted, setHighlighted] = useState(0); + + useInput((key) => { + if (state !== "focused") return; + if (key.return) { + setState("selected"); + onSelect(items[highlighted]); + } + }); +} +``` + +## Token access + +Tokens are accessed via React hooks: + +```tsx +// Colors — via hook (memoized per render) +const colors = useColors(); +Hello; + +// Icons — direct import (static constants) +import { IconStatus } from "../tokens/icons"; +{IconStatus.prompt}; + +// Breakpoints — via hook (reactive to terminal resize) +const { breakpoint, isCompact } = useBreakpoint(); +``` + +## Styling with Ink + +Ink's `` and `` are the styling primitives: + +```tsx +// Spec: "key is bold in textPrimary" +Esc + +// Spec: "label uses textSecondary" +to cancel + +// Spec: layout direction vertical + + {items.map(item => {item.label})} + +``` + +## Composition + +React composition is natural — components are JSX children: + +```tsx +function Select({ items, hideHints }: SelectProps) { + return ( + <> + {items.map((item, i) => renderItem(item, i))} + {!hideHints && } + + ); +} +``` + +## Input handling + +Use the `useInput` hook (Ink's keyboard handler): + +```tsx +import { useInput } from "ink"; +// Or the wrapped version for Kitty protocol support: +import useInput from "../hooks/useInput"; + +useInput((input, key) => { + if (key.upArrow) { + /* move up */ + } + if (key.downArrow) { + /* move down */ + } + if (key.return) { + /* select */ + } + if (key.escape) { + /* cancel */ + } +}); +``` + +## Test pattern + +Use `ink-testing-library` for component testing. The `.test.md` expect blocks +become string comparisons against rendered output: + +````tsx +import { render } from "ink-testing-library"; +import { HintBar } from "./HintBar"; + +test("renders basic hints with separator", () => { + const { lastFrame } = render( + + ); + + // ```expect block → string comparison + const expected = "Esc to cancel · Enter to select"; + expect(stripAnsi(lastFrame())).toBe(expected); +}); + +test("moves highlight down on arrow key", () => { + const { lastFrame, stdin } = render( + ; +} + +function InputMultilinePreview({ hasFocus }: { hasFocus: boolean }) { + const [text, setText] = useState(""); + return ; +} + +function InputMaskedPreview({ hasFocus }: { hasFocus: boolean }) { + const [text, setText] = useState(""); + return ; +} + +function InputSingleLinePreview({ hasFocus }: { hasFocus: boolean }) { + const [text, setText] = useState(""); + return ; +} + +function TabBarInteractivePreview({ hasFocus, items, selectedIndex: initial, navigationKeys, loop }: { + hasFocus: boolean; + items: Array<{ value: string; label: string }>; + selectedIndex: number; + navigationKeys?: "arrow-only" | "tab-only" | "all"; + loop?: boolean; +}) { + const [tab, setTab] = useState(initial); + return ( + + ); +} + +// ─── Build the registry from preview specs ────────────────────────── + +const PREVIEW_REGISTRY: PreviewEntry[] = [ + // --- Tokens (alphabetical, lowercase names) --- + { + name: "breakpoints", + type: "token", + variants: [ + { + name: "Current breakpoint", + render: () => { + const bp = useBreakpoint(); + return ( + + breakpoint: {bp.breakpoint} + columns: {bp.terminalWidth} rows: {bp.terminalHeight} + compact: ≤{COMPACT_MAX} + narrow: {NARROW_MIN}–{NARROW_MAX} + wide: ≥{WIDE_MIN} + + ); + }, + }, + ], + }, + { + name: "colors", + type: "token", + variants: [ + { name: "Semantic colors", render: () => }, + { name: "Text tokens", render: () => }, + { name: "Status tokens", render: () => }, + { name: "Brand tokens", render: () => }, + ], + }, + { + name: "icons", + type: "token", + variants: [ + { name: "Status icons", render: () => }, + { name: "Navigation icons", render: () => }, + { name: "UI icons", render: () => }, + { name: "Tree icons", render: () => }, + ], + }, + + // --- Components (alphabetical, PascalCase names) --- + { + name: "Dialog", + type: "component", + variants: [ + { + name: "Basic", + render: () => ( + This is a simple dialog. + ), + }, + { + name: "With subtitle", + render: () => ( + + Are you sure? + + ), + }, + { + name: "Fixed width", + render: () => ( + + Content constrained to 40 columns. + + ), + }, + { + name: "Border title", + render: () => ( + + Model: GPT-4 · Tokens: 1,234 + + ), + }, + { + name: "Full variant", + render: () => ( + + Allow access to this folder? + + ), + }, + ], + }, + { + name: "HintBar", + type: "component", + variants: [ + { + name: "Default", + render: () => ( + + ), + }, + { + name: "Custom keys", + render: () => ( + + ), + }, + { + name: "Conditional", + render: () => ( + + ), + }, + { + name: "Custom separator", + render: () => ( + + ), + }, + ], + }, + { + name: "Icons", + type: "component", + variants: [ + { name: "All icons", render: () => }, + ], + }, + { + name: "Input", + type: "component", + variants: [ + { name: "Default", render: (f) => }, + { name: "Multiline", render: (f) => }, + { name: "Masked", render: (f) => }, + { name: "Single line", render: (f) => }, + ], + }, + { + name: "Link", + type: "component", + variants: [ + { name: "Default", render: () => }, + { name: "With label color", render: () => { + const colors = useColors(); + return GitHub; + }}, + { name: "With brand color", render: () => { + const colors = useColors(); + return GitHub; + }}, + { name: "Bold", render: () => { + const colors = useColors(); + return GitHub; + }}, + ], + }, + { + name: "Metric", + type: "component", + variants: [ + { + name: "Default", + render: () => ( + + ), + }, + { + name: "Highlighted", + render: () => { + const colors = useColors(); + return ( + + ); + }, + }, + ], + }, + { + name: "QrCode", + type: "component", + variants: [ + { name: "Short URL", render: () => }, + { name: "Long URL", render: () => }, + ], + }, + { + name: "Screen", + type: "component", + variants: [ + { + name: "Basic", + render: () => ( + + 10:21:03 Server starting on port 3000 + 10:21:04 Connected to database + 10:21:05 Registered 14 API routes + + ), + }, + { + name: "With header and footer", + render: () => ( + Screen — Screen.tsxApplication Log (3 entries)} + footer={↑↓ scroll · Esc back} + > + 10:21:03 Server starting on port 3000 + 10:21:04 Connected to database + 10:21:05 Registered 14 API routes + + ), + }, + { + name: "Non-scrollable", + render: () => ( + + Line 1 + Line 2 + Line 3 + + ), + }, + ], + }, + { + name: "ScrollBox", + type: "component", + variants: [ + { + name: "No scroll (content fits)", + render: () => ( + + ◎ 001 [INFO ] Server starting on port 3000 + ✓ 002 [OK ] Connected to database + ◎ 003 [INFO ] Registered 14 API routes + + ), + }, + { + name: "Scrollable list", + render: (f) => ( + + ◎ 001 [INFO ] Server starting on port 3000 + ◎ 002 [INFO ] Loading configuration from .env + ✓ 003 [OK ] Connected to database + ◎ 004 [INFO ] Processing batch job #1284 + ✓ 005 [OK ] Batch job completed (42 items) + ! 006 [WARN ] Redis not configured + ◎ 007 [INFO ] Incoming webhook from GitHub + ✖ 008 [ERROR] Build failed: missing dependency + + ), + }, + { + name: "No scrollbar", + render: () => ( + + {Array.from({ length: 6 }, (_, i) => Item {i})} + + ), + }, + { + name: "Focusable", + render: (f) => ( + {}}> + ❯ 001 [INFO ] First item + 002 [WARN ] Second item + 003 [OK ] Third item + 004 [INFO ] Fourth item + + ), + }, + { + name: "Hover + virtualized", + render: (f) => ( + {}} onHoverLine={() => {}}> + ❯ 001 [INFO ] Item one + 002 [INFO ] Item two + 003 [WARN ] Item three + 004 [OK ] Item four + 005 [INFO ] Item five + 006 [ERROR] Item six + + ), + }, + ], + }, + { + name: "Select", + type: "component", + variants: [ + { + name: "Basic", + render: () => ( + {}} + escapeItem={{ label: "Cancel", value: "cancel" }} + /> + ), + }, + { + name: "With text input", + render: () => ( + {}} + escapeItem={{ label: "Cancel", value: "cancel" }} + /> + ), + }, + ], + }, + { + name: "SelectAutocomplete", + type: "component", + variants: [ + { + name: "Basic", + render: () => ( + {}} + onEscape={() => {}} + placeholder="Search options..." + /> + ), + }, + { + name: "With current item", + render: () => ( + {}} + onEscape={() => {}} + placeholder="Search..." + /> + ), + }, + ], + }, + { + name: "TabBar", + type: "component", + variants: [ + { + name: "Display only", + render: () => ( + + ), + }, + { + name: "Arrow navigation", + render: (f) => ( + + ), + }, + { + name: "Tab navigation", + render: (f) => ( + + ), + }, + { + name: "No loop", + render: (f) => ( + + ), + }, + ], + }, + { + name: "Table", + type: "component", + variants: [ + { + name: "Basic", + render: () => ( + + ), + }, + { + name: "Borderless key-value", + render: () => ( +
+ ), + }, + { + name: "Right-aligned numbers", + render: () => ( +
+ ), + }, + { + name: "Width-constrained", + render: () => ( +
+ ), + }, + ], + }, + { + name: "TextHeading", + type: "component", + variants: [ + { name: "Default", render: () => Section Heading }, + { name: "Error", render: () => Error Details }, + ], + }, + { + name: "TextSpinner", + type: "component", + variants: [ + { name: "Default", render: () => }, + { name: "Icon only", render: () => }, + { name: "Label only", render: () => }, + { name: "Placeholder", render: () => }, + { name: "Brand", render: () => }, + { name: "Info", render: () => }, + ], + }, + { + name: "TextTitle", + type: "component", + variants: [ + { name: "Default", render: () => Welcome to TUIkit }, + { name: "Error", render: () => Something went wrong }, + ], + }, + { + name: "TimelineItem", + type: "component", + variants: [ + { name: "Loading", render: () => }, + { name: "Success", render: () => }, + { name: "Error", render: () => }, + { name: "Warning", render: () => }, + { name: "Info", render: () => }, + { name: "Muted", render: () => }, + { + name: "With multiple sub-items", + render: () => ( + + ), + }, + ], + }, +]; + +// Computed lookup maps +const REGISTRY_MAP = new Map(PREVIEW_REGISTRY.map((e) => [e.name, e])); +const ALL_NAMES = PREVIEW_REGISTRY.map((e) => e.name); + +// ─── Variant renderer component ───────────────────────────────────── + +function VariantRenderer({ entry, variantFilter, hasFocus }: { + entry: PreviewEntry; + variantFilter?: string; + hasFocus: boolean; +}) { + const variants = variantFilter + ? entry.variants.filter((v) => v.name === variantFilter) + : entry.variants; + return ( + + {variants.map((v) => ( + + {v.name} + {v.render(hasFocus)} + + ))} + + ); +} + +// ─── CLI Argument Parsing ─────────────────────────────────────────── + +const args = process.argv.slice(2); +const listFlag = args.includes("--list"); +const snapshotFlag = args.includes("--snapshot"); +const componentIdx = args.indexOf("--component"); +const componentArg = componentIdx >= 0 ? args[componentIdx + 1] : undefined; +const variantIdx = args.indexOf("--variant"); +const variantArg = variantIdx >= 0 ? args[variantIdx + 1] : undefined; + +// ─── --list mode ──────────────────────────────────────────────────── + +if (listFlag) { + for (const name of ALL_NAMES) { + console.log(name); + } + process.exit(0); +} + +// ─── --snapshot mode ──────────────────────────────────────────────── + +if (snapshotFlag) { + if (!componentArg) { + process.stderr.write("Error: --snapshot requires --component \n"); + process.exit(1); + } + const entry = REGISTRY_MAP.get(componentArg); + if (!entry) { + process.stderr.write(`Error: unknown component "${componentArg}"\n`); + process.exit(1); + } + if (variantArg) { + const found = entry.variants.some((v) => v.name === variantArg); + if (!found) { + process.stderr.write(`Error: unknown variant "${variantArg}" for component "${componentArg}"\n`); + process.exit(1); + } + } + const { lastFrame, unmount } = testRender( + React.createElement(VariantRenderer, { entry, variantFilter: variantArg, hasFocus: false }) + ); + const frame = lastFrame(); + unmount(); + process.stdout.write((frame ?? "") + "\n"); + process.exit(0); +} + +// ─── --component (interactive single-component mode) ──────────────── + +if (componentArg && !snapshotFlag) { + const entry = REGISTRY_MAP.get(componentArg); + if (!entry) { + process.stderr.write(`Error: unknown component "${componentArg}"\n`); + process.exit(1); + } + if (variantArg) { + const found = entry.variants.some((v) => v.name === variantArg); + if (!found) { + process.stderr.write(`Error: unknown variant "${variantArg}" for component "${componentArg}"\n`); + process.exit(1); + } + } + + function SingleComponentApp() { + const { exit } = useApp(); + const colors = useColors(); + useInput((input, key) => { + if (key.escape || input === "q") exit(); + }); + return ( + {componentArg!}} + footer={} + > + + + + + ); + } + + process.stdout.write("\x1b[?1049h"); + const app = inkRender(React.createElement(SingleComponentApp)); + app.waitUntilExit().then(() => { + process.stdout.write("\x1b[?1049l"); + }); +} else { + // ─── Default: full interactive TUI ────────────────────────────────── + + // Sidebar display names: tokens use display casing, components use their name + const TOKEN_DISPLAY = PREVIEW_REGISTRY.filter((e) => e.type === "token").map((e) => ({ + name: e.name, + display: e.name.charAt(0).toUpperCase() + e.name.slice(1), + })); + const COMPONENT_DISPLAY = PREVIEW_REGISTRY.filter((e) => e.type === "component").map((e) => ({ + name: e.name, + display: e.name, + })); + const SEPARATOR = "──────────────────"; + + type SidebarItem = { kind: "entry"; name: string; display: string } | { kind: "separator" }; + const SIDEBAR_ITEMS: SidebarItem[] = [ + ...TOKEN_DISPLAY.map((t) => ({ kind: "entry" as const, name: t.name, display: t.display })), + { kind: "separator" as const }, + ...COMPONENT_DISPLAY.map((c) => ({ kind: "entry" as const, name: c.name, display: c.display })), + ]; + + type FocusState = "sidebar" | "searching" | "preview"; + + function App() { + const colors = useColors(); + const { exit } = useApp(); + + const [focus, setFocus] = useState("sidebar"); + const [selectedIdx, setSelectedIdx] = useState(0); + const [openName, setOpenName] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + + const filteredItems = useMemo(() => { + if (!searchQuery) return SIDEBAR_ITEMS; + const q = searchQuery.toLowerCase(); + return SIDEBAR_ITEMS.filter( + (item) => item.kind === "separator" || item.display.toLowerCase().includes(q) + ); + }, [searchQuery]); + + useEffect(() => { + setSelectedIdx((prev) => { + const max = filteredItems.length - 1; + if (prev > max) return Math.max(0, max); + if (filteredItems[prev]?.kind === "separator") { + return prev + 1 <= max ? prev + 1 : Math.max(0, prev - 1); + } + return prev; + }); + }, [filteredItems]); + + const moveSelection = (dir: 1 | -1) => { + setSelectedIdx((prev) => { + let next = prev + dir; + if (next < 0) next = 0; + if (next >= filteredItems.length) next = filteredItems.length - 1; + if (filteredItems[next]?.kind === "separator") next += dir; + if (next < 0) next = 0; + if (next >= filteredItems.length) next = filteredItems.length - 1; + return next; + }); + }; + + useInput((input, key) => { + if (input === "q" && focus !== "searching" && focus !== "preview") { + exit(); + return; + } + + if (focus === "searching") { + if (key.escape) { setSearchQuery(""); setFocus("sidebar"); return; } + if (key.return) { + const item = filteredItems[selectedIdx]; + if (item?.kind === "entry") { + setOpenName(item.name); + setFocus("preview"); + } + return; + } + if (key.backspace || key.delete) { setSearchQuery((q) => q.slice(0, -1)); return; } + if (key.upArrow) { moveSelection(-1); return; } + if (key.downArrow) { moveSelection(1); return; } + if (input && !key.ctrl && !key.meta) { setSearchQuery((q) => q + input); } + return; + } + + if (focus === "preview") { + if (key.escape) { setOpenName(null); setFocus("sidebar"); } + return; + } + + // Sidebar + if (key.escape) return; + if (input === "/" || (input && !key.ctrl && !key.meta && input !== "j" && input !== "k" && input !== "q")) { + setFocus("searching"); + if (input !== "/") setSearchQuery((q) => q + input); + return; + } + if (key.upArrow || input === "k") { moveSelection(-1); return; } + if (key.downArrow || input === "j") { moveSelection(1); return; } + if (key.return) { + const item = filteredItems[selectedIdx]; + if (item?.kind === "entry") { + setOpenName(item.name); + setFocus("preview"); + } + return; + } + }); + + const sidebarWidth = 24; + let hints: Record; + if (focus === "searching") { + hints = { "up-down": "navigate", enter: "open", esc: "clear", q: false }; + } else if (focus === "preview") { + hints = { esc: "back", q: "quit" }; + } else { + hints = { "up-down": "navigate", enter: "open", "/": "search", q: "quit" }; + } + + const openEntry = openName ? REGISTRY_MAP.get(openName) : undefined; + + return ( + TUIKit Preview} + footer={} + > + + + + {focus === "searching" ? "▸ " : " "} + + + {filteredItems.map((item, i) => { + if (item.kind === "separator") { + return {SEPARATOR}; + } + const isHighlighted = i === selectedIdx && focus !== "preview"; + const isOpen = item.name === openName; + return ( + + + {isHighlighted ? "▸ " : " "}{item.display} + + {isOpen ? : null} + + ); + })} + + + + {openEntry ? ( + <> + + {openName!} + + + + ) : ( + + Select a component to preview + + )} + + + + ); + } + + process.stdout.write("\x1b[?1049h"); + const app = inkRender(React.createElement(App)); + app.waitUntilExit().then(() => { + process.stdout.write("\x1b[?1049l"); + }); +} diff --git a/dist/bun/tsconfig.json b/dist/bun/tsconfig.json new file mode 100644 index 0000000..c95c67b --- /dev/null +++ b/dist/bun/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "strict": true, + "module": "esnext", + "moduleResolution": "bundler", + "target": "esnext", + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["bun-types"] + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "demo.tsx", "demo.test.tsx"] +} From 41e9bf1b6b02ca7e63dc5e583f0c618162cb3a2c Mon Sep 17 00:00:00 2001 From: Ismael Canal Date: Fri, 17 Apr 2026 14:42:21 +0200 Subject: [PATCH 08/18] feat(go): add CLI flags, smoke tests, fix Select nil-pointer bug - Add --list, --component, --variant, --snapshot CLI flags to demo app - Implement full component/variant registry with snapshot rendering - Add table-driven smoke tests (TestSnapshotAllComponents, TestSnapshotEachVariant) that verify every component renders via --snapshot with exit 0 and non-empty output - Fix Select component nil-pointer panic on TextOnBackgroundSecondary (was *lipgloss.Color nil, now guarded before dereference) - Fix Select/SelectAutocomplete terminal state bug in demo: reset State back to StateFocused after Enter/number-key selection so the preview stays interactive - Add q-to-quit from preview focus (except text-input components) - Re-init interactive sub-models on each component open for clean state - Render all preview variants with TextHeading labels per spec Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dist/go/cmd/demo/main.go | 1194 +++++++++++++++++ dist/go/cmd/demo/main_test.go | 95 ++ .../tuikit/components/selectcomp/select.go | 302 +++++ 3 files changed, 1591 insertions(+) create mode 100644 dist/go/cmd/demo/main.go create mode 100644 dist/go/cmd/demo/main_test.go create mode 100644 dist/go/pkg/tuikit/components/selectcomp/select.go diff --git a/dist/go/cmd/demo/main.go b/dist/go/cmd/demo/main.go new file mode 100644 index 0000000..181b2ff --- /dev/null +++ b/dist/go/cmd/demo/main.go @@ -0,0 +1,1194 @@ +// Demo is an interactive TUI component browser for TUIKit. +// Two-panel layout: sidebar browser on the left, main preview on the right. +// +// CLI flags: +// +// (no flags) Launch full interactive TUI +// --list Print all component/token names +// --component Open directly into a component +// --component --variant Open a specific variant +// --component --snapshot Render one frame to stdout and exit +// --component --variant --snapshot Render one variant frame +package main + +import ( + "flag" + "fmt" + "os" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/basiclines/tuikit/pkg/tuikit/components/dialog" + "github.com/basiclines/tuikit/pkg/tuikit/components/hintbar" + "github.com/basiclines/tuikit/pkg/tuikit/components/icons" + "github.com/basiclines/tuikit/pkg/tuikit/components/input" + "github.com/basiclines/tuikit/pkg/tuikit/components/link" + "github.com/basiclines/tuikit/pkg/tuikit/components/metric" + "github.com/basiclines/tuikit/pkg/tuikit/components/qrcode" + "github.com/basiclines/tuikit/pkg/tuikit/components/screen" + "github.com/basiclines/tuikit/pkg/tuikit/components/scrollbox" + "github.com/basiclines/tuikit/pkg/tuikit/components/selectautocomplete" + "github.com/basiclines/tuikit/pkg/tuikit/components/selectcomp" + "github.com/basiclines/tuikit/pkg/tuikit/components/tabbar" + "github.com/basiclines/tuikit/pkg/tuikit/components/tablecomp" + "github.com/basiclines/tuikit/pkg/tuikit/components/textheading" + "github.com/basiclines/tuikit/pkg/tuikit/components/textspinner" + "github.com/basiclines/tuikit/pkg/tuikit/components/texttitle" + "github.com/basiclines/tuikit/pkg/tuikit/components/timelineitem" + "github.com/basiclines/tuikit/pkg/tuikit/tokens" +) + +// --------------------------------------------------------------------------- +// Component registry +// --------------------------------------------------------------------------- + +// componentDef defines a previewable component/token with its variant names. +type componentDef struct { + Name string + IsToken bool + Variants []string // matches ## headings from .preview.md +} + +// ComponentRegistry is the ordered list of all previewable items. +// Tokens first (lowercase), then components (PascalCase), alphabetical within +// each group. +var ComponentRegistry = []componentDef{ + {Name: "breakpoints", IsToken: true, Variants: []string{"Current breakpoint"}}, + {Name: "colors", IsToken: true, Variants: []string{"Semantic colors", "Text tokens", "Status tokens", "Brand tokens"}}, + {Name: "icons", IsToken: true, Variants: []string{"Status icons", "Navigation icons", "UI icons", "Tree icons"}}, + {Name: "Dialog", Variants: []string{"Basic", "With subtitle", "Fixed width", "Border title", "Full variant"}}, + {Name: "HintBar", Variants: []string{"Default", "Custom keys", "Conditional", "Custom separator"}}, + {Name: "Input", Variants: []string{"Default", "Multiline", "Masked", "Single line"}}, + {Name: "Link", Variants: []string{"Default", "With label color", "With brand color", "Bold"}}, + {Name: "Metric", Variants: []string{"Default", "Highlighted"}}, + {Name: "QrCode", Variants: []string{"Short URL", "Long URL"}}, + {Name: "Screen", Variants: []string{"Basic", "With header and footer", "Non-scrollable"}}, + {Name: "ScrollBox", Variants: []string{"No scroll (content fits)", "Scrollable list", "No scrollbar", "Focusable", "Hover + virtualized"}}, + {Name: "Select", Variants: []string{"Basic", "With current item", "With text input", "Scrolling"}}, + {Name: "SelectAutocomplete", Variants: []string{"Basic", "With current item"}}, + {Name: "TabBar", Variants: []string{"Display only", "Arrow navigation", "Tab navigation", "No loop"}}, + {Name: "Table", Variants: []string{"Basic", "Borderless key-value", "Right-aligned numbers", "Width-constrained"}}, + {Name: "TextHeading", Variants: []string{"Default", "Error"}}, + {Name: "TextSpinner", Variants: []string{"Default", "Icon only", "Label only", "Placeholder", "Brand", "Info"}}, + {Name: "TextTitle", Variants: []string{"Default", "Error"}}, + {Name: "TimelineItem", Variants: []string{"Loading", "Success", "Error", "Warning", "Info", "Muted", "With multiple sub-items"}}, +} + +// ComponentNames returns the list of all component/token names. +func ComponentNames() []string { + names := make([]string, len(ComponentRegistry)) + for i, c := range ComponentRegistry { + names[i] = c.Name + } + return names +} + +func findComponentDef(name string) *componentDef { + for i := range ComponentRegistry { + if ComponentRegistry[i].Name == name { + return &ComponentRegistry[i] + } + } + return nil +} + +func (c *componentDef) hasVariant(name string) bool { + for _, v := range c.Variants { + if v == name { + return true + } + } + return false +} + +// sidebarDisplayName returns a human-friendly label for the sidebar. +func sidebarDisplayName(name string) string { + if len(name) > 0 && name[0] >= 'a' && name[0] <= 'z' { + return strings.ToUpper(name[:1]) + name[1:] + } + return name +} + +// --------------------------------------------------------------------------- +// Snapshot rendering — pure functions, no TUI required +// --------------------------------------------------------------------------- + +// RenderSnapshot renders one frame of a component (all variants or a single +// variant) to a string. Used by --snapshot and by the demo smoke tests. +func RenderSnapshot(compName, variantFilter string, colors tokens.SemanticColors, width int) string { + comp := findComponentDef(compName) + if comp == nil { + return "" + } + + var b strings.Builder + for _, vName := range comp.Variants { + if variantFilter != "" && vName != variantFilter { + continue + } + heading := textheading.New(vName, colors) + b.WriteString(heading.View() + "\n") + b.WriteString(renderVariant(compName, vName, colors, width)) + b.WriteString("\n\n") + } + return strings.TrimRight(b.String(), "\n") +} + +//nolint:cyclop // switch on component names is inherently long +func renderVariant(compName, variant string, colors tokens.SemanticColors, width int) string { + switch compName { + // ---- Tokens ---- + case "breakpoints": + return renderBreakpointVariant(variant, colors, width) + case "colors": + return renderColorVariant(variant, colors) + case "icons": + return renderIconVariant(variant, colors) + + // ---- Components ---- + case "Dialog": + return renderDialogVariant(variant, colors) + case "HintBar": + return renderHintBarVariant(variant, colors) + case "Input": + return renderInputVariant(variant, colors) + case "Link": + return renderLinkVariant(variant, colors) + case "Metric": + return renderMetricVariant(variant, colors) + case "QrCode": + return renderQrCodeVariant(variant) + case "Screen": + return renderScreenVariant(variant, colors) + case "ScrollBox": + return renderScrollBoxVariant(variant, colors) + case "Select": + return renderSelectVariant(variant, colors) + case "SelectAutocomplete": + return renderSelectAutocompleteVariant(variant, colors) + case "TabBar": + return renderTabBarVariant(variant, colors, width) + case "Table": + return renderTableVariant(variant, colors) + case "TextHeading": + return renderTextHeadingVariant(variant, colors) + case "TextSpinner": + return renderTextSpinnerVariant(variant, colors) + case "TextTitle": + return renderTextTitleVariant(variant, colors) + case "TimelineItem": + return renderTimelineItemVariant(variant, colors) + } + return "" +} + +// --- Token variant renderers --- + +func renderBreakpointVariant(_ string, colors tokens.SemanticColors, width int) string { + bp := tokens.GetBreakpoint(width) + bpStyle := lipgloss.NewStyle().Foreground(colors.Selected).Bold(true) + var b strings.Builder + b.WriteString(fmt.Sprintf(" Terminal width: %d columns\n", width)) + b.WriteString(fmt.Sprintf(" Current breakpoint: %s\n\n", bpStyle.Render(bp.String()))) + b.WriteString(fmt.Sprintf(" Compact: < 80 columns %s\n", checkIf(bp == tokens.BreakpointCompact, colors))) + b.WriteString(fmt.Sprintf(" Narrow: 80–119 %s\n", checkIf(bp == tokens.BreakpointNarrow, colors))) + b.WriteString(fmt.Sprintf(" Wide: ≥ 120 %s\n", checkIf(bp == tokens.BreakpointWide, colors))) + return b.String() +} + +func renderColorVariant(variant string, colors tokens.SemanticColors) string { + type ce struct { + name string + color lipgloss.Color + } + var entries []ce + switch variant { + case "Text tokens": + entries = []ce{ + {"TextSecondary", colors.TextSecondary}, + {"TextTertiary", colors.TextTertiary}, + } + case "Status tokens": + entries = []ce{ + {"StatusSuccess", colors.StatusSuccess}, + {"StatusWarning", colors.StatusWarning}, + {"StatusError", colors.StatusError}, + {"StatusInfo", colors.StatusInfo}, + } + case "Brand tokens": + entries = []ce{ + {"Brand", colors.Brand}, + {"BrandBright", colors.BrandBright}, + {"Selected", colors.Selected}, + {"SelectedBright", colors.SelectedBright}, + } + default: // Semantic colors + entries = []ce{ + {"Selected", colors.Selected}, + {"SelectedBright", colors.SelectedBright}, + {"StatusSuccess", colors.StatusSuccess}, + {"StatusWarning", colors.StatusWarning}, + {"StatusError", colors.StatusError}, + {"StatusInfo", colors.StatusInfo}, + {"Brand", colors.Brand}, + {"TextSecondary", colors.TextSecondary}, + {"TextTertiary", colors.TextTertiary}, + {"BorderNeutral", colors.BorderNeutral}, + } + } + var b strings.Builder + for _, e := range entries { + swatch := lipgloss.NewStyle().Background(e.color).Render(" ") + label := lipgloss.NewStyle().Foreground(e.color).Render(e.name) + b.WriteString(fmt.Sprintf(" %s %s\n", swatch, label)) + } + return b.String() +} + +func renderIconVariant(variant string, colors tokens.SemanticColors) string { + type ie struct{ name, glyph string } + var list []ie + switch variant { + case "Navigation icons": + list = []ie{ + {"ArrowUp", tokens.ArrowUp}, {"ArrowDown", tokens.ArrowDown}, + {"ArrowLeft", tokens.ArrowLeft}, {"ArrowRight", tokens.ArrowRight}, + } + case "UI icons": + list = []ie{ + {"CheckboxChecked", tokens.CheckboxChecked}, {"CheckboxUnchecked", tokens.CheckboxUnchecked}, + {"Scrollbar", tokens.Scrollbar}, {"SeparatorWord", tokens.DotSeparator}, + {"SeparatorList", tokens.Bullet}, + } + case "Tree icons": + list = []ie{ + {"NestingLast", tokens.ChildLast}, {"NestingMiddle", tokens.ChildMiddle}, + {"NestingSkip", tokens.ChildSkip}, + } + default: // Status icons + list = []ie{ + {"Success", tokens.Check}, {"Error", tokens.Cross}, + {"Warning", tokens.Warning}, {"Prompt", tokens.ChevronRight}, + {"InfoCompleted", tokens.CircleFilled}, {"InfoWorking", tokens.CircleHalf}, + {"InfoEmpty", tokens.CircleEmpty}, + } + } + var b strings.Builder + for _, ic := range list { + i := icons.CreateIcon(ic.glyph, ic.name) + b.WriteString(fmt.Sprintf(" %s %s\n", i.View(colors), ic.name)) + } + return b.String() +} + +// --- Component variant renderers --- + +func renderDialogVariant(variant string, colors tokens.SemanticColors) string { + switch variant { + case "With subtitle": + return dialog.New("Confirm Delete", "Are you sure?", colors). + WithSubtitle("This action cannot be undone").View() + case "Fixed width": + return dialog.New("Narrow Dialog", "Content constrained to 40 columns.", colors). + WithWidth(40).View() + case "Border title": + return dialog.New("Session Info", "Model: GPT-4 · Tokens: 1,234", colors). + WithTitlePlacement("border").View() + case "Full variant": + return dialog.New("Permissions", "Allow access to this folder?", colors). + WithTitlePlacement("border").WithSubtitle("Required for this workspace").View() + default: // Basic + return dialog.New("Notice", "This is a simple dialog.", colors).View() + } +} + +func renderHintBarVariant(variant string, colors tokens.SemanticColors) string { + switch variant { + case "Custom keys": + return hintbar.New([]hintbar.Hint{ + {Key: "tab", Label: "next file"}, {Key: "shift-tab", Label: "previous file"}, + {Key: "s", Label: "to save"}, {Key: "esc", Label: "to close"}, + }, colors).View() + case "Conditional": + return hintbar.New([]hintbar.Hint{ + {Key: "up-down", Label: "to navigate"}, {Key: "enter", Label: "to select"}, + {Key: "esc", Label: "to cancel"}, + }, colors).View() + case "Custom separator": + return hintbar.New([]hintbar.Hint{ + {Key: "a", Label: "one"}, {Key: "b", Label: "two"}, {Key: "c", Label: "three"}, + }, colors).WithSeparator(" | ").View() + default: // Default + return hintbar.New([]hintbar.Hint{ + {Key: "up-down", Label: "to navigate"}, {Key: "enter", Label: "to select"}, + {Key: "esc", Label: "to cancel"}, + }, colors).View() + } +} + +func renderInputVariant(variant string, colors tokens.SemanticColors) string { + switch variant { + case "Multiline": + return input.New(colors).WithPlaceholder("Type here... (Shift+Enter for newlines)").View() + case "Masked": + return input.New(colors).WithPlaceholder("Enter password...").WithMask("*").View() + case "Single line": + return input.New(colors).WithPlaceholder("No newlines allowed...").WithSingleLine(true).View() + default: // Default + return input.New(colors).WithPlaceholder("Type here...").View() + } +} + +func renderLinkVariant(variant string, colors tokens.SemanticColors) string { + switch variant { + case "With label color": + return link.New("https://github.com").WithColor(colors.MarkdownLink).View() + case "With brand color": + return link.New("https://github.com").WithColor(colors.Brand).View() + case "Bold": + return link.New("https://github.com").WithColor(colors.MarkdownLink).WithBold(true).View() + default: // Default + return link.New("https://github.com").View() + } +} + +func renderMetricVariant(variant string, colors tokens.SemanticColors) string { + chars := []metric.MetricChar{ + metric.NewUniformChar("U+25A0", "■"), + metric.NewUniformChar("U+2588", "█"), + metric.NewProgressiveChar("U+28xx", []string{"⣀", "⣤", "⣶", "⣿"}), + } + met := metric.New(chars, colors) + _ = variant // Highlighted variant uses same data (color override not yet wired) + return met.View() +} + +func renderQrCodeVariant(variant string) string { + switch variant { + case "Long URL": + return qrcode.New("https://github.com/github/copilot-agent-runtime/tasks/abc-123").View() + default: + return qrcode.New("https://github.com").View() + } +} + +func renderScreenVariant(variant string, colors tokens.SemanticColors) string { + switch variant { + case "With header and footer": + lines := []string{ + "10:21:03 Server starting on port 3000", + "10:21:04 Connected to database", + "10:21:05 Registered 14 API routes", + } + return screen.New(lines, 10, colors). + WithHeader("Screen — Screen.tsx\nApplication Log (3 entries)"). + WithFooter("↑↓ scroll · Esc back").View() + case "Non-scrollable": + lines := []string{"Line 1", "Line 2", "Line 3"} + return screen.New(lines, 10, colors). + WithScrollable(false).WithHeader("Static Screen").WithFooter("Esc back").View() + default: // Basic + lines := []string{ + "10:21:03 Server starting on port 3000", + "10:21:04 Connected to database", + "10:21:05 Registered 14 API routes", + } + return screen.New(lines, 10, colors).View() + } +} + +func renderScrollBoxVariant(variant string, colors tokens.SemanticColors) string { + switch variant { + case "Scrollable list": + lines := []string{ + " ◎ 001 [INFO ] Server starting on port 3000", + " ◎ 002 [INFO ] Loading configuration from .env", + " ✓ 003 [OK ] Connected to database", + " ◎ 004 [INFO ] Processing batch job #1284", + " ✓ 005 [OK ] Batch job completed (42 items)", + " ! 006 [WARN ] Redis not configured", + " ◎ 007 [INFO ] Incoming webhook from GitHub", + " ✖ 008 [ERROR] Build failed: missing dependency", + } + return scrollbox.New(lines, 5, colors).View() + case "No scrollbar": + lines := []string{"Item 0", "Item 1", "Item 2", "Item 3", "Item 4", "Item 5"} + return scrollbox.New(lines, 4, colors).WithShowScrollbar(false).View() + case "Focusable": + lines := []string{ + "❯ 001 [INFO ] First item", " 002 [WARN ] Second item", + " 003 [OK ] Third item", " 004 [INFO ] Fourth item", + } + return scrollbox.New(lines, 4, colors).View() + case "Hover + virtualized": + lines := []string{ + "❯ 001 [INFO ] Item one", " 002 [INFO ] Item two", + " 003 [WARN ] Item three", " 004 [OK ] Item four", + " 005 [INFO ] Item five", " 006 [ERROR] Item six", + } + return scrollbox.New(lines, 4, colors).View() + default: // No scroll (content fits) + lines := []string{ + " ◎ 001 [INFO ] Server starting on port 3000", + " ✓ 002 [OK ] Connected to database", + " ◎ 003 [INFO ] Registered 14 API routes", + } + return scrollbox.New(lines, 5, colors).View() + } +} + +func renderSelectVariant(variant string, colors tokens.SemanticColors) string { + switch variant { + case "With current item": + items := []selectcomp.SelectItem[string]{ + {Label: "Alpha", Value: "alpha"}, + {Label: "Beta", Value: "beta", Current: true}, + {Label: "Gamma", Value: "gamma"}, + } + return selectcomp.New(items, colors). + WithEscapeItem(selectcomp.SelectItem[string]{Label: "Cancel", Value: "cancel"}).View() + case "With text input": + items := []selectcomp.SelectItem[string]{ + {Label: "Alpha", Value: "alpha"}, + {Label: "Beta", Value: "beta"}, + } + return selectcomp.New(items, colors). + WithEscapeItem(selectcomp.SelectItem[string]{Label: "Something else...", Value: "other"}).View() + case "Scrolling": + items := []selectcomp.SelectItem[string]{ + {Label: "Alpha", Value: "alpha"}, {Label: "Beta", Value: "beta"}, + {Label: "Gamma", Value: "gamma"}, {Label: "Delta", Value: "delta"}, + {Label: "Epsilon", Value: "epsilon"}, {Label: "Zeta", Value: "zeta"}, + {Label: "Eta", Value: "eta"}, {Label: "Theta", Value: "theta"}, + {Label: "Iota", Value: "iota"}, {Label: "Kappa", Value: "kappa"}, + } + return selectcomp.New(items, colors). + WithEscapeItem(selectcomp.SelectItem[string]{Label: "Cancel", Value: "cancel"}).View() + default: // Basic + items := []selectcomp.SelectItem[string]{ + {Label: "Alpha", Value: "alpha"}, + {Label: "Beta", Value: "beta"}, + {Label: "Gamma", Value: "gamma"}, + } + return selectcomp.New(items, colors). + WithEscapeItem(selectcomp.SelectItem[string]{Label: "Cancel", Value: "cancel"}).View() + } +} + +func renderSelectAutocompleteVariant(variant string, colors tokens.SemanticColors) string { + switch variant { + case "With current item": + items := []selectautocomplete.Item{ + {Label: "Alpha", Value: "alpha"}, + {Label: "Beta", Value: "beta", Current: true}, + {Label: "Gamma", Value: "gamma"}, + } + return selectautocomplete.New(items, colors). + WithEscapeItem(selectautocomplete.Item{Label: "Cancel", Value: "cancel"}). + WithSearchPlaceholder("Search...").View() + default: // Basic + items := []selectautocomplete.Item{ + {Label: "Alpha", Value: "alpha"}, + {Label: "Beta", Value: "beta"}, + {Label: "Gamma", Value: "gamma"}, + } + return selectautocomplete.New(items, colors). + WithEscapeItem(selectautocomplete.Item{Label: "Cancel", Value: "cancel"}). + WithSearchPlaceholder("Search options...").View() + } +} + +func renderTabBarVariant(variant string, colors tokens.SemanticColors, width int) string { + switch variant { + case "Arrow navigation": + items := []tabbar.TabItem{ + {Value: "1", Label: "index.ts"}, {Value: "2", Label: "utils.ts"}, + {Value: "3", Label: "config.ts"}, {Value: "4", Label: "types.ts"}, + {Value: "5", Label: "test.ts"}, + } + return tabbar.New(items, 0, colors).WithNavKeys("arrow-only").WithWidth(width).View() + case "Tab navigation": + items := []tabbar.TabItem{ + {Value: "1", Label: "Overview"}, {Value: "2", Label: "Details"}, + {Value: "3", Label: "Settings"}, + } + return tabbar.New(items, 0, colors).WithNavKeys("tab-only").WithWidth(width).View() + case "No loop": + items := []tabbar.TabItem{ + {Value: "1", Label: "First"}, {Value: "2", Label: "Second"}, + {Value: "3", Label: "Third"}, + } + return tabbar.New(items, 0, colors).WithNavKeys("all").WithLoop(false).WithWidth(width).View() + default: // Display only + items := []tabbar.TabItem{ + {Value: "overview", Label: "Overview"}, {Value: "details", Label: "Details"}, + {Value: "settings", Label: "Settings"}, {Value: "about", Label: "About"}, + } + return tabbar.New(items, 1, colors).WithWidth(width).View() + } +} + +func renderTableVariant(variant string, colors tokens.SemanticColors) string { + switch variant { + case "Borderless key-value": + rows := [][]tablecomp.TableCell{ + {tablecomp.NewTextCell("Type"), tablecomp.NewTextCell("stdio")}, + {tablecomp.NewTextCell("Status"), tablecomp.NewTextCell("Connected")}, + {tablecomp.NewTextCell("Model"), tablecomp.NewTextCell("GPT-4")}, + } + return tablecomp.New(rows, colors).WithBorderStyle("none").View() + case "Right-aligned numbers": + rows := [][]tablecomp.TableCell{ + {tablecomp.NewTextCell("Latency"), tablecomp.NewTextCell("42"), tablecomp.NewTextCell("ms")}, + {tablecomp.NewTextCell("Tokens"), tablecomp.NewTextCell("1234"), tablecomp.NewTextCell("tok")}, + {tablecomp.NewTextCell("Cost"), tablecomp.NewTextCell("0.03"), tablecomp.NewTextCell("USD")}, + } + return tablecomp.New(rows, colors). + WithHeaders([]string{"Metric", "Value", "Unit"}). + WithAlign([]string{"left", "right", "left"}).View() + case "Width-constrained": + rows := [][]tablecomp.TableCell{ + {tablecomp.NewTextCell("E001"), tablecomp.NewTextCell("Missing required field 'name' in configuration")}, + {tablecomp.NewTextCell("E002"), tablecomp.NewTextCell("Connection timeout after 30 seconds")}, + } + return tablecomp.New(rows, colors). + WithHeaders([]string{"Error", "Message"}).WithWidth(60).View() + default: // Basic + rows := [][]tablecomp.TableCell{ + {tablecomp.NewTextCell("/help"), tablecomp.NewTextCell("Show all commands")}, + {tablecomp.NewTextCell("/theme"), tablecomp.NewTextCell("Change color theme")}, + {tablecomp.NewTextCell("/clear"), tablecomp.NewTextCell("Clear conversation")}, + } + return tablecomp.New(rows, colors). + WithHeaders([]string{"Command", "Description"}).View() + } +} + +func renderTextHeadingVariant(variant string, colors tokens.SemanticColors) string { + switch variant { + case "Error": + return textheading.New("Error Details", colors).WithType("error").View() + default: + return textheading.New("Section Heading", colors).View() + } +} + +func renderTextSpinnerVariant(variant string, colors tokens.SemanticColors) string { + switch variant { + case "Icon only": + return textspinner.New(colors).View() + case "Label only": + return textspinner.New(colors).WithShowIcon(false).WithText("Unlimited reqs.").View() + case "Placeholder": + return textspinner.New(colors).WithText("Waiting for input").WithVariant(textspinner.VariantPlaceholder).View() + case "Brand": + return textspinner.New(colors).WithText("Thinking").WithVariant(textspinner.VariantBrand).View() + case "Info": + return textspinner.New(colors).WithText("Compacting conversation history").WithVariant(textspinner.VariantInfo).View() + default: // Default + return textspinner.New(colors).WithText("Loading").View() + } +} + +func renderTextTitleVariant(variant string, colors tokens.SemanticColors) string { + switch variant { + case "Error": + return texttitle.New("Something went wrong", colors).WithType("error").View() + default: + return texttitle.New("Welcome to TUIkit", colors).View() + } +} + +func renderTimelineItemVariant(variant string, colors tokens.SemanticColors) string { + switch variant { + case "Success": + return timelineitem.New("Grep", timelineitem.VariantSuccess, colors). + WithDescription(`"pattern" in *.ts`). + WithSubItems([]string{"5 files found"}).View() + case "Error": + return timelineitem.New("Bash", timelineitem.VariantError, colors). + WithDescription("npm run build"). + WithSubItems([]string{"Exit code 1"}).View() + case "Warning": + return timelineitem.New("Bash", timelineitem.VariantWarning, colors). + WithDescription("rm -rf /"). + WithSubItems([]string{"Rejected by you."}).View() + case "Info": + return timelineitem.New("Compacted", timelineitem.VariantInfo, colors). + WithDescription("Removed 42 messages").View() + case "Muted": + return timelineitem.New("Read", timelineitem.VariantMuted, colors). + WithDescription("src/index.ts"). + WithSubItems([]string{"24 lines"}).View() + case "With multiple sub-items": + return timelineitem.New("Edit", timelineitem.VariantSuccess, colors). + WithDescription("src/auth.ts"). + WithSubItems([]string{"Added JWT validation", "Removed deprecated handler", "+12 -8 lines"}).View() + default: // Loading + return timelineitem.New("Grep", timelineitem.VariantLoading, colors). + WithDescription(`"pattern" in *.ts`).View() + } +} + +func checkIf(active bool, colors tokens.SemanticColors) string { + if active { + return lipgloss.NewStyle().Foreground(colors.StatusSuccess).Render(tokens.Check) + } + return " " +} + +// --------------------------------------------------------------------------- +// Interactive TUI model +// --------------------------------------------------------------------------- + +type focus int + +const ( + focusSidebar focus = iota + focusPreview +) + +// TickMsg triggers spinner animation. +type TickMsg time.Time + +type model struct { + colors tokens.SemanticColors + focus focus + highlighted int + openIndex int // -1 = nothing open + searchTerm string + searching bool + width int + height int + sidebarW int + + // When non-empty, skip sidebar and open directly into this component. + directComponent string + + // Reusable component models for interactive previews + spinner textspinner.Model + inputModel input.Model + tabModel tabbar.Model + selectModel selectcomp.Model + scrollModel scrollbox.Model + saModel selectautocomplete.Model +} + +func initialModel() model { + colors := tokens.ResolveColors(tokens.ModeDefault) + return model{ + colors: colors, + focus: focusSidebar, + openIndex: -1, + sidebarW: 24, + spinner: textspinner.New(colors).WithText("Loading demo..."), + } +} + +// initInteractiveModels creates/resets the interactive component models used +// in the preview panel. +func (m *model) initInteractiveModels() { + colors := m.colors + + m.spinner = textspinner.New(colors).WithText("Loading demo...") + + m.selectModel = selectcomp.New([]selectcomp.SelectItem[string]{ + {Label: "Alpha", Value: "alpha"}, + {Label: "Beta", Value: "beta"}, + {Label: "Gamma", Value: "gamma"}, + }, colors).WithEscapeItem(selectcomp.SelectItem[string]{Label: "Cancel", Value: "cancel"}) + + m.tabModel = tabbar.New([]tabbar.TabItem{ + {Value: "overview", Label: "Overview"}, + {Value: "details", Label: "Details"}, + {Value: "settings", Label: "Settings"}, + {Value: "about", Label: "About"}, + }, 0, colors) + + m.inputModel = input.New(colors).WithPlaceholder("Type here...") + + m.saModel = selectautocomplete.New([]selectautocomplete.Item{ + {Label: "Alpha", Value: "alpha"}, + {Label: "Beta", Value: "beta"}, + {Label: "Gamma", Value: "gamma"}, + }, colors).WithEscapeItem(selectautocomplete.Item{Label: "Cancel", Value: "cancel"}). + WithSearchPlaceholder("Search options...") + + scrollLines := make([]string, 20) + for i := range scrollLines { + scrollLines[i] = fmt.Sprintf("Scrollable line %d", i+1) + } + m.scrollModel = scrollbox.New(scrollLines, 8, colors) +} + +func (m model) Init() tea.Cmd { + return tea.Batch(tickCmd()) +} + +func tickCmd() tea.Cmd { + return tea.Tick(120*time.Millisecond, func(t time.Time) tea.Msg { + return TickMsg(t) + }) +} + +func (m model) filteredEntries() []componentDef { + if m.searchTerm == "" { + return ComponentRegistry + } + term := strings.ToLower(m.searchTerm) + var out []componentDef + for _, e := range ComponentRegistry { + if fuzzyMatch(strings.ToLower(e.Name), term) { + out = append(out, e) + } + } + return out +} + +func fuzzyMatch(text, pattern string) bool { + pi := 0 + for ti := 0; ti < len(text) && pi < len(pattern); ti++ { + if text[ti] == pattern[pi] { + pi++ + } + } + return pi == len(pattern) +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + // On first size message with --component, jump to that component. + if m.directComponent != "" { + idx := m.resolveIndex(m.directComponent) + if idx >= 0 { + m.openIndex = idx + m.focus = focusPreview + m.initInteractiveModels() + } + m.directComponent = "" // consume + } + return m, nil + + case TickMsg: + newSp, cmd := m.spinner.Update(textspinner.TickMsg{}) + m.spinner = newSp.(textspinner.Model) + return m, tea.Batch(cmd, tickCmd()) + + case tea.KeyMsg: + if msg.String() == "ctrl+c" { + return m, tea.Quit + } + + if m.focus == focusSidebar { + // q quits from sidebar (unless searching) + if msg.String() == "q" && !m.searching { + return m, tea.Quit + } + return m.updateSidebar(msg) + } + return m.updatePreview(msg) + } + return m, nil +} + +func (m model) updateSidebar(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + entries := m.filteredEntries() + + if m.searching { + switch msg.Type { + case tea.KeyEscape: + m.searching = false + m.searchTerm = "" + m.highlighted = 0 + return m, nil + case tea.KeyBackspace: + if len(m.searchTerm) > 0 { + m.searchTerm = m.searchTerm[:len(m.searchTerm)-1] + m.highlighted = 0 + } + return m, nil + case tea.KeyUp: + if len(entries) > 0 && m.highlighted > 0 { + m.highlighted-- + } + return m, nil + case tea.KeyDown: + if len(entries) > 0 && m.highlighted < len(entries)-1 { + m.highlighted++ + } + return m, nil + case tea.KeyEnter: + if len(entries) > 0 { + m.openIndex = m.resolveIndex(entries[m.highlighted].Name) + m.focus = focusPreview + m.searching = false + m.initInteractiveModels() + } + return m, nil + case tea.KeyRunes: + m.searchTerm += string(msg.Runes) + m.highlighted = 0 + return m, nil + } + return m, nil + } + + switch msg.String() { + case "up", "k": + if m.highlighted > 0 { + m.highlighted-- + } + case "down", "j": + if len(entries) > 0 && m.highlighted < len(entries)-1 { + m.highlighted++ + } + case "enter": + if len(entries) > 0 { + m.openIndex = m.resolveIndex(entries[m.highlighted].Name) + m.focus = focusPreview + m.initInteractiveModels() + } + case "/": + m.searching = true + default: + if msg.Type == tea.KeyRunes { + m.searching = true + m.searchTerm = string(msg.Runes) + m.highlighted = 0 + } + } + return m, nil +} + +func (m model) resolveIndex(name string) int { + for i, e := range ComponentRegistry { + if e.Name == name { + return i + } + } + return -1 +} + +func (m model) isTextInputComponent() bool { + if m.openIndex < 0 || m.openIndex >= len(ComponentRegistry) { + return false + } + name := ComponentRegistry[m.openIndex].Name + return name == "Input" || name == "SelectAutocomplete" +} + +func (m model) updatePreview(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Escape always returns to sidebar + if msg.String() == "esc" { + m.focus = focusSidebar + return m, nil + } + + // q quits from preview (except for text-input components where it is + // forwarded as a typed character) + if msg.String() == "q" && !m.isTextInputComponent() { + return m, tea.Quit + } + + if m.openIndex < 0 || m.openIndex >= len(ComponentRegistry) { + return m, nil + } + + name := ComponentRegistry[m.openIndex].Name + switch name { + case "Input": + newM, cmd := m.inputModel.Update(msg) + m.inputModel = newM.(input.Model) + return m, cmd + case "TabBar": + newM, cmd := m.tabModel.Update(msg) + m.tabModel = newM.(tabbar.Model) + return m, cmd + case "Select": + newM, cmd := m.selectModel.Update(msg) + m.selectModel = newM.(selectcomp.Model) + // Reset terminal state so the demo stays interactive + if m.selectModel.State != selectcomp.StateFocused { + m.selectModel.State = selectcomp.StateFocused + } + return m, cmd + case "SelectAutocomplete": + newM, cmd := m.saModel.Update(msg) + m.saModel = newM.(selectautocomplete.Model) + if m.saModel.State != selectautocomplete.StateFocused { + m.saModel.State = selectautocomplete.StateFocused + } + return m, cmd + case "ScrollBox": + newM, cmd := m.scrollModel.Update(msg) + m.scrollModel = newM.(scrollbox.Model) + return m, cmd + } + return m, nil +} + +// --------------------------------------------------------------------------- +// View +// --------------------------------------------------------------------------- + +func (m model) View() string { + if m.width == 0 || m.height == 0 { + return "Initializing..." + } + + sidebar := m.renderSidebar() + preview := m.renderPreview() + + mainW := m.width - m.sidebarW - 3 + if mainW < 10 { + mainW = 10 + } + + sidebarStyle := lipgloss.NewStyle(). + Width(m.sidebarW). + Height(m.height - 2). + BorderStyle(lipgloss.NormalBorder()). + BorderRight(true). + BorderForeground(m.colors.BorderNeutral). + PaddingRight(1) + + previewStyle := lipgloss.NewStyle(). + Width(mainW). + Height(m.height - 2). + PaddingLeft(1) + + if m.focus == focusSidebar { + sidebarStyle = sidebarStyle.BorderForeground(m.colors.Selected) + } + + content := lipgloss.JoinHorizontal(lipgloss.Top, + sidebarStyle.Render(sidebar), + previewStyle.Render(preview), + ) + + hints := m.buildHintBar() + return content + "\n" + hints.View() +} + +func (m model) renderSidebar() string { + var b strings.Builder + + title := texttitle.New("TUIkit Preview", m.colors) + b.WriteString(title.View()) + b.WriteString("\n\n") + + if m.searching { + searchStyle := lipgloss.NewStyle().Foreground(m.colors.Selected) + b.WriteString(searchStyle.Render(tokens.IconPrompt+" "+m.searchTerm) + "█\n\n") + } else { + dimStyle := lipgloss.NewStyle().Foreground(m.colors.TextTertiary) + b.WriteString(dimStyle.Render(" ▸ Search...") + "\n\n") + } + + entries := m.filteredEntries() + lastWasToken := false + + for i, e := range entries { + if lastWasToken && !e.IsToken { + dimStyle := lipgloss.NewStyle().Foreground(m.colors.TextTertiary) + b.WriteString(dimStyle.Render(" ──────────────────") + "\n") + lastWasToken = false + } + if e.IsToken { + lastWasToken = true + } + + prefix := " " + nameStyle := lipgloss.NewStyle() + if i == m.highlighted { + prefix = lipgloss.NewStyle().Foreground(m.colors.Selected).Render(tokens.IconPrompt) + " " + nameStyle = nameStyle.Foreground(m.colors.Selected) + } + + suffix := "" + if m.openIndex >= 0 && ComponentRegistry[m.openIndex].Name == e.Name { + suffix = " " + lipgloss.NewStyle().Foreground(m.colors.TextSecondary).Render("◂") + } + + b.WriteString(prefix + nameStyle.Render(sidebarDisplayName(e.Name)) + suffix + "\n") + } + + return b.String() +} + +func (m model) renderPreview() string { + if m.openIndex < 0 { + placeholder := lipgloss.NewStyle().Foreground(m.colors.TextSecondary) + return placeholder.Render("Select a component to preview") + } + + entry := ComponentRegistry[m.openIndex] + var b strings.Builder + + compHeading := textheading.New(sidebarDisplayName(entry.Name), m.colors) + b.WriteString(compHeading.View() + "\n\n") + + for vi, vName := range entry.Variants { + variantHeading := lipgloss.NewStyle(). + Foreground(m.colors.TextSecondary).Bold(true).Render("## " + vName) + b.WriteString(variantHeading + "\n") + + b.WriteString(m.renderInteractiveVariant(entry.Name, vName)) + if vi < len(entry.Variants)-1 { + b.WriteString("\n\n") + } + } + + return b.String() +} + +// renderInteractiveVariant renders a live (possibly stateful) variant. +// For interactive components it uses the model's persistent sub-models; +// for display-only components it creates a fresh instance (same as snapshot). +func (m model) renderInteractiveVariant(compName, variant string) string { + switch compName { + case "Select": + if variant == "Basic" { + return m.selectModel.View() + } + return renderVariant(compName, variant, m.colors, m.width) + case "SelectAutocomplete": + if variant == "Basic" { + return m.saModel.View() + } + return renderVariant(compName, variant, m.colors, m.width) + case "Input": + if variant == "Default" { + return m.inputModel.View() + } + return renderVariant(compName, variant, m.colors, m.width) + case "TabBar": + if variant == "Display only" { + return m.tabModel.View() + } + return renderVariant(compName, variant, m.colors, m.width) + case "ScrollBox": + if variant == "Scrollable list" { + return m.scrollModel.View() + } + return renderVariant(compName, variant, m.colors, m.width) + case "TextSpinner": + if variant == "Default" { + return m.spinner.View() + } + return renderVariant(compName, variant, m.colors, m.width) + default: + return renderVariant(compName, variant, m.colors, m.width) + } +} + +func (m model) buildHintBar() hintbar.Model { + var hints []hintbar.Hint + + if m.focus == focusSidebar { + hints = append(hints, hintbar.Hint{Key: "up-down", Label: "navigate"}) + hints = append(hints, hintbar.Hint{Key: "enter", Label: "open"}) + if m.searching { + hints = append(hints, hintbar.Hint{Key: "esc", Label: "clear"}) + } else { + hints = append(hints, hintbar.Hint{Key: "/", Label: "search"}) + } + hints = append(hints, hintbar.Hint{Key: "q", Label: "quit"}) + } else { + if m.openIndex >= 0 { + name := ComponentRegistry[m.openIndex].Name + switch name { + case "Select", "SelectAutocomplete": + hints = append(hints, hintbar.Hint{Key: "up-down", Label: "navigate"}) + hints = append(hints, hintbar.Hint{Key: "enter", Label: "select"}) + case "TabBar": + hints = append(hints, hintbar.Hint{Key: "left-right", Label: "switch tab"}) + case "Input": + hints = append(hints, hintbar.Hint{Key: "type", Label: "to input"}) + case "ScrollBox": + hints = append(hints, hintbar.Hint{Key: "up-down", Label: "scroll"}) + } + } + hints = append(hints, hintbar.Hint{Key: "esc", Label: "back"}) + hints = append(hints, hintbar.Hint{Key: "q", Label: "quit"}) + } + + return hintbar.New(hints, m.colors) +} + +// --------------------------------------------------------------------------- +// CLI entry point +// --------------------------------------------------------------------------- + +func main() { + listFlag := flag.Bool("list", false, "Print all component/token names, one per line") + compFlag := flag.String("component", "", "Component name to preview") + variantFlag := flag.String("variant", "", "Variant name (requires --component)") + snapshotFlag := flag.Bool("snapshot", false, "Render one frame to stdout and exit (requires --component)") + flag.Parse() + + // --list + if *listFlag { + for _, name := range ComponentNames() { + fmt.Println(name) + } + os.Exit(0) + } + + // --variant without --component + if *variantFlag != "" && *compFlag == "" { + fmt.Fprintln(os.Stderr, "Error: --variant requires --component") + os.Exit(1) + } + + // --snapshot without --component + if *snapshotFlag && *compFlag == "" { + fmt.Fprintln(os.Stderr, "Error: --snapshot requires --component") + os.Exit(1) + } + + // --component + if *compFlag != "" { + comp := findComponentDef(*compFlag) + if comp == nil { + fmt.Fprintf(os.Stderr, "Error: component %q not found\n", *compFlag) + os.Exit(1) + } + + if *variantFlag != "" && !comp.hasVariant(*variantFlag) { + fmt.Fprintf(os.Stderr, "Error: variant %q not found in component %q\n", *variantFlag, *compFlag) + os.Exit(1) + } + + // --snapshot: render one frame and exit + if *snapshotFlag { + colors := tokens.ResolveColors(tokens.ModeDefault) + output := RenderSnapshot(*compFlag, *variantFlag, colors, 80) + fmt.Print(output) + os.Exit(0) + } + + // Interactive direct-component mode + m := initialModel() + m.directComponent = *compFlag + p := tea.NewProgram(m, tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + os.Exit(0) + } + + // Default: full interactive TUI + p := tea.NewProgram(initialModel(), tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} diff --git a/dist/go/cmd/demo/main_test.go b/dist/go/cmd/demo/main_test.go new file mode 100644 index 0000000..dbcdd20 --- /dev/null +++ b/dist/go/cmd/demo/main_test.go @@ -0,0 +1,95 @@ +package main + +import ( + "strings" + "testing" + + "github.com/basiclines/tuikit/pkg/tuikit/tokens" +) + +// TestSnapshotAllComponents is a table-driven smoke test that verifies every +// component and token listed in the registry renders without error via the +// snapshot code path. It mirrors the spec requirement: +// for each entry from --list, run --component --snapshot and assert +// exit 0 + non-empty output. +func TestSnapshotAllComponents(t *testing.T) { + colors := tokens.ResolveColors(tokens.ModeDefault) + + for _, comp := range ComponentRegistry { + t.Run(comp.Name, func(t *testing.T) { + output := RenderSnapshot(comp.Name, "", colors, 80) + if strings.TrimSpace(output) == "" { + t.Errorf("snapshot for %q produced empty output", comp.Name) + } + }) + } +} + +// TestSnapshotEachVariant ensures every individual variant also renders +// without error and produces non-empty output. +func TestSnapshotEachVariant(t *testing.T) { + colors := tokens.ResolveColors(tokens.ModeDefault) + + for _, comp := range ComponentRegistry { + for _, variant := range comp.Variants { + name := comp.Name + "/" + variant + t.Run(name, func(t *testing.T) { + output := RenderSnapshot(comp.Name, variant, colors, 80) + if strings.TrimSpace(output) == "" { + t.Errorf("snapshot for %q variant %q produced empty output", comp.Name, variant) + } + }) + } + } +} + +// TestListOutput verifies that ComponentNames returns a non-empty list +// with tokens appearing before components. +func TestListOutput(t *testing.T) { + names := ComponentNames() + if len(names) == 0 { + t.Fatal("ComponentNames() returned empty list") + } + + // Tokens should come first (lowercase names) + if names[0] != "breakpoints" { + t.Errorf("expected first entry to be 'breakpoints', got %q", names[0]) + } + + // Components should follow (uppercase first letter) + foundComponent := false + for _, n := range names { + if len(n) > 0 && n[0] >= 'A' && n[0] <= 'Z' { + foundComponent = true + break + } + } + if !foundComponent { + t.Error("expected at least one PascalCase component in the registry") + } +} + +// TestUnknownComponentError verifies that an unknown component name +// returns empty output from RenderSnapshot. +func TestUnknownComponentError(t *testing.T) { + colors := tokens.ResolveColors(tokens.ModeDefault) + output := RenderSnapshot("NonExistent", "", colors, 80) + if output != "" { + t.Errorf("expected empty output for unknown component, got %q", output) + } +} + +// TestVariantFilter verifies that --variant filters to a single variant. +func TestVariantFilter(t *testing.T) { + colors := tokens.ResolveColors(tokens.ModeDefault) + + all := RenderSnapshot("Select", "", colors, 80) + one := RenderSnapshot("Select", "Basic", colors, 80) + + if len(one) >= len(all) { + t.Error("single variant output should be shorter than all variants") + } + if strings.TrimSpace(one) == "" { + t.Error("single variant output should not be empty") + } +} diff --git a/dist/go/pkg/tuikit/components/selectcomp/select.go b/dist/go/pkg/tuikit/components/selectcomp/select.go new file mode 100644 index 0000000..9b0cfc5 --- /dev/null +++ b/dist/go/pkg/tuikit/components/selectcomp/select.go @@ -0,0 +1,302 @@ +// Package selectcomp implements a keyboard-navigable selection list. +package selectcomp + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/basiclines/tuikit/pkg/tuikit/components/hintbar" + "github.com/basiclines/tuikit/pkg/tuikit/tokens" +) + +// SelectItem represents a selectable option. +type SelectItem[T comparable] struct { + Label string + Value T + Current bool +} + +// State represents the component's state machine state. +type State int + +const ( + StateFocused State = iota + StateSelected + StateDismissed +) + +// SelectMsg is emitted when the user selects an item. +type SelectMsg[T comparable] struct { + Item SelectItem[T] +} + +// EscapeMsg is emitted when the user presses Escape without escapeItem. +type EscapeMsg struct{} + +// HighlightMsg is emitted when the highlighted item changes. +type HighlightMsg[T comparable] struct { + Item SelectItem[T] +} + +// Model is the Select Bubbletea model. +type Model struct { + Items []SelectItem[string] + Highlighted int + State State + EscapeItem *SelectItem[string] + HideHints bool + ExtraHints []hintbar.Hint + InitialItem *string + HasOnEscape bool + Colors tokens.SemanticColors +} + +// New creates a new Select model. +func New(items []SelectItem[string], colors tokens.SemanticColors) Model { + return Model{ + Items: items, + Highlighted: 0, + State: StateFocused, + Colors: colors, + } +} + +// WithEscapeItem sets the escape item. +func (m Model) WithEscapeItem(item SelectItem[string]) Model { + m.EscapeItem = &item + return m +} + +// WithInitialItem sets the initial highlight position by value. +func (m Model) WithInitialItem(value string) Model { + m.InitialItem = &value + allItems := m.allItems() + for i, item := range allItems { + if item.Value == value { + m.Highlighted = i + return m + } + } + return m +} + +// WithHideHints hides the hint bar. +func (m Model) WithHideHints(hide bool) Model { + m.HideHints = hide + return m +} + +// WithExtraHints adds extra hints to merge into the HintBar. +func (m Model) WithExtraHints(hints []hintbar.Hint) Model { + m.ExtraHints = hints + return m +} + +// WithOnEscape indicates that onEscape callback is provided. +func (m Model) WithOnEscape(has bool) Model { + m.HasOnEscape = has + return m +} + +func (m Model) allItems() []SelectItem[string] { + items := make([]SelectItem[string], len(m.Items)) + copy(items, m.Items) + if m.EscapeItem != nil { + items = append(items, *m.EscapeItem) + } + return items +} + +// Init initializes the model. +func (m Model) Init() tea.Cmd { + return nil +} + +// Update handles keyboard input. +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if m.State != StateFocused { + return m, nil + } + + allItems := m.allItems() + if len(allItems) == 0 { + return m, nil + } + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "up", "k": + if m.Highlighted > 0 { + m.Highlighted-- + return m, func() tea.Msg { + return HighlightMsg[string]{Item: allItems[m.Highlighted]} + } + } + case "down", "j": + if m.Highlighted < len(allItems)-1 { + m.Highlighted++ + return m, func() tea.Msg { + return HighlightMsg[string]{Item: allItems[m.Highlighted]} + } + } + case "enter": + m.State = StateSelected + item := allItems[m.Highlighted] + return m, func() tea.Msg { + return SelectMsg[string]{Item: item} + } + case "esc", "ctrl+g": + if m.EscapeItem != nil { + m.State = StateDismissed + item := *m.EscapeItem + return m, func() tea.Msg { + return SelectMsg[string]{Item: item} + } + } + if m.HasOnEscape { + m.State = StateDismissed + return m, func() tea.Msg { + return EscapeMsg{} + } + } + case "1", "2", "3", "4", "5", "6", "7", "8", "9": + num := int(msg.String()[0] - '0') + if num >= 1 && num <= len(allItems) { + m.State = StateSelected + item := allItems[num-1] + return m, func() tea.Msg { + return SelectMsg[string]{Item: item} + } + } + } + } + return m, nil +} + +// View renders the Select component. +func (m Model) View() string { + var b strings.Builder + + allItems := m.allItems() + colors := m.Colors + + selectedStyle := lipgloss.NewStyle().Foreground(colors.Selected) + unselectedStyle := lipgloss.NewStyle() + if colors.TextOnBackgroundSecondary != nil { + unselectedStyle = unselectedStyle.Foreground(*colors.TextOnBackgroundSecondary) + } + successStyle := lipgloss.NewStyle().Foreground(colors.StatusSuccess) + + for i, item := range allItems { + isHighlighted := i == m.Highlighted + isEscapeItem := m.EscapeItem != nil && i == len(m.Items) + + var line string + num := i + 1 + label := item.Label + + // Build suffix + suffix := "" + if item.Current { + suffix += " " + successStyle.Render(tokens.IconSuccess) + } + if isEscapeItem { + suffix += " " + unselectedStyle.Render("(Esc)") + } + + if isHighlighted { + indicator := selectedStyle.Render(tokens.IconPrompt) + text := selectedStyle.Render(fmt.Sprintf("%d. %s", num, label)) + line = indicator + " " + text + suffix + } else { + text := unselectedStyle.Render(fmt.Sprintf("%d. %s", num, label)) + line = " " + text + suffix + } + + b.WriteString(line) + if i < len(allItems)-1 { + b.WriteString("\n") + } + } + + if !m.HideHints { + b.WriteString("\n") + hb := m.buildHintBar() + b.WriteString(hb.View()) + } + + return b.String() +} + +// PlainView renders without ANSI (for testing). +func (m Model) PlainView() string { + var b strings.Builder + + allItems := m.allItems() + + for i, item := range allItems { + isHighlighted := i == m.Highlighted + isEscapeItem := m.EscapeItem != nil && i == len(m.Items) + + num := i + 1 + label := item.Label + + suffix := "" + if item.Current { + suffix += " " + tokens.IconSuccess + } + if isEscapeItem { + suffix += " (Esc)" + } + + if isHighlighted { + b.WriteString(fmt.Sprintf("%s %d. %s%s", tokens.IconPrompt, num, label, suffix)) + } else { + b.WriteString(fmt.Sprintf(" %d. %s%s", num, label, suffix)) + } + + if i < len(allItems)-1 { + b.WriteString("\n") + } + } + + if !m.HideHints { + b.WriteString("\n") + hb := m.buildHintBar() + b.WriteString(hb.PlainView()) + } + + return b.String() +} + +func (m Model) buildHintBar() hintbar.Model { + var hints []hintbar.Hint + hints = append(hints, hintbar.Hint{Key: "up-down", Label: "to navigate"}) + + // Insert extra hints + for _, h := range m.ExtraHints { + hints = append(hints, h) + } + + hints = append(hints, hintbar.Hint{Key: "enter", Label: "to select"}) + + if m.EscapeItem != nil || m.HasOnEscape { + hints = append(hints, hintbar.Hint{Key: "esc", Label: "to cancel"}) + } + + return hintbar.New(hints, m.Colors) +} + +// SelectedItem returns the currently highlighted item. +func (m Model) SelectedItem() *SelectItem[string] { + allItems := m.allItems() + if m.Highlighted >= 0 && m.Highlighted < len(allItems) { + item := allItems[m.Highlighted] + return &item + } + return nil +} From c4e9123ebfa4fa990751ff2adc853a3024c79622 Mon Sep 17 00:00:00 2001 From: Ismael Canal Date: Fri, 17 Apr 2026 14:45:06 +0200 Subject: [PATCH 09/18] Add modular demo architecture and snapshot verification to specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - previews.md: Add file structure guidance — demo MUST be split into modules (registry, sidebar, preview_panel, app, cli, variants), not a single monolithic file - compile.ts: Verification section now requires running --list and --snapshot for every component before reporting done - Agents must self-test via --snapshot to catch demo wiring bugs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- components/previews.md | 67 ++++++++++++++++++++++++++++++++---------- scripts/compile.ts | 12 +++++--- 2 files changed, 60 insertions(+), 19 deletions(-) diff --git a/components/previews.md b/components/previews.md index 6cb7414..be9e327 100644 --- a/components/previews.md +++ b/components/previews.md @@ -14,6 +14,33 @@ The demo is a **full-screen TUI** that takes over the alternate screen buffer. It uses a two-panel layout: a persistent sidebar on the left for component navigation, and a main panel on the right for the active component preview. +### File structure + +The demo MUST be split into separate modules — not a single monolithic file. +Recommended structure: + +``` +demo entrypoint Main entry, CLI flag parsing, app bootstrap + preview/ + registry Component/token registry (names, variant definitions) + sidebar Sidebar component (list, search, highlight, scroll) + preview_panel Main panel (mounts/unmounts active component preview) + app Root app shell (layout, focus routing, HintBar) + cli CLI flag handlers (--list, --snapshot, --component) + variants/ + tokens Token preview renderers (colors grid, icons grid, etc.) + components Component preview variant factories (read from registry) +``` + +Each preview variant factory creates a **live component instance** with initial +props from the `.preview.md` spec. The registry maps component names to their +variant factories. + +This separation ensures: +- **Testability**: Each module can be tested independently +- **Debuggability**: Bugs are isolated to specific modules, not buried in 1000+ lines +- **Maintainability**: Adding a new component preview means adding to the registry, not editing a giant file + ``` ┌──────────────────────┬──────────────────────────────────────────┐ │ TUIkit Preview │ │ @@ -219,19 +246,29 @@ When combined with `--variant`, only that variant's frame is rendered. ### Examples -```bash -# Bun -bun run demo.tsx --list -bun run demo.tsx --component Select -bun run demo.tsx --component Select --variant "With current item" -bun run demo.tsx --component Select --snapshot -bun run demo.tsx --component HintBar --variant "Default hints" --snapshot - -# Go -go run ./cmd/demo --list -go run ./cmd/demo --component Select --snapshot - -# Rust -cargo run --example demo -- --list -cargo run --example demo -- --component Select --snapshot +Use the target's run command (defined in `targets/{target}.md` under `demo.run_command`): + +``` + --list + --component Select + --component Select --variant "With current item" + --component Select --snapshot + --component HintBar --variant "Default hints" --snapshot ``` + +### Demo smoke tests (REQUIRED) + +The demo MUST include automated tests that verify every component renders +without errors via `--snapshot`. This is the integration test layer that +catches wiring bugs (wrong init, broken update routing, missing imports) +that unit tests miss. + +For each component/token listed by `--list`, the test: +1. Runs ` --component --snapshot` +2. Asserts exit code 0 (no panic, no crash) +3. Asserts stdout is non-empty (something rendered) + +Implement this as a single parameterized or table-driven test in the +target's test framework. The test should programmatically get the list +of components (via `--list` or by reading the component registry), then +loop over each one and run a snapshot assertion. diff --git a/scripts/compile.ts b/scripts/compile.ts index 217ef16..31969a5 100644 --- a/scripts/compile.ts +++ b/scripts/compile.ts @@ -339,13 +339,17 @@ function generatePrompt(target: string, specs: SpecEntry[], allSpecs: SpecEntry[ sections.push("---"); sections.push("## Verification (REQUIRED)"); sections.push(""); - sections.push("After generating ALL files, you MUST:"); + sections.push("After generating ALL files, you MUST verify in this order:"); sections.push(""); - sections.push("1. **Run tests**: Execute the target's test command and ensure ALL tests pass."); + sections.push("1. **Run unit tests**: Execute the target's test command and ensure ALL tests pass."); sections.push(" Fix any failures before proceeding."); sections.push("2. **Build the demo**: Compile/build the demo CLI and verify it starts without errors."); - sections.push(" For interpreted targets (Bun), verify the demo file has no syntax/import errors."); - sections.push("3. **Report**: State the final test count, pass/fail status, and demo build status."); + sections.push("3. **Verify demo --list**: Run the demo with `--list` and confirm all components/tokens appear."); + sections.push("4. **Verify demo --snapshot**: For EVERY component from `--list`, run"); + sections.push(" `--component --snapshot` and confirm it exits 0 with non-empty output."); + sections.push(" If any snapshot fails, fix the demo wiring before continuing."); + sections.push("5. **Run demo smoke tests**: Execute the demo test file and ensure all snapshot tests pass."); + sections.push("6. **Report**: State the final unit test count, demo smoke test count, and pass/fail status."); sections.push(""); return sections.join("\n"); From 0eebdb20d34696406a8c5e838d9c1ce91171fedc Mon Sep 17 00:00:00 2001 From: Ismael Canal Date: Fri, 17 Apr 2026 14:47:02 +0200 Subject: [PATCH 10/18] feat(rust): add demo CLI flags, SelectAutocomplete preview, and smoke tests - Add --list, --component, --variant, --snapshot CLI flags to demo - Add SelectAutocomplete to demo preview browser (19 total entries) - Add VariantRenderer helper for per-variant filtering and headings - Fix Escape key routing: always returns to sidebar from preview - Reset component state on entering preview - Add snapshot mode using TestBackend for non-interactive rendering - Add integration smoke tests (tests/demo_smoke.rs): - list_returns_all_components - snapshot_all_components_exit_0_with_output - unknown_component_exits_with_error - unknown_variant_exits_with_error All 164 tests pass (160 unit + 4 integration). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dist/rust/examples/demo.rs | 1629 +++++++++++++++++++++++++++++++++ dist/rust/tests/demo_smoke.rs | 112 +++ 2 files changed, 1741 insertions(+) create mode 100644 dist/rust/examples/demo.rs create mode 100644 dist/rust/tests/demo_smoke.rs diff --git a/dist/rust/examples/demo.rs b/dist/rust/examples/demo.rs new file mode 100644 index 0000000..a3fb4cf --- /dev/null +++ b/dist/rust/examples/demo.rs @@ -0,0 +1,1629 @@ +//! TUIKit interactive demo — full-screen preview browser for all components and tokens. +//! +//! Run with: cargo run --example demo +//! CLI flags: +//! --list List all components/tokens +//! --component Open directly into a component preview +//! --component --variant Show only a specific variant +//! --component --snapshot Render one frame to stdout and exit +//! --component --variant --snapshot + +use std::io::{self, Write}; +use std::time::{Duration, Instant}; + +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}; +use crossterm::execute; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use ratatui::backend::{CrosstermBackend, TestBackend}; +use ratatui::buffer::Buffer; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Widget}; +use ratatui::Terminal; + +use tuikit::tuikit::components::dialog::Dialog; +use tuikit::tuikit::components::hint_bar::HintBar; +use tuikit::tuikit::components::input::{handle_input_key, Input, InputState}; +use tuikit::tuikit::components::link::Link; +use tuikit::tuikit::components::metric::{EffortLevel, Metric, MetricEntry}; +use tuikit::tuikit::components::qr_code::QrCode; +use tuikit::tuikit::components::scroll_box::{handle_scroll_key, ScrollBox, ScrollState}; +use tuikit::tuikit::components::select::{handle_select_key, Select, SelectItem, SelectState}; +use tuikit::tuikit::components::select_autocomplete::{ + handle_select_auto_key, SelectAutocomplete, SelectAutoState, +}; +use tuikit::tuikit::components::tab_bar::{handle_tab_bar_key, Tab, TabBar, TabBarState}; +use tuikit::tuikit::components::table::{Table, TableCell, TableRow}; +use tuikit::tuikit::components::text_heading::TextHeading; +use tuikit::tuikit::components::text_spinner::{ + SpinnerState, SpinnerType, TextSpinner, TextSpinnerWidget, +}; +use tuikit::tuikit::components::text_title::TextTitle; +use tuikit::tuikit::components::timeline_item::{ + TimelineItem, TimelineSubItem, TimelineVariant, +}; +use tuikit::tuikit::tokens::{breakpoints, icons, resolve_colors, SemanticColors}; + +const SIDEBAR_WIDTH: u16 = 26; + +// ── CLI parsing ──────────────────────────────────────────────────────────── + +enum CliMode { + Interactive, + List, + Direct { + component: String, + variant: Option, + }, + Snapshot { + component: String, + variant: Option, + }, +} + +fn parse_cli() -> CliMode { + let args: Vec = std::env::args().collect(); + let mut list = false; + let mut component: Option = None; + let mut variant: Option = None; + let mut snapshot = false; + + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--list" => list = true, + "--component" => { + i += 1; + component = Some( + args.get(i) + .expect("--component requires a name") + .clone(), + ); + } + "--variant" => { + i += 1; + variant = Some( + args.get(i) + .expect("--variant requires a name") + .clone(), + ); + } + "--snapshot" => snapshot = true, + _ => { + eprintln!("Unknown flag: {}", args[i]); + std::process::exit(1); + } + } + i += 1; + } + + if list { + return CliMode::List; + } + if let Some(name) = component { + if snapshot { + CliMode::Snapshot { + component: name, + variant, + } + } else { + CliMode::Direct { + component: name, + variant, + } + } + } else if snapshot || variant.is_some() { + eprintln!("--snapshot and --variant require --component"); + std::process::exit(1); + } else { + CliMode::Interactive + } +} + +// ── PreviewId ────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy, PartialEq)] +enum PreviewId { + Breakpoints, + Colors, + Icons, + Dialog, + HintBar, + Input, + Link, + Metric, + QrCode, + Screen, + ScrollBox, + Select, + SelectAutocomplete, + TabBar, + Table, + TextHeading, + TextSpinner, + TextTitle, + TimelineItem, +} + +impl PreviewId { + const ALL: &'static [PreviewId] = &[ + PreviewId::Breakpoints, + PreviewId::Colors, + PreviewId::Icons, + PreviewId::Dialog, + PreviewId::HintBar, + PreviewId::Input, + PreviewId::Link, + PreviewId::Metric, + PreviewId::QrCode, + PreviewId::Screen, + PreviewId::ScrollBox, + PreviewId::Select, + PreviewId::SelectAutocomplete, + PreviewId::TabBar, + PreviewId::Table, + PreviewId::TextHeading, + PreviewId::TextSpinner, + PreviewId::TextTitle, + PreviewId::TimelineItem, + ]; + + fn name(&self) -> &'static str { + match self { + Self::Breakpoints => "breakpoints", + Self::Colors => "colors", + Self::Icons => "icons", + Self::Dialog => "Dialog", + Self::HintBar => "HintBar", + Self::Input => "Input", + Self::Link => "Link", + Self::Metric => "Metric", + Self::QrCode => "QrCode", + Self::Screen => "Screen", + Self::ScrollBox => "ScrollBox", + Self::Select => "Select", + Self::SelectAutocomplete => "SelectAutocomplete", + Self::TabBar => "TabBar", + Self::Table => "Table", + Self::TextHeading => "TextHeading", + Self::TextSpinner => "TextSpinner", + Self::TextTitle => "TextTitle", + Self::TimelineItem => "TimelineItem", + } + } + + fn from_name(name: &str) -> Option { + Self::ALL.iter().find(|p| p.name() == name).copied() + } + + fn sidebar_label(&self) -> &'static str { + match self { + Self::Breakpoints => "Breakpoints", + Self::Colors => "Colors", + Self::Icons => "Icons", + Self::Dialog => "Dialog", + Self::HintBar => "HintBar", + Self::Input => "Input", + Self::Link => "Link", + Self::Metric => "Metric", + Self::QrCode => "QrCode", + Self::Screen => "Screen", + Self::ScrollBox => "ScrollBox", + Self::Select => "Select", + Self::SelectAutocomplete => "SelectAutocomplete", + Self::TabBar => "TabBar", + Self::Table => "Table", + Self::TextHeading => "TextHeading", + Self::TextSpinner => "TextSpinner", + Self::TextTitle => "TextTitle", + Self::TimelineItem => "TimelineItem", + } + } + + fn is_token(&self) -> bool { + matches!(self, Self::Breakpoints | Self::Colors | Self::Icons) + } + + fn variant_names(&self) -> &'static [&'static str] { + match self { + Self::Breakpoints => &["Current breakpoint"], + Self::Colors => &["Semantic colors", "Text tokens", "Status tokens", "Brand tokens"], + Self::Icons => &["Status icons", "Navigation icons", "UI icons", "Tree icons"], + Self::Dialog => &["Basic", "With subtitle", "Fixed width", "Border title", "Full variant"], + Self::HintBar => &["Default", "Custom keys", "Conditional", "Custom separator"], + Self::Input => &["Default", "Multiline", "Masked", "Single line"], + Self::Link => &["Default", "With label color", "With brand color", "Bold"], + Self::Metric => &["Default", "Highlighted"], + Self::QrCode => &["Short URL", "Long URL"], + Self::Screen => &["Basic", "With header and footer", "Non-scrollable"], + Self::ScrollBox => &["No scroll (content fits)", "Scrollable list", "No scrollbar", "Focusable", "Hover + virtualized"], + Self::Select => &["Basic", "With current item", "With text input", "Scrolling"], + Self::SelectAutocomplete => &["Basic", "With current item"], + Self::TabBar => &["Display only", "Arrow navigation", "Tab navigation", "No loop"], + Self::Table => &["Basic", "Borderless key-value", "Right-aligned numbers", "Width-constrained"], + Self::TextHeading => &["Default", "Error"], + Self::TextSpinner => &["Default", "Icon only", "Label only", "Placeholder", "Brand", "Info"], + Self::TextTitle => &["Default", "Error"], + Self::TimelineItem => &["Loading", "Success", "Error", "Warning", "Info", "Muted", "With multiple sub-items"], + } + } +} + +// ── Variant renderer helper ──────────────────────────────────────────────── + +struct VR<'a> { + y: u16, + area: Rect, + colors: &'a SemanticColors, + filter: Option<&'a str>, +} + +impl<'a> VR<'a> { + fn new(area: Rect, colors: &'a SemanticColors, filter: Option<&'a str>) -> Self { + Self { y: area.y, area, colors, filter } + } + + fn begin(&mut self, name: &str, buf: &mut Buffer) -> Option { + if let Some(f) = self.filter { + if f != name { + return None; + } + } + if self.y + 2 >= self.area.y + self.area.height { + return None; + } + let heading = TextHeading::new(name, self.colors); + heading.render(Rect::new(self.area.x, self.y, self.area.width, 1), buf); + self.y += 2; + let remaining = (self.area.y + self.area.height).saturating_sub(self.y); + Some(Rect::new(self.area.x, self.y, self.area.width, remaining)) + } + + fn advance(&mut self, rows: u16) { + self.y += rows + 1; + } +} + +// ── Focus & App ──────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy, PartialEq)] +enum Focus { + Sidebar, + Preview, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +enum AppMode { + Sidebar, + Direct(PreviewId), +} + +struct App { + mode: AppMode, + focus: Focus, + sidebar_cursor: usize, + colors: SemanticColors, + variant_filter: Option, + spinner_state: SpinnerState, + input_state: InputState, + select_state: SelectState, + tab_state: TabBarState, + scroll_state: ScrollState, + select_auto_state: SelectAutoState, + last_tick: Instant, +} + +impl App { + fn new() -> Self { + Self { + mode: AppMode::Sidebar, + focus: Focus::Sidebar, + sidebar_cursor: 0, + colors: resolve_colors(), + variant_filter: None, + spinner_state: SpinnerState::new(), + input_state: InputState::new(), + select_state: SelectState::new(3), + tab_state: TabBarState::new(3), + scroll_state: ScrollState::new(50, 10), + select_auto_state: SelectAutoState::new(), + last_tick: Instant::now(), + } + } + + fn current_preview(&self) -> PreviewId { + match self.mode { + AppMode::Direct(id) => id, + AppMode::Sidebar => PreviewId::ALL[self.sidebar_cursor], + } + } +} + +// ── main ─────────────────────────────────────────────────────────────────── + +fn main() -> io::Result<()> { + let cli = parse_cli(); + + match cli { + CliMode::List => { + for id in PreviewId::ALL { + println!("{}", id.name()); + } + Ok(()) + } + CliMode::Snapshot { component, variant } => { + let id = match PreviewId::from_name(&component) { + Some(id) => id, + None => { + eprintln!("Unknown component: {}", component); + std::process::exit(1); + } + }; + if let Some(ref v) = variant { + if !id.variant_names().contains(&v.as_str()) { + eprintln!("Unknown variant '{}' for {}", v, component); + std::process::exit(1); + } + } + render_snapshot(id, variant.as_deref()); + Ok(()) + } + CliMode::Direct { component, variant } => { + let id = match PreviewId::from_name(&component) { + Some(id) => id, + None => { + eprintln!("Unknown component: {}", component); + std::process::exit(1); + } + }; + if let Some(ref v) = variant { + if !id.variant_names().contains(&v.as_str()) { + eprintln!("Unknown variant '{}' for {}", v, component); + std::process::exit(1); + } + } + run_interactive(AppMode::Direct(id), variant) + } + CliMode::Interactive => run_interactive(AppMode::Sidebar, None), + } +} + +// ── Snapshot mode ────────────────────────────────────────────────────────── + +fn render_snapshot(id: PreviewId, variant: Option<&str>) { + let backend = TestBackend::new(80, 60); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = App::new(); + app.mode = AppMode::Direct(id); + app.variant_filter = variant.map(String::from); + + terminal + .draw(|f| { + let area = f.area(); + draw_component_preview(f, &mut app, id, area); + }) + .unwrap(); + + let buf = terminal.backend().buffer(); + let output = buffer_to_string(buf); + print!("{}", output); + io::stdout().flush().ok(); +} + +fn buffer_to_string(buf: &Buffer) -> String { + let mut result = String::new(); + let cells = buf.content(); + for y in 0..buf.area.height { + let mut line = String::new(); + for x in 0..buf.area.width { + let idx = (y * buf.area.width + x) as usize; + line.push_str(cells[idx].symbol()); + } + result.push_str(line.trim_end()); + result.push('\n'); + } + while result.ends_with("\n\n") { + result.pop(); + } + result +} + +// ── Interactive mode ─────────────────────────────────────────────────────── + +fn run_interactive(mode: AppMode, variant: Option) -> io::Result<()> { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let mut app = App::new(); + app.mode = mode; + app.variant_filter = variant; + if matches!(mode, AppMode::Direct(_)) { + app.focus = Focus::Preview; + } + + let tick_rate = Duration::from_millis(100); + + loop { + terminal.draw(|f| draw(f, &mut app))?; + + let timeout = tick_rate + .checked_sub(app.last_tick.elapsed()) + .unwrap_or_else(|| Duration::from_secs(0)); + + if event::poll(timeout)? { + if let Event::Key(key) = event::read()? { + if should_quit(&key) { + break; + } + if handle_key(&mut app, key) { + break; + } + } + } + + if app.last_tick.elapsed() >= tick_rate { + app.spinner_state.tick(icons::SPINNER_BOUNCE.len()); + app.last_tick = Instant::now(); + } + } + + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + Ok(()) +} + +fn should_quit(key: &KeyEvent) -> bool { + matches!( + key, + KeyEvent { + code: KeyCode::Char('q'), + modifiers: KeyModifiers::NONE, + .. + } | KeyEvent { + code: KeyCode::Char('c'), + modifiers: KeyModifiers::CONTROL, + .. + } + ) +} + +/// Returns true if the app should exit. +fn handle_key(app: &mut App, key: KeyEvent) -> bool { + match app.mode { + AppMode::Direct(_) => { + if key.code == KeyCode::Esc { + return true; + } + handle_component_key(app, key); + false + } + AppMode::Sidebar => match app.focus { + Focus::Sidebar => { + handle_sidebar_key(app, key); + false + } + Focus::Preview => { + if key.code == KeyCode::Esc { + app.focus = Focus::Sidebar; + } else { + handle_component_key(app, key); + } + false + } + }, + } +} + +fn handle_sidebar_key(app: &mut App, key: KeyEvent) { + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + if app.sidebar_cursor > 0 { + app.sidebar_cursor -= 1; + } + } + KeyCode::Down | KeyCode::Char('j') => { + if app.sidebar_cursor < PreviewId::ALL.len() - 1 { + app.sidebar_cursor += 1; + } + } + KeyCode::Enter => { + app.focus = Focus::Preview; + reset_component_state(app); + } + _ => {} + } +} + +fn reset_component_state(app: &mut App) { + app.input_state = InputState::new(); + app.select_state = SelectState::new(3); + app.tab_state = TabBarState::new(3); + app.scroll_state = ScrollState::new(50, 10); + app.select_auto_state = SelectAutoState::new(); +} + +fn handle_component_key(app: &mut App, key: KeyEvent) { + match app.current_preview() { + PreviewId::Input => { + handle_input_key(key, &mut app.input_state); + } + PreviewId::Select => { + let items = sample_select_items(); + handle_select_key(key, &mut app.select_state, &items, None); + } + PreviewId::SelectAutocomplete => { + let items = sample_select_items(); + let widget = SelectAutocomplete::new(&items, &app.colors); + let filtered = widget.filtered_items(&app.select_auto_state.query); + handle_select_auto_key( + key, + &mut app.select_auto_state, + &items, + &filtered, + None, + ); + } + PreviewId::TabBar => { + handle_tab_bar_key(key, &mut app.tab_state, true); + } + PreviewId::ScrollBox => { + handle_scroll_key(key, &mut app.scroll_state); + } + _ => {} + } +} + +fn sample_select_items() -> Vec> { + vec![ + SelectItem::new("Option A", "a".into()), + SelectItem::new("Option B", "b".into()), + SelectItem::new("Option C", "c".into()), + ] +} + +// ── Drawing ──────────────────────────────────────────────────────────────── + +fn draw(f: &mut ratatui::Frame, app: &mut App) { + match app.mode { + AppMode::Direct(_) => { + let id = app.current_preview(); + draw_component_preview(f, app, id, f.area()); + } + AppMode::Sidebar => { + let area = f.area(); + let main_height = area.height.saturating_sub(1); + let main_area = Rect::new(area.x, area.y, area.width, main_height); + let hint_area = Rect::new(area.x, area.y + main_height, area.width, 1); + + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(SIDEBAR_WIDTH), + Constraint::Min(0), + ]) + .split(main_area); + + draw_sidebar(f, app, chunks[0]); + let id = app.current_preview(); + draw_component_preview(f, app, id, chunks[1]); + + let hints = match app.focus { + Focus::Sidebar => vec![ + ("↑↓", Some("navigate")), + ("enter", Some("open")), + ("/", Some("search")), + ("q", Some("quit")), + ], + Focus::Preview => vec![ + ("esc", Some("back")), + ("q", Some("quit")), + ], + }; + let bar = HintBar::new(hints, &app.colors); + f.render_widget(bar, hint_area); + } + } +} + +fn draw_sidebar(f: &mut ratatui::Frame, app: &App, area: Rect) { + let block = Block::default() + .borders(Borders::RIGHT) + .border_style(Style::default().fg(app.colors.border_neutral)) + .title(Span::styled( + " TUIKit ", + Style::default() + .fg(app.colors.brand) + .add_modifier(Modifier::BOLD), + )); + let inner = block.inner(area); + f.render_widget(block, area); + + let mut y = inner.y; + let mut drew_separator = false; + + for (i, preview) in PreviewId::ALL.iter().enumerate() { + if y >= inner.y + inner.height { + break; + } + // Separator between tokens and components + if !drew_separator && !preview.is_token() { + let sep = Line::from(Span::styled( + "─".repeat(inner.width as usize), + Style::default().fg(app.colors.text_tertiary), + )); + f.render_widget(sep, Rect::new(inner.x, y, inner.width, 1)); + y += 1; + drew_separator = true; + if y >= inner.y + inner.height { + break; + } + } + + let is_active = i == app.sidebar_cursor; + let is_open = app.focus == Focus::Preview && i == app.sidebar_cursor; + let style = if is_active { + Style::default() + .fg(app.colors.brand) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(app.colors.text_secondary) + }; + let prefix = if is_active { "▸ " } else { " " }; + let suffix = if is_open { " ◂" } else { "" }; + let icon = if preview.is_token() { "☰ " } else { "" }; + let line = Line::from(vec![ + Span::styled(prefix, style), + Span::styled(icon, style), + Span::styled(preview.sidebar_label(), style), + Span::styled(suffix, Style::default().fg(app.colors.text_tertiary)), + ]); + f.render_widget(line, Rect::new(inner.x, y, inner.width, 1)); + y += 1; + } +} + +// ── Component preview dispatcher ─────────────────────────────────────────── + +fn draw_component_preview(f: &mut ratatui::Frame, app: &mut App, id: PreviewId, area: Rect) { + let block = Block::default() + .borders(Borders::NONE) + .title(Span::styled( + format!(" {} ", id.name()), + Style::default() + .fg(app.colors.brand) + .add_modifier(Modifier::BOLD), + )); + let inner = block.inner(area); + f.render_widget(block, area); + + let filter = app.variant_filter.as_deref(); + + match id { + PreviewId::Breakpoints => draw_breakpoints_preview(f, &app.colors, inner, filter), + PreviewId::Colors => draw_colors_preview(f, &app.colors, inner, filter), + PreviewId::Icons => draw_icons_preview(f, &app.colors, inner, filter), + PreviewId::Dialog => draw_dialog_preview(f, &app.colors, inner, filter), + PreviewId::HintBar => draw_hintbar_preview(f, &app.colors, inner, filter), + PreviewId::Input => { + draw_input_preview(f, &app.colors, &mut app.input_state, inner, filter) + } + PreviewId::Link => draw_link_preview(f, &app.colors, inner, filter), + PreviewId::Metric => draw_metric_preview(f, &app.colors, inner, filter), + PreviewId::QrCode => draw_qrcode_preview(f, &app.colors, inner, filter), + PreviewId::Screen => draw_screen_preview(f, &app.colors, inner, filter), + PreviewId::ScrollBox => { + draw_scrollbox_preview(f, &app.colors, &mut app.scroll_state, inner, filter) + } + PreviewId::Select => { + draw_select_preview(f, &app.colors, &mut app.select_state, inner, filter) + } + PreviewId::SelectAutocomplete => { + draw_select_auto_preview(f, &app.colors, &mut app.select_auto_state, inner, filter) + } + PreviewId::TabBar => { + draw_tabbar_preview(f, &app.colors, &mut app.tab_state, inner, filter) + } + PreviewId::Table => draw_table_preview(f, &app.colors, inner, filter), + PreviewId::TextHeading => draw_text_heading_preview(f, &app.colors, inner, filter), + PreviewId::TextSpinner => { + draw_spinner_preview(f, &app.colors, &app.spinner_state, inner, filter) + } + PreviewId::TextTitle => draw_text_title_preview(f, &app.colors, inner, filter), + PreviewId::TimelineItem => draw_timeline_preview(f, &app.colors, inner, filter), + } +} + +// ── Preview renderers ────────────────────────────────────────────────────── + +fn draw_breakpoints_preview( + f: &mut ratatui::Frame, + colors: &SemanticColors, + area: Rect, + filter: Option<&str>, +) { + let mut vr = VR::new(area, colors, filter); + if let Some(a) = vr.begin("Current breakpoint", f.buffer_mut()) { + let bp = breakpoints::get_breakpoint(a.width); + let lines = vec![ + Line::from(format!("Current width: {} columns", a.width)), + Line::from(format!("Breakpoint: {:?}", bp)), + Line::from(""), + Line::from("Compact: < 65 cols"), + Line::from("Narrow: 65 \u{2013} 99 cols"), + Line::from("Wide: \u{2265} 100 cols"), + ]; + for (i, line) in lines.iter().enumerate() { + if i as u16 >= a.height { + break; + } + f.render_widget( + line.clone(), + Rect::new(a.x, a.y + i as u16, a.width, 1), + ); + } + vr.advance(6); + } +} + +fn draw_colors_preview( + f: &mut ratatui::Frame, + colors: &SemanticColors, + area: Rect, + filter: Option<&str>, +) { + let mut vr = VR::new(area, colors, filter); + + if let Some(a) = vr.begin("Semantic colors", f.buffer_mut()) { + let swatches = vec![ + ("brand", colors.brand), + ("selected", colors.selected), + ("border_neutral", colors.border_neutral), + ("selection_background", colors.selection_background), + ]; + let h = render_color_swatches(f, &swatches, a); + vr.advance(h); + } + if let Some(a) = vr.begin("Text tokens", f.buffer_mut()) { + let swatches = vec![ + ("text_primary", colors.text_primary.unwrap_or(ratatui::style::Color::White)), + ("text_secondary", colors.text_secondary), + ("text_tertiary", colors.text_tertiary), + ]; + let h = render_color_swatches(f, &swatches, a); + vr.advance(h); + } + if let Some(a) = vr.begin("Status tokens", f.buffer_mut()) { + let swatches = vec![ + ("status_success", colors.status_success), + ("status_error", colors.status_error), + ("status_warning", colors.status_warning), + ("status_info", colors.status_info), + ]; + let h = render_color_swatches(f, &swatches, a); + vr.advance(h); + } + if let Some(a) = vr.begin("Brand tokens", f.buffer_mut()) { + let swatches = vec![("brand", colors.brand)]; + let h = render_color_swatches(f, &swatches, a); + vr.advance(h); + } +} + +fn render_color_swatches( + f: &mut ratatui::Frame, + swatches: &[(&str, ratatui::style::Color)], + area: Rect, +) -> u16 { + for (i, (name, color)) in swatches.iter().enumerate() { + if i as u16 >= area.height { + break; + } + let line = Line::from(vec![ + Span::styled("\u{2588}\u{2588} ", Style::default().fg(*color)), + Span::raw(*name), + ]); + f.render_widget( + line, + Rect::new(area.x, area.y + i as u16, area.width, 1), + ); + } + swatches.len() as u16 +} + +fn draw_icons_preview( + f: &mut ratatui::Frame, + colors: &SemanticColors, + area: Rect, + filter: Option<&str>, +) { + let mut vr = VR::new(area, colors, filter); + + if let Some(a) = vr.begin("Status icons", f.buffer_mut()) { + let items = vec![ + ("CHECK", icons::CHECK), + ("CROSS", icons::CROSS), + ("WARNING", icons::WARNING), + ]; + let h = render_icon_list(f, &items, a); + vr.advance(h); + } + if let Some(a) = vr.begin("Navigation icons", f.buffer_mut()) { + let items = vec![ + ("CHEVRON_RIGHT", icons::CHEVRON_RIGHT), + ("ARROW_RIGHT", icons::ARROW_RIGHT), + ]; + let h = render_icon_list(f, &items, a); + vr.advance(h); + } + if let Some(a) = vr.begin("UI icons", f.buffer_mut()) { + let items = vec![ + ("CIRCLE_FILLED", icons::CIRCLE_FILLED), + ("CIRCLE_HALF", icons::CIRCLE_HALF), + ("CIRCLE_EMPTY", icons::CIRCLE_EMPTY), + ("DISABLED", icons::DISABLED), + ]; + let h = render_icon_list(f, &items, a); + vr.advance(h); + } + if let Some(a) = vr.begin("Tree icons", f.buffer_mut()) { + let items = vec![ + ("SCROLLBAR", icons::SCROLLBAR), + ("DOT_SEPARATOR", icons::DOT_SEPARATOR), + ]; + let h = render_icon_list(f, &items, a); + vr.advance(h); + } +} + +fn render_icon_list( + f: &mut ratatui::Frame, + items: &[(&str, &str)], + area: Rect, +) -> u16 { + for (i, (name, glyph)) in items.iter().enumerate() { + if i as u16 >= area.height { + break; + } + let line = Line::from(format!("{} \u{2014} {}", glyph, name)); + f.render_widget( + line, + Rect::new(area.x, area.y + i as u16, area.width, 1), + ); + } + items.len() as u16 +} + +fn draw_dialog_preview( + f: &mut ratatui::Frame, + colors: &SemanticColors, + area: Rect, + filter: Option<&str>, +) { + let mut vr = VR::new(area, colors, filter); + + if let Some(a) = vr.begin("Basic", f.buffer_mut()) { + let dialog = Dialog::new(colors) + .with_title("Confirm Action") + .with_content(vec![Line::raw("Are you sure?")]); + let h = 5.min(a.height); + dialog.render(Rect::new(a.x, a.y, 40.min(a.width), h), f.buffer_mut()); + vr.advance(h); + } + if let Some(a) = vr.begin("With subtitle", f.buffer_mut()) { + let dialog = Dialog::new(colors) + .with_title("Delete File") + .with_subtitle("This cannot be undone") + .with_content(vec![Line::raw("Proceed?")]); + let h = 6.min(a.height); + dialog.render(Rect::new(a.x, a.y, 40.min(a.width), h), f.buffer_mut()); + vr.advance(h); + } + if let Some(a) = vr.begin("Fixed width", f.buffer_mut()) { + let dialog = Dialog::new(colors) + .with_title("Fixed Width") + .with_width(50) + .with_content(vec![Line::raw("This dialog has a fixed width of 50.")]); + let h = 5.min(a.height); + dialog.render(Rect::new(a.x, a.y, 50.min(a.width), h), f.buffer_mut()); + vr.advance(h); + } + if let Some(a) = vr.begin("Border title", f.buffer_mut()) { + let dialog = Dialog::new(colors) + .with_title("Border Title Dialog") + .with_content(vec![Line::raw("Content here.")]); + let h = 5.min(a.height); + dialog.render(Rect::new(a.x, a.y, 40.min(a.width), h), f.buffer_mut()); + vr.advance(h); + } + if let Some(a) = vr.begin("Full variant", f.buffer_mut()) { + let dialog = Dialog::new(colors) + .with_title("Full Dialog") + .with_subtitle("With all options") + .with_width(50) + .with_content(vec![ + Line::raw("This dialog has all options set."), + Line::raw("Title, subtitle, fixed width, and content."), + ]); + let h = 7.min(a.height); + dialog.render(Rect::new(a.x, a.y, 50.min(a.width), h), f.buffer_mut()); + vr.advance(h); + } +} + +fn draw_hintbar_preview( + f: &mut ratatui::Frame, + colors: &SemanticColors, + area: Rect, + filter: Option<&str>, +) { + let mut vr = VR::new(area, colors, filter); + + if let Some(a) = vr.begin("Default", f.buffer_mut()) { + let bar = HintBar::new( + vec![ + ("esc", Some("cancel")), + ("enter", Some("confirm")), + ("\u{2191}\u{2193}", Some("navigate")), + ], + colors, + ); + f.render_widget(bar, Rect::new(a.x, a.y, a.width, 1)); + vr.advance(1); + } + if let Some(a) = vr.begin("Custom keys", f.buffer_mut()) { + let bar = HintBar::new( + vec![("ctrl+c", Some("exit")), ("tab", Some("switch"))], + colors, + ); + f.render_widget(bar, Rect::new(a.x, a.y, a.width, 1)); + vr.advance(1); + } + if let Some(a) = vr.begin("Conditional", f.buffer_mut()) { + let bar = HintBar::new( + vec![ + ("esc", Some("cancel")), + ("enter", None), + ("\u{2191}\u{2193}", Some("navigate")), + ], + colors, + ); + f.render_widget(bar, Rect::new(a.x, a.y, a.width, 1)); + vr.advance(1); + } + if let Some(a) = vr.begin("Custom separator", f.buffer_mut()) { + let bar = HintBar::new( + vec![("a", Some("first")), ("b", Some("second"))], + colors, + ); + f.render_widget(bar, Rect::new(a.x, a.y, a.width, 1)); + vr.advance(1); + } +} + +fn draw_input_preview( + f: &mut ratatui::Frame, + colors: &SemanticColors, + state: &mut InputState, + area: Rect, + filter: Option<&str>, +) { + let mut vr = VR::new(area, colors, filter); + + if let Some(a) = vr.begin("Default", f.buffer_mut()) { + let input = Input::new(colors) + .with_prefix("> ") + .with_placeholder("Enter text..."); + f.render_stateful_widget(input, Rect::new(a.x, a.y, a.width, 1), state); + vr.advance(1); + } + if let Some(a) = vr.begin("Multiline", f.buffer_mut()) { + let label = Line::from("Multiline input:"); + f.render_widget(label, Rect::new(a.x, a.y, a.width, 1)); + let mut multi_state = InputState::new(); + let input = Input::new(colors).with_prefix(">> "); + f.render_stateful_widget( + input, + Rect::new(a.x, a.y + 1, a.width, 1), + &mut multi_state, + ); + vr.advance(2); + } + if let Some(a) = vr.begin("Masked", f.buffer_mut()) { + let label = Line::from("Password input:"); + f.render_widget(label, Rect::new(a.x, a.y, a.width, 1)); + let mut masked_state = InputState::new().with_masked(true); + let input = Input::new(colors).with_prefix("\u{1f512} "); + f.render_stateful_widget( + input, + Rect::new(a.x, a.y + 1, a.width, 1), + &mut masked_state, + ); + vr.advance(2); + } + if let Some(a) = vr.begin("Single line", f.buffer_mut()) { + let mut sl_state = InputState::new(); + let input = Input::new(colors) + .with_prefix("$ ") + .with_placeholder("command..."); + f.render_stateful_widget( + input, + Rect::new(a.x, a.y, a.width, 1), + &mut sl_state, + ); + vr.advance(1); + } +} + +fn draw_link_preview( + f: &mut ratatui::Frame, + colors: &SemanticColors, + area: Rect, + filter: Option<&str>, +) { + let mut vr = VR::new(area, colors, filter); + + if let Some(a) = vr.begin("Default", f.buffer_mut()) { + let link = Link::new("https://github.com").with_text("GitHub"); + link.render(Rect::new(a.x, a.y, a.width, 1), f.buffer_mut()); + vr.advance(1); + } + if let Some(a) = vr.begin("With label color", f.buffer_mut()) { + let link = Link::new("https://example.com") + .with_text("Example") + .with_color(colors.status_info); + link.render(Rect::new(a.x, a.y, a.width, 1), f.buffer_mut()); + vr.advance(1); + } + if let Some(a) = vr.begin("With brand color", f.buffer_mut()) { + let link = Link::new("https://github.com") + .with_text("GitHub Brand") + .with_color(colors.brand); + link.render(Rect::new(a.x, a.y, a.width, 1), f.buffer_mut()); + vr.advance(1); + } + if let Some(a) = vr.begin("Bold", f.buffer_mut()) { + let link = Link::new("https://example.org") + .with_text("Bold Link") + .with_color(colors.text_primary.unwrap_or(ratatui::style::Color::White)); + link.render(Rect::new(a.x, a.y, a.width, 1), f.buffer_mut()); + vr.advance(1); + } +} + +fn draw_metric_preview( + f: &mut ratatui::Frame, + colors: &SemanticColors, + area: Rect, + filter: Option<&str>, +) { + let mut vr = VR::new(area, colors, filter); + + if let Some(a) = vr.begin("Default", f.buffer_mut()) { + let metric = Metric::new(colors) + .with_entries(vec![ + MetricEntry::new("Files Changed", "42"), + MetricEntry::new("Lines Added", "+1,234"), + MetricEntry::new("Lines Removed", "-567"), + ]) + .with_efforts(vec![ + EffortLevel::new("Complexity", 7, 10), + EffortLevel::new("Risk", 3, 10), + ]); + let h = 8.min(a.height); + metric.render(Rect::new(a.x, a.y, a.width, h), f.buffer_mut()); + vr.advance(h); + } + if let Some(a) = vr.begin("Highlighted", f.buffer_mut()) { + let metric = Metric::new(colors) + .with_entries(vec![MetricEntry::new("Score", "98/100")]) + .with_efforts(vec![EffortLevel::new("Difficulty", 9, 10)]); + let h = 5.min(a.height); + metric.render(Rect::new(a.x, a.y, a.width, h), f.buffer_mut()); + vr.advance(h); + } +} + +fn draw_qrcode_preview( + f: &mut ratatui::Frame, + colors: &SemanticColors, + area: Rect, + filter: Option<&str>, +) { + let mut vr = VR::new(area, colors, filter); + + if let Some(a) = vr.begin("Short URL", f.buffer_mut()) { + let qr = QrCode::new("https://github.com"); + let h = 15.min(a.height); + qr.render(Rect::new(a.x, a.y, a.width, h), f.buffer_mut()); + vr.advance(h); + } + if let Some(a) = vr.begin("Long URL", f.buffer_mut()) { + let qr = QrCode::new("https://github.com/some/very/long/path/to/resource"); + let h = 20.min(a.height); + qr.render(Rect::new(a.x, a.y, a.width, h), f.buffer_mut()); + vr.advance(h); + } +} + +fn draw_screen_preview( + f: &mut ratatui::Frame, + colors: &SemanticColors, + area: Rect, + filter: Option<&str>, +) { + let mut vr = VR::new(area, colors, filter); + + if let Some(a) = vr.begin("Basic", f.buffer_mut()) { + let lines = vec![ + Line::raw("Screen component provides header + body + footer layout."), + Line::raw("This is the body content area."), + ]; + for (i, line) in lines.iter().enumerate() { + if i as u16 >= a.height { + break; + } + f.render_widget( + line.clone(), + Rect::new(a.x, a.y + i as u16, a.width, 1), + ); + } + vr.advance(lines.len() as u16); + } + if let Some(a) = vr.begin("With header and footer", f.buffer_mut()) { + let lines = vec![ + Line::styled( + "\u{2500}\u{2500} Header \u{2500}\u{2500}", + Style::default().fg(colors.brand), + ), + Line::raw("Body content goes here."), + Line::styled( + "\u{2500}\u{2500} Footer \u{2500}\u{2500}", + Style::default().fg(colors.text_secondary), + ), + ]; + for (i, line) in lines.iter().enumerate() { + if i as u16 >= a.height { + break; + } + f.render_widget( + line.clone(), + Rect::new(a.x, a.y + i as u16, a.width, 1), + ); + } + vr.advance(lines.len() as u16); + } + if let Some(a) = vr.begin("Non-scrollable", f.buffer_mut()) { + let line = Line::raw("Static content \u{2014} no scrolling needed."); + f.render_widget(line, Rect::new(a.x, a.y, a.width, 1)); + vr.advance(1); + } +} + +fn draw_scrollbox_preview( + f: &mut ratatui::Frame, + colors: &SemanticColors, + state: &mut ScrollState, + area: Rect, + filter: Option<&str>, +) { + let mut vr = VR::new(area, colors, filter); + + if let Some(a) = vr.begin("No scroll (content fits)", f.buffer_mut()) { + let lines: Vec = (0..3) + .map(|i| Line::raw(format!("Line {}", i))) + .collect(); + let scroll = ScrollBox::new(&lines, colors); + let h = 4.min(a.height); + let mut small_state = ScrollState::new(3, h as usize); + f.render_stateful_widget( + scroll, + Rect::new(a.x, a.y, a.width, h), + &mut small_state, + ); + vr.advance(h); + } + if let Some(a) = vr.begin("Scrollable list", f.buffer_mut()) { + let lines: Vec = (0..50) + .map(|i| Line::raw(format!("Scrollable line {}", i))) + .collect(); + let scroll = ScrollBox::new(&lines, colors); + let h = 8.min(a.height); + f.render_stateful_widget(scroll, Rect::new(a.x, a.y, a.width, h), state); + vr.advance(h); + } + if let Some(a) = vr.begin("No scrollbar", f.buffer_mut()) { + let lines: Vec = (0..20) + .map(|i| Line::raw(format!("No-bar line {}", i))) + .collect(); + let scroll = ScrollBox::new(&lines, colors); + let h = 5.min(a.height); + let mut ns_state = ScrollState::new(20, h as usize); + f.render_stateful_widget( + scroll, + Rect::new(a.x, a.y, a.width, h), + &mut ns_state, + ); + vr.advance(h); + } + if let Some(a) = vr.begin("Focusable", f.buffer_mut()) { + let line = Line::raw("Focusable scrollbox \u{2014} use \u{2191}\u{2193} to scroll."); + f.render_widget(line, Rect::new(a.x, a.y, a.width, 1)); + vr.advance(1); + } + if let Some(a) = vr.begin("Hover + virtualized", f.buffer_mut()) { + let line = Line::raw("Virtualized variant for large lists."); + f.render_widget(line, Rect::new(a.x, a.y, a.width, 1)); + vr.advance(1); + } +} + +fn draw_select_preview( + f: &mut ratatui::Frame, + colors: &SemanticColors, + state: &mut SelectState, + area: Rect, + filter: Option<&str>, +) { + let mut vr = VR::new(area, colors, filter); + + if let Some(a) = vr.begin("Basic", f.buffer_mut()) { + let items = sample_select_items(); + let select = Select::new(&items, colors); + let h = 5.min(a.height); + f.render_stateful_widget(select, Rect::new(a.x, a.y, a.width, h), state); + vr.advance(h); + } + if let Some(a) = vr.begin("With current item", f.buffer_mut()) { + let items = vec![ + SelectItem::new("Option A", "a".into()), + SelectItem::::new("Option B", "b".into()).with_current(true), + SelectItem::new("Option C", "c".into()), + ]; + let select = Select::new(&items, colors); + let h = 5.min(a.height); + let mut cur_state = SelectState::new(items.len()); + f.render_stateful_widget( + select, + Rect::new(a.x, a.y, a.width, h), + &mut cur_state, + ); + vr.advance(h); + } + if let Some(a) = vr.begin("With text input", f.buffer_mut()) { + let label = Line::from("Select with text input variant:"); + f.render_widget(label, Rect::new(a.x, a.y, a.width, 1)); + vr.advance(1); + } + if let Some(a) = vr.begin("Scrolling", f.buffer_mut()) { + let items: Vec> = (1..=10) + .map(|i| SelectItem::new(&format!("Item {}", i), format!("{}", i))) + .collect(); + let select = Select::new(&items, colors); + let h = 6.min(a.height); + let mut scroll_state = SelectState::new(items.len()); + f.render_stateful_widget( + select, + Rect::new(a.x, a.y, a.width, h), + &mut scroll_state, + ); + vr.advance(h); + } +} + +fn draw_select_auto_preview( + f: &mut ratatui::Frame, + colors: &SemanticColors, + state: &mut SelectAutoState, + area: Rect, + filter: Option<&str>, +) { + let mut vr = VR::new(area, colors, filter); + + if let Some(a) = vr.begin("Basic", f.buffer_mut()) { + let items = sample_select_items(); + let widget = SelectAutocomplete::new(&items, colors).with_placeholder("Search..."); + let h = 8.min(a.height); + f.render_stateful_widget(widget, Rect::new(a.x, a.y, a.width, h), state); + vr.advance(h); + } + if let Some(a) = vr.begin("With current item", f.buffer_mut()) { + let items = vec![ + SelectItem::new("Alpha", "a".into()), + SelectItem::::new("Beta", "b".into()).with_current(true), + SelectItem::new("Gamma", "c".into()), + ]; + let widget = + SelectAutocomplete::new(&items, colors).with_placeholder("Filter items..."); + let h = 8.min(a.height); + let mut cur_state = SelectAutoState::new(); + f.render_stateful_widget( + widget, + Rect::new(a.x, a.y, a.width, h), + &mut cur_state, + ); + vr.advance(h); + } +} + +fn draw_tabbar_preview( + f: &mut ratatui::Frame, + colors: &SemanticColors, + state: &mut TabBarState, + area: Rect, + filter: Option<&str>, +) { + let mut vr = VR::new(area, colors, filter); + let tabs = vec![ + Tab::new("Files", "files"), + Tab::new("Search", "search"), + Tab::new("Settings", "settings"), + ]; + + if let Some(a) = vr.begin("Display only", f.buffer_mut()) { + let tab_bar = TabBar::new(&tabs, colors); + let mut display_state = TabBarState::new(tabs.len()); + f.render_stateful_widget( + tab_bar, + Rect::new(a.x, a.y, a.width, 1), + &mut display_state, + ); + vr.advance(1); + } + if let Some(a) = vr.begin("Arrow navigation", f.buffer_mut()) { + let tab_bar = TabBar::new(&tabs, colors); + f.render_stateful_widget(tab_bar, Rect::new(a.x, a.y, a.width, 1), state); + let content_text = match state.active { + 0 => "Files panel content", + 1 => "Search panel content", + 2 => "Settings panel content", + _ => "", + }; + if a.height > 2 { + f.render_widget( + Line::raw(content_text), + Rect::new(a.x, a.y + 2, a.width, 1), + ); + } + vr.advance(3); + } + if let Some(a) = vr.begin("Tab navigation", f.buffer_mut()) { + let tab_bar = TabBar::new(&tabs, colors); + let mut tab_state = TabBarState::new(tabs.len()); + tab_state.active = 1; + f.render_stateful_widget( + tab_bar, + Rect::new(a.x, a.y, a.width, 1), + &mut tab_state, + ); + vr.advance(1); + } + if let Some(a) = vr.begin("No loop", f.buffer_mut()) { + let tab_bar = TabBar::new(&tabs, colors); + let mut nl_state = TabBarState::new(tabs.len()); + nl_state.active = 2; + f.render_stateful_widget( + tab_bar, + Rect::new(a.x, a.y, a.width, 1), + &mut nl_state, + ); + vr.advance(1); + } +} + +fn draw_table_preview( + f: &mut ratatui::Frame, + colors: &SemanticColors, + area: Rect, + filter: Option<&str>, +) { + let mut vr = VR::new(area, colors, filter); + + if let Some(a) = vr.begin("Basic", f.buffer_mut()) { + let table = Table::new(vec!["Name", "Type", "Size"], colors).with_rows(vec![ + TableRow::new(vec![ + TableCell::new("main.rs"), + TableCell::new("Rust"), + TableCell::new("4.2 KB"), + ]), + TableRow::new(vec![ + TableCell::new("Cargo.toml"), + TableCell::new("TOML"), + TableCell::new("1.1 KB"), + ]), + TableRow::new(vec![ + TableCell::new("README.md"), + TableCell::new("Markdown"), + TableCell::new("2.8 KB"), + ]), + ]); + let h = 6.min(a.height); + table.render(Rect::new(a.x, a.y, a.width, h), f.buffer_mut()); + vr.advance(h); + } + if let Some(a) = vr.begin("Borderless key-value", f.buffer_mut()) { + let table = Table::new(vec!["Key", "Value"], colors).with_rows(vec![ + TableRow::new(vec![ + TableCell::new("Name"), + TableCell::new("TUIKit"), + ]), + TableRow::new(vec![ + TableCell::new("Version"), + TableCell::new("0.1.0"), + ]), + ]); + let h = 4.min(a.height); + table.render(Rect::new(a.x, a.y, a.width, h), f.buffer_mut()); + vr.advance(h); + } + if let Some(a) = vr.begin("Right-aligned numbers", f.buffer_mut()) { + let table = Table::new(vec!["Item", "Count"], colors).with_rows(vec![ + TableRow::new(vec![ + TableCell::new("Files"), + TableCell::new("128"), + ]), + TableRow::new(vec![ + TableCell::new("Tests"), + TableCell::new("42"), + ]), + ]); + let h = 4.min(a.height); + table.render(Rect::new(a.x, a.y, a.width, h), f.buffer_mut()); + vr.advance(h); + } + if let Some(a) = vr.begin("Width-constrained", f.buffer_mut()) { + let table = Table::new(vec!["Col A", "Col B"], colors).with_rows(vec![ + TableRow::new(vec![ + TableCell::new("Short"), + TableCell::new("Value"), + ]), + ]); + let h = 3.min(a.height); + let w = 30.min(a.width); + table.render(Rect::new(a.x, a.y, w, h), f.buffer_mut()); + vr.advance(h); + } +} + +fn draw_text_heading_preview( + f: &mut ratatui::Frame, + colors: &SemanticColors, + area: Rect, + filter: Option<&str>, +) { + let mut vr = VR::new(area, colors, filter); + + if let Some(a) = vr.begin("Default", f.buffer_mut()) { + let h = TextHeading::new("Default Heading", colors); + h.render(Rect::new(a.x, a.y, a.width, 1), f.buffer_mut()); + vr.advance(1); + } + if let Some(a) = vr.begin("Error", f.buffer_mut()) { + let h = TextHeading::new("Error Heading", colors).error(); + h.render(Rect::new(a.x, a.y, a.width, 1), f.buffer_mut()); + vr.advance(1); + } +} + +fn draw_spinner_preview( + f: &mut ratatui::Frame, + colors: &SemanticColors, + state: &SpinnerState, + area: Rect, + filter: Option<&str>, +) { + let mut vr = VR::new(area, colors, filter); + + if let Some(a) = vr.begin("Default", f.buffer_mut()) { + let spinner = TextSpinner::new(colors) + .with_type(SpinnerType::Bounce) + .with_message("Loading..."); + let w = TextSpinnerWidget::new(spinner, state); + w.render(Rect::new(a.x, a.y, a.width, 1), f.buffer_mut()); + vr.advance(1); + } + if let Some(a) = vr.begin("Icon only", f.buffer_mut()) { + let spinner = TextSpinner::new(colors).with_type(SpinnerType::Bounce); + let w = TextSpinnerWidget::new(spinner, state); + w.render(Rect::new(a.x, a.y, a.width, 1), f.buffer_mut()); + vr.advance(1); + } + if let Some(a) = vr.begin("Label only", f.buffer_mut()) { + let spinner = TextSpinner::new(colors).with_message("Processing"); + let w = TextSpinnerWidget::new(spinner, state); + w.render(Rect::new(a.x, a.y, a.width, 1), f.buffer_mut()); + vr.advance(1); + } + if let Some(a) = vr.begin("Placeholder", f.buffer_mut()) { + let spinner = TextSpinner::new(colors) + .with_type(SpinnerType::Inline) + .with_message("Waiting..."); + let w = TextSpinnerWidget::new(spinner, state); + w.render(Rect::new(a.x, a.y, a.width, 1), f.buffer_mut()); + vr.advance(1); + } + if let Some(a) = vr.begin("Brand", f.buffer_mut()) { + let spinner = TextSpinner::new(colors) + .with_type(SpinnerType::Bounce) + .with_message("Brand spinner"); + let w = TextSpinnerWidget::new(spinner, state); + w.render(Rect::new(a.x, a.y, a.width, 1), f.buffer_mut()); + vr.advance(1); + } + if let Some(a) = vr.begin("Info", f.buffer_mut()) { + let spinner = TextSpinner::new(colors) + .with_type(SpinnerType::Inline) + .with_message("Info spinner"); + let w = TextSpinnerWidget::new(spinner, state); + w.render(Rect::new(a.x, a.y, a.width, 1), f.buffer_mut()); + vr.advance(1); + } +} + +fn draw_text_title_preview( + f: &mut ratatui::Frame, + colors: &SemanticColors, + area: Rect, + filter: Option<&str>, +) { + let mut vr = VR::new(area, colors, filter); + + if let Some(a) = vr.begin("Default", f.buffer_mut()) { + let t = TextTitle::new("Default Title", colors); + t.render(Rect::new(a.x, a.y, a.width, 1), f.buffer_mut()); + vr.advance(1); + } + if let Some(a) = vr.begin("Error", f.buffer_mut()) { + let t = TextTitle::new("Error Title", colors).error(); + t.render(Rect::new(a.x, a.y, a.width, 1), f.buffer_mut()); + vr.advance(1); + } +} + +fn draw_timeline_preview( + f: &mut ratatui::Frame, + colors: &SemanticColors, + area: Rect, + filter: Option<&str>, +) { + let mut vr = VR::new(area, colors, filter); + + let variant_data: Vec<(&str, TimelineVariant, &str, Vec<&str>)> = vec![ + ("Loading", TimelineVariant::Loading, "Running tests", vec!["unit tests"]), + ("Success", TimelineVariant::Success, "Install dependencies", vec!["npm install"]), + ("Error", TimelineVariant::Error, "Build failed", vec!["src/main.rs"]), + ("Warning", TimelineVariant::Warning, "Deprecation notice", vec![]), + ("Info", TimelineVariant::Info, "Monitor active", vec![]), + ("Muted", TimelineVariant::Muted, "Skipped deploy", vec![]), + ]; + + for (name, variant, text, subs) in &variant_data { + if let Some(a) = vr.begin(name, f.buffer_mut()) { + let needed = 1 + subs.len() as u16; + let h = needed.min(a.height); + let ti = TimelineItem::new(*variant, text, colors) + .with_sub_items(subs.iter().map(|s| TimelineSubItem::new(s)).collect()); + ti.render(Rect::new(a.x, a.y, a.width, h), f.buffer_mut()); + vr.advance(h); + } + } + + if let Some(a) = vr.begin("With multiple sub-items", f.buffer_mut()) { + let ti = TimelineItem::new(TimelineVariant::Success, "Build project", colors) + .with_sub_items(vec![ + TimelineSubItem::new("lint"), + TimelineSubItem::new("compile"), + TimelineSubItem::new("test"), + TimelineSubItem::new("package"), + ]); + let h = 5.min(a.height); + ti.render(Rect::new(a.x, a.y, a.width, h), f.buffer_mut()); + vr.advance(h); + } +} diff --git a/dist/rust/tests/demo_smoke.rs b/dist/rust/tests/demo_smoke.rs new file mode 100644 index 0000000..84822b2 --- /dev/null +++ b/dist/rust/tests/demo_smoke.rs @@ -0,0 +1,112 @@ +//! Demo smoke tests — verify every component renders via --snapshot without errors. + +use std::path::Path; +use std::process::Command; + +fn demo_binary() -> std::path::PathBuf { + // Build the demo binary once + let status = Command::new(env!("CARGO")) + .args(["build", "--example", "demo"]) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .status() + .expect("failed to build demo"); + assert!(status.success(), "demo build failed"); + + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join("debug") + .join("examples") + .join("demo") +} + +#[test] +fn list_returns_all_components() { + let bin = demo_binary(); + let output = Command::new(&bin) + .arg("--list") + .output() + .expect("failed to run --list"); + assert!(output.status.success(), "--list failed"); + + let stdout = String::from_utf8(output.stdout).unwrap(); + let names: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect(); + + // Tokens (lowercase) + components (PascalCase) = 19 + assert!( + names.len() >= 19, + "expected at least 19 entries, got {}: {:?}", + names.len(), + names + ); + // Spot-check tokens + assert!(names.contains(&"breakpoints"), "missing breakpoints"); + assert!(names.contains(&"colors"), "missing colors"); + assert!(names.contains(&"icons"), "missing icons"); + // Spot-check components + assert!(names.contains(&"Select"), "missing Select"); + assert!(names.contains(&"Dialog"), "missing Dialog"); + assert!(names.contains(&"SelectAutocomplete"), "missing SelectAutocomplete"); +} + +#[test] +fn snapshot_all_components_exit_0_with_output() { + let bin = demo_binary(); + + // Get the list of all component/token names + let list_out = Command::new(&bin) + .arg("--list") + .output() + .expect("failed to run --list"); + assert!(list_out.status.success()); + + let stdout = String::from_utf8(list_out.stdout).unwrap(); + let names: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect(); + assert!(!names.is_empty(), "--list returned no components"); + + // Snapshot each component and verify exit 0 + non-empty output + for name in &names { + let snap = Command::new(&bin) + .args(["--component", name, "--snapshot"]) + .output() + .unwrap_or_else(|e| panic!("failed to run snapshot for {}: {}", name, e)); + + assert!( + snap.status.success(), + "'{}' snapshot failed (exit {}): {}", + name, + snap.status, + String::from_utf8_lossy(&snap.stderr) + ); + assert!( + !snap.stdout.is_empty(), + "'{}' snapshot produced no output", + name + ); + } +} + +#[test] +fn unknown_component_exits_with_error() { + let bin = demo_binary(); + let output = Command::new(&bin) + .args(["--component", "NonExistentComponent", "--snapshot"]) + .output() + .expect("failed to run demo"); + assert!( + !output.status.success(), + "expected non-zero exit for unknown component" + ); +} + +#[test] +fn unknown_variant_exits_with_error() { + let bin = demo_binary(); + let output = Command::new(&bin) + .args(["--component", "Select", "--variant", "NoSuchVariant", "--snapshot"]) + .output() + .expect("failed to run demo"); + assert!( + !output.status.success(), + "expected non-zero exit for unknown variant" + ); +} From 183cc4e12f1f8952d0210cc80d0365783d12d388 Mon Sep 17 00:00:00 2001 From: Ismael Canal Date: Fri, 17 Apr 2026 14:48:58 +0200 Subject: [PATCH 11/18] Rename bun target to node (Node.js + npm + Ink + vitest) The bun target becomes the node target using standard Node.js toolchain: - Runtime: Node.js 20+ (was Bun 1.1+) - Testing: vitest (was bun:test) - Execution: npx tsx (was bun run) - Package manager: npm (was bun install) The bun target name is reserved for a future OpenTUI-based target. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- components/previews.md | 11 +- dist/bun/demo.test.tsx | 61 -- dist/bun/demo.tsx | 1068 ----------------------------------- dist/bun/tsconfig.json | 13 - targets/{bun.md => node.md} | 35 +- 5 files changed, 26 insertions(+), 1162 deletions(-) delete mode 100644 dist/bun/demo.test.tsx delete mode 100644 dist/bun/demo.tsx delete mode 100644 dist/bun/tsconfig.json rename targets/{bun.md => node.md} (91%) diff --git a/components/previews.md b/components/previews.md index be9e327..6cf556d 100644 --- a/components/previews.md +++ b/components/previews.md @@ -92,12 +92,17 @@ This separation ensures: - **Content**: When no component is selected, show a centered placeholder message (e.g., `"Select a component to preview"`). -- **Active preview**: When a component is opened, render all its `.preview.md` - variants stacked vertically, each with a **TextHeading** label showing the - variant name. If the content overflows, wrap it in a **ScrollBox**. +- **Active preview**: When a component is opened, render its `.preview.md` + variants. If the content overflows, wrap it in a **ScrollBox**. - **Interactive previews**: Every variant MUST be a live, interactive instance. The `props` block defines **initial** props, not a static snapshot. Components MUST respond to keyboard input, update state, and re-render in real time. +- **Variant navigation with TabBar**: When a component has multiple preview + variants, use a **TabBar** at the top of the preview panel with one tab per + variant. Only the active variant is mounted and receives keyboard focus. + This avoids the problem of multiple interactive instances competing for + input (e.g., two Select lists both capturing arrow keys). Switching tabs + unmounts the previous variant and mounts the new one with fresh initial props. ## Focus model diff --git a/dist/bun/demo.test.tsx b/dist/bun/demo.test.tsx deleted file mode 100644 index 3eba593..0000000 --- a/dist/bun/demo.test.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, test, expect } from "bun:test"; -import { execFileSync } from "child_process"; -import path from "path"; - -const DEMO_PATH = path.join(import.meta.dir, "demo.tsx"); - -function runDemo(...args: string[]): { stdout: string; exitCode: number } { - try { - const stdout = execFileSync("bun", ["run", DEMO_PATH, ...args], { - encoding: "utf-8", - timeout: 15_000, - env: { ...process.env, FORCE_COLOR: "0" }, - }); - return { stdout, exitCode: 0 }; - } catch (err: any) { - return { stdout: err.stdout ?? "", exitCode: err.status ?? 1 }; - } -} - -describe("demo CLI", () => { - test("--list prints all component names and exits 0", () => { - const { stdout, exitCode } = runDemo("--list"); - expect(exitCode).toBe(0); - const names = stdout.trim().split("\n"); - expect(names.length).toBeGreaterThanOrEqual(10); - // tokens first, lowercase - expect(names[0]).toBe("breakpoints"); - expect(names[1]).toBe("colors"); - expect(names[2]).toBe("icons"); - // components follow - expect(names).toContain("Dialog"); - expect(names).toContain("Select"); - expect(names).toContain("TextTitle"); - }); - - test("--component with unknown name exits 1", () => { - const { exitCode } = runDemo("--component", "DoesNotExist", "--snapshot"); - expect(exitCode).toBe(1); - }); - - test("--variant with unknown variant exits 1", () => { - const { exitCode } = runDemo("--component", "HintBar", "--variant", "NoSuchVariant", "--snapshot"); - expect(exitCode).toBe(1); - }); - - test("--snapshot without --component exits 1", () => { - const { exitCode } = runDemo("--snapshot"); - expect(exitCode).toBe(1); - }); -}); - -describe("demo smoke tests", () => { - const { stdout: listOutput } = runDemo("--list"); - const componentNames = listOutput.trim().split("\n").filter(Boolean); - - test.each(componentNames)("--component %s --snapshot renders without error", (name) => { - const { stdout, exitCode } = runDemo("--component", name, "--snapshot"); - expect(exitCode).toBe(0); - expect(stdout.trim().length).toBeGreaterThan(0); - }); -}); diff --git a/dist/bun/demo.tsx b/dist/bun/demo.tsx deleted file mode 100644 index 291f230..0000000 --- a/dist/bun/demo.tsx +++ /dev/null @@ -1,1068 +0,0 @@ -#!/usr/bin/env bun -import React, { useState, useMemo, useEffect } from "react"; -import { render as inkRender, Box, Text, useInput, useApp } from "ink"; -import { render as testRender } from "ink-testing-library"; -import { useColors } from "./src/tuikit/hooks/useColors.js"; -import { useBreakpoint } from "./src/tuikit/hooks/useBreakpoint.js"; -import { TextTitle } from "./src/tuikit/components/TextTitle/TextTitle.js"; -import { TextHeading } from "./src/tuikit/components/TextHeading/TextHeading.js"; -import { HintBar } from "./src/tuikit/components/HintBar/HintBar.js"; -import { Select } from "./src/tuikit/components/Select/Select.js"; -import { Input } from "./src/tuikit/components/Input/Input.js"; -import { Link } from "./src/tuikit/components/Link/Link.js"; -import { TextSpinner } from "./src/tuikit/components/TextSpinner/TextSpinner.js"; -import { ScrollBox } from "./src/tuikit/components/ScrollBox/ScrollBox.js"; -import { - IconSuccess, IconError, IconWarning, IconInfoCompleted, IconDisabled, - IconPrompt, IconInfoWorking, IconInfoEmpty, - IconArrowRight, IconArrowLeft, IconArrowUp, IconArrowDown, - IconScrollbar, IconCheckboxChecked, IconCheckboxUnchecked, - IconSeparatorWord, IconSeparatorList, - IconNestingLast, IconNestingMiddle, IconNestingSkip, -} from "./src/tuikit/components/Icons/Icons.js"; -import { Dialog } from "./src/tuikit/components/Dialog/Dialog.js"; -import { TimelineItem } from "./src/tuikit/components/TimelineItem/TimelineItem.js"; -import { TabBar } from "./src/tuikit/components/TabBar/TabBar.js"; -import { Table } from "./src/tuikit/components/Table/Table.js"; -import { QrCode } from "./src/tuikit/components/QrCode/QrCode.js"; -import { Screen } from "./src/tuikit/components/Screen/Screen.js"; -import { Metric } from "./src/tuikit/components/Metric/Metric.js"; -import { SelectAutocomplete } from "./src/tuikit/components/SelectAutocomplete/SelectAutocomplete.js"; -import { COMPACT_MAX, NARROW_MIN, NARROW_MAX, WIDE_MIN } from "./src/tuikit/tokens/breakpoints.js"; - -// ─── Preview Variant Registry ─────────────────────────────────────── - -interface PreviewVariant { - name: string; - render: (hasFocus: boolean) => React.ReactNode; -} - -interface PreviewEntry { - name: string; - type: "token" | "component"; - variants: PreviewVariant[]; -} - -// Token preview components (stateless) - -function ColorsSemanticPreview() { - const colors = useColors(); - const entries = Object.entries(colors).filter(([, v]) => v != null); - return ( - - {entries.map(([name, hex]) => ( - - ██ - {name} - {hex as string} - - ))} - - ); -} - -function ColorsTextPreview() { - const colors = useColors(); - const tokens = ["textPrimary", "textSecondary", "textTertiary", "textDisabled", "textOnBackground", "textOnBackgroundSecondary"] as const; - return ( - - {tokens.map((t) => ( - - ██ {t} - {(colors as any)[t] ?? "–"} - - ))} - - ); -} - -function ColorsStatusPreview() { - const colors = useColors(); - const tokens = ["statusSuccess", "statusError", "statusWarning", "statusInfo"] as const; - return ( - - {tokens.map((t) => ( - - ██ {t} - {(colors as any)[t] ?? "–"} - - ))} - - ); -} - -function ColorsBrandPreview() { - const colors = useColors(); - const tokens = ["brand", "accent", "selected"] as const; - return ( - - {tokens.map((t) => ( - - ██ {t} - {(colors as any)[t] ?? "–"} - - ))} - - ); -} - -function IconGroupPreview({ icons }: { icons: Array<[string, React.FC]> }) { - return ( - - {icons.map(([label, Comp]) => ( - - - {label} - - ))} - - ); -} - -const STATUS_ICONS: Array<[string, React.FC]> = [ - ["IconSuccess", IconSuccess], ["IconError", IconError], ["IconWarning", IconWarning], - ["IconPrompt", IconPrompt], ["IconInfoCompleted", IconInfoCompleted], - ["IconInfoWorking", IconInfoWorking], ["IconInfoEmpty", IconInfoEmpty], -]; -const NAV_ICONS: Array<[string, React.FC]> = [ - ["IconArrowUp", IconArrowUp], ["IconArrowDown", IconArrowDown], - ["IconArrowLeft", IconArrowLeft], ["IconArrowRight", IconArrowRight], -]; -const UI_ICONS: Array<[string, React.FC]> = [ - ["IconCheckboxChecked", IconCheckboxChecked], ["IconCheckboxUnchecked", IconCheckboxUnchecked], - ["IconScrollbar", IconScrollbar], ["IconSeparatorWord", IconSeparatorWord], - ["IconSeparatorList", IconSeparatorList], -]; -const TREE_ICONS: Array<[string, React.FC]> = [ - ["IconNestingLast", IconNestingLast], ["IconNestingMiddle", IconNestingMiddle], - ["IconNestingSkip", IconNestingSkip], -]; -const ALL_ICONS: Array<[string, React.FC]> = [...STATUS_ICONS, ...NAV_ICONS, ...UI_ICONS, ...TREE_ICONS]; - -// Interactive preview sub-components - -function InputPreview({ hasFocus }: { hasFocus: boolean }) { - const [text, setText] = useState(""); - return ; -} - -function InputMultilinePreview({ hasFocus }: { hasFocus: boolean }) { - const [text, setText] = useState(""); - return ; -} - -function InputMaskedPreview({ hasFocus }: { hasFocus: boolean }) { - const [text, setText] = useState(""); - return ; -} - -function InputSingleLinePreview({ hasFocus }: { hasFocus: boolean }) { - const [text, setText] = useState(""); - return ; -} - -function TabBarInteractivePreview({ hasFocus, items, selectedIndex: initial, navigationKeys, loop }: { - hasFocus: boolean; - items: Array<{ value: string; label: string }>; - selectedIndex: number; - navigationKeys?: "arrow-only" | "tab-only" | "all"; - loop?: boolean; -}) { - const [tab, setTab] = useState(initial); - return ( - - ); -} - -// ─── Build the registry from preview specs ────────────────────────── - -const PREVIEW_REGISTRY: PreviewEntry[] = [ - // --- Tokens (alphabetical, lowercase names) --- - { - name: "breakpoints", - type: "token", - variants: [ - { - name: "Current breakpoint", - render: () => { - const bp = useBreakpoint(); - return ( - - breakpoint: {bp.breakpoint} - columns: {bp.terminalWidth} rows: {bp.terminalHeight} - compact: ≤{COMPACT_MAX} - narrow: {NARROW_MIN}–{NARROW_MAX} - wide: ≥{WIDE_MIN} - - ); - }, - }, - ], - }, - { - name: "colors", - type: "token", - variants: [ - { name: "Semantic colors", render: () => }, - { name: "Text tokens", render: () => }, - { name: "Status tokens", render: () => }, - { name: "Brand tokens", render: () => }, - ], - }, - { - name: "icons", - type: "token", - variants: [ - { name: "Status icons", render: () => }, - { name: "Navigation icons", render: () => }, - { name: "UI icons", render: () => }, - { name: "Tree icons", render: () => }, - ], - }, - - // --- Components (alphabetical, PascalCase names) --- - { - name: "Dialog", - type: "component", - variants: [ - { - name: "Basic", - render: () => ( - This is a simple dialog. - ), - }, - { - name: "With subtitle", - render: () => ( - - Are you sure? - - ), - }, - { - name: "Fixed width", - render: () => ( - - Content constrained to 40 columns. - - ), - }, - { - name: "Border title", - render: () => ( - - Model: GPT-4 · Tokens: 1,234 - - ), - }, - { - name: "Full variant", - render: () => ( - - Allow access to this folder? - - ), - }, - ], - }, - { - name: "HintBar", - type: "component", - variants: [ - { - name: "Default", - render: () => ( - - ), - }, - { - name: "Custom keys", - render: () => ( - - ), - }, - { - name: "Conditional", - render: () => ( - - ), - }, - { - name: "Custom separator", - render: () => ( - - ), - }, - ], - }, - { - name: "Icons", - type: "component", - variants: [ - { name: "All icons", render: () => }, - ], - }, - { - name: "Input", - type: "component", - variants: [ - { name: "Default", render: (f) => }, - { name: "Multiline", render: (f) => }, - { name: "Masked", render: (f) => }, - { name: "Single line", render: (f) => }, - ], - }, - { - name: "Link", - type: "component", - variants: [ - { name: "Default", render: () => }, - { name: "With label color", render: () => { - const colors = useColors(); - return GitHub; - }}, - { name: "With brand color", render: () => { - const colors = useColors(); - return GitHub; - }}, - { name: "Bold", render: () => { - const colors = useColors(); - return GitHub; - }}, - ], - }, - { - name: "Metric", - type: "component", - variants: [ - { - name: "Default", - render: () => ( - - ), - }, - { - name: "Highlighted", - render: () => { - const colors = useColors(); - return ( - - ); - }, - }, - ], - }, - { - name: "QrCode", - type: "component", - variants: [ - { name: "Short URL", render: () => }, - { name: "Long URL", render: () => }, - ], - }, - { - name: "Screen", - type: "component", - variants: [ - { - name: "Basic", - render: () => ( - - 10:21:03 Server starting on port 3000 - 10:21:04 Connected to database - 10:21:05 Registered 14 API routes - - ), - }, - { - name: "With header and footer", - render: () => ( - Screen — Screen.tsxApplication Log (3 entries)} - footer={↑↓ scroll · Esc back} - > - 10:21:03 Server starting on port 3000 - 10:21:04 Connected to database - 10:21:05 Registered 14 API routes - - ), - }, - { - name: "Non-scrollable", - render: () => ( - - Line 1 - Line 2 - Line 3 - - ), - }, - ], - }, - { - name: "ScrollBox", - type: "component", - variants: [ - { - name: "No scroll (content fits)", - render: () => ( - - ◎ 001 [INFO ] Server starting on port 3000 - ✓ 002 [OK ] Connected to database - ◎ 003 [INFO ] Registered 14 API routes - - ), - }, - { - name: "Scrollable list", - render: (f) => ( - - ◎ 001 [INFO ] Server starting on port 3000 - ◎ 002 [INFO ] Loading configuration from .env - ✓ 003 [OK ] Connected to database - ◎ 004 [INFO ] Processing batch job #1284 - ✓ 005 [OK ] Batch job completed (42 items) - ! 006 [WARN ] Redis not configured - ◎ 007 [INFO ] Incoming webhook from GitHub - ✖ 008 [ERROR] Build failed: missing dependency - - ), - }, - { - name: "No scrollbar", - render: () => ( - - {Array.from({ length: 6 }, (_, i) => Item {i})} - - ), - }, - { - name: "Focusable", - render: (f) => ( - {}}> - ❯ 001 [INFO ] First item - 002 [WARN ] Second item - 003 [OK ] Third item - 004 [INFO ] Fourth item - - ), - }, - { - name: "Hover + virtualized", - render: (f) => ( - {}} onHoverLine={() => {}}> - ❯ 001 [INFO ] Item one - 002 [INFO ] Item two - 003 [WARN ] Item three - 004 [OK ] Item four - 005 [INFO ] Item five - 006 [ERROR] Item six - - ), - }, - ], - }, - { - name: "Select", - type: "component", - variants: [ - { - name: "Basic", - render: () => ( - {}} - escapeItem={{ label: "Cancel", value: "cancel" }} - /> - ), - }, - { - name: "With text input", - render: () => ( - {}} - escapeItem={{ label: "Cancel", value: "cancel" }} - /> - ), - }, - ], - }, - { - name: "SelectAutocomplete", - type: "component", - variants: [ - { - name: "Basic", - render: () => ( - {}} - onEscape={() => {}} - placeholder="Search options..." - /> - ), - }, - { - name: "With current item", - render: () => ( - {}} - onEscape={() => {}} - placeholder="Search..." - /> - ), - }, - ], - }, - { - name: "TabBar", - type: "component", - variants: [ - { - name: "Display only", - render: () => ( - - ), - }, - { - name: "Arrow navigation", - render: (f) => ( - - ), - }, - { - name: "Tab navigation", - render: (f) => ( - - ), - }, - { - name: "No loop", - render: (f) => ( - - ), - }, - ], - }, - { - name: "Table", - type: "component", - variants: [ - { - name: "Basic", - render: () => ( -
- ), - }, - { - name: "Borderless key-value", - render: () => ( -
- ), - }, - { - name: "Right-aligned numbers", - render: () => ( -
- ), - }, - { - name: "Width-constrained", - render: () => ( -
- ), - }, - ], - }, - { - name: "TextHeading", - type: "component", - variants: [ - { name: "Default", render: () => Section Heading }, - { name: "Error", render: () => Error Details }, - ], - }, - { - name: "TextSpinner", - type: "component", - variants: [ - { name: "Default", render: () => }, - { name: "Icon only", render: () => }, - { name: "Label only", render: () => }, - { name: "Placeholder", render: () => }, - { name: "Brand", render: () => }, - { name: "Info", render: () => }, - ], - }, - { - name: "TextTitle", - type: "component", - variants: [ - { name: "Default", render: () => Welcome to TUIkit }, - { name: "Error", render: () => Something went wrong }, - ], - }, - { - name: "TimelineItem", - type: "component", - variants: [ - { name: "Loading", render: () => }, - { name: "Success", render: () => }, - { name: "Error", render: () => }, - { name: "Warning", render: () => }, - { name: "Info", render: () => }, - { name: "Muted", render: () => }, - { - name: "With multiple sub-items", - render: () => ( - - ), - }, - ], - }, -]; - -// Computed lookup maps -const REGISTRY_MAP = new Map(PREVIEW_REGISTRY.map((e) => [e.name, e])); -const ALL_NAMES = PREVIEW_REGISTRY.map((e) => e.name); - -// ─── Variant renderer component ───────────────────────────────────── - -function VariantRenderer({ entry, variantFilter, hasFocus }: { - entry: PreviewEntry; - variantFilter?: string; - hasFocus: boolean; -}) { - const variants = variantFilter - ? entry.variants.filter((v) => v.name === variantFilter) - : entry.variants; - return ( - - {variants.map((v) => ( - - {v.name} - {v.render(hasFocus)} - - ))} - - ); -} - -// ─── CLI Argument Parsing ─────────────────────────────────────────── - -const args = process.argv.slice(2); -const listFlag = args.includes("--list"); -const snapshotFlag = args.includes("--snapshot"); -const componentIdx = args.indexOf("--component"); -const componentArg = componentIdx >= 0 ? args[componentIdx + 1] : undefined; -const variantIdx = args.indexOf("--variant"); -const variantArg = variantIdx >= 0 ? args[variantIdx + 1] : undefined; - -// ─── --list mode ──────────────────────────────────────────────────── - -if (listFlag) { - for (const name of ALL_NAMES) { - console.log(name); - } - process.exit(0); -} - -// ─── --snapshot mode ──────────────────────────────────────────────── - -if (snapshotFlag) { - if (!componentArg) { - process.stderr.write("Error: --snapshot requires --component \n"); - process.exit(1); - } - const entry = REGISTRY_MAP.get(componentArg); - if (!entry) { - process.stderr.write(`Error: unknown component "${componentArg}"\n`); - process.exit(1); - } - if (variantArg) { - const found = entry.variants.some((v) => v.name === variantArg); - if (!found) { - process.stderr.write(`Error: unknown variant "${variantArg}" for component "${componentArg}"\n`); - process.exit(1); - } - } - const { lastFrame, unmount } = testRender( - React.createElement(VariantRenderer, { entry, variantFilter: variantArg, hasFocus: false }) - ); - const frame = lastFrame(); - unmount(); - process.stdout.write((frame ?? "") + "\n"); - process.exit(0); -} - -// ─── --component (interactive single-component mode) ──────────────── - -if (componentArg && !snapshotFlag) { - const entry = REGISTRY_MAP.get(componentArg); - if (!entry) { - process.stderr.write(`Error: unknown component "${componentArg}"\n`); - process.exit(1); - } - if (variantArg) { - const found = entry.variants.some((v) => v.name === variantArg); - if (!found) { - process.stderr.write(`Error: unknown variant "${variantArg}" for component "${componentArg}"\n`); - process.exit(1); - } - } - - function SingleComponentApp() { - const { exit } = useApp(); - const colors = useColors(); - useInput((input, key) => { - if (key.escape || input === "q") exit(); - }); - return ( - {componentArg!}} - footer={} - > - - - - - ); - } - - process.stdout.write("\x1b[?1049h"); - const app = inkRender(React.createElement(SingleComponentApp)); - app.waitUntilExit().then(() => { - process.stdout.write("\x1b[?1049l"); - }); -} else { - // ─── Default: full interactive TUI ────────────────────────────────── - - // Sidebar display names: tokens use display casing, components use their name - const TOKEN_DISPLAY = PREVIEW_REGISTRY.filter((e) => e.type === "token").map((e) => ({ - name: e.name, - display: e.name.charAt(0).toUpperCase() + e.name.slice(1), - })); - const COMPONENT_DISPLAY = PREVIEW_REGISTRY.filter((e) => e.type === "component").map((e) => ({ - name: e.name, - display: e.name, - })); - const SEPARATOR = "──────────────────"; - - type SidebarItem = { kind: "entry"; name: string; display: string } | { kind: "separator" }; - const SIDEBAR_ITEMS: SidebarItem[] = [ - ...TOKEN_DISPLAY.map((t) => ({ kind: "entry" as const, name: t.name, display: t.display })), - { kind: "separator" as const }, - ...COMPONENT_DISPLAY.map((c) => ({ kind: "entry" as const, name: c.name, display: c.display })), - ]; - - type FocusState = "sidebar" | "searching" | "preview"; - - function App() { - const colors = useColors(); - const { exit } = useApp(); - - const [focus, setFocus] = useState("sidebar"); - const [selectedIdx, setSelectedIdx] = useState(0); - const [openName, setOpenName] = useState(null); - const [searchQuery, setSearchQuery] = useState(""); - - const filteredItems = useMemo(() => { - if (!searchQuery) return SIDEBAR_ITEMS; - const q = searchQuery.toLowerCase(); - return SIDEBAR_ITEMS.filter( - (item) => item.kind === "separator" || item.display.toLowerCase().includes(q) - ); - }, [searchQuery]); - - useEffect(() => { - setSelectedIdx((prev) => { - const max = filteredItems.length - 1; - if (prev > max) return Math.max(0, max); - if (filteredItems[prev]?.kind === "separator") { - return prev + 1 <= max ? prev + 1 : Math.max(0, prev - 1); - } - return prev; - }); - }, [filteredItems]); - - const moveSelection = (dir: 1 | -1) => { - setSelectedIdx((prev) => { - let next = prev + dir; - if (next < 0) next = 0; - if (next >= filteredItems.length) next = filteredItems.length - 1; - if (filteredItems[next]?.kind === "separator") next += dir; - if (next < 0) next = 0; - if (next >= filteredItems.length) next = filteredItems.length - 1; - return next; - }); - }; - - useInput((input, key) => { - if (input === "q" && focus !== "searching" && focus !== "preview") { - exit(); - return; - } - - if (focus === "searching") { - if (key.escape) { setSearchQuery(""); setFocus("sidebar"); return; } - if (key.return) { - const item = filteredItems[selectedIdx]; - if (item?.kind === "entry") { - setOpenName(item.name); - setFocus("preview"); - } - return; - } - if (key.backspace || key.delete) { setSearchQuery((q) => q.slice(0, -1)); return; } - if (key.upArrow) { moveSelection(-1); return; } - if (key.downArrow) { moveSelection(1); return; } - if (input && !key.ctrl && !key.meta) { setSearchQuery((q) => q + input); } - return; - } - - if (focus === "preview") { - if (key.escape) { setOpenName(null); setFocus("sidebar"); } - return; - } - - // Sidebar - if (key.escape) return; - if (input === "/" || (input && !key.ctrl && !key.meta && input !== "j" && input !== "k" && input !== "q")) { - setFocus("searching"); - if (input !== "/") setSearchQuery((q) => q + input); - return; - } - if (key.upArrow || input === "k") { moveSelection(-1); return; } - if (key.downArrow || input === "j") { moveSelection(1); return; } - if (key.return) { - const item = filteredItems[selectedIdx]; - if (item?.kind === "entry") { - setOpenName(item.name); - setFocus("preview"); - } - return; - } - }); - - const sidebarWidth = 24; - let hints: Record; - if (focus === "searching") { - hints = { "up-down": "navigate", enter: "open", esc: "clear", q: false }; - } else if (focus === "preview") { - hints = { esc: "back", q: "quit" }; - } else { - hints = { "up-down": "navigate", enter: "open", "/": "search", q: "quit" }; - } - - const openEntry = openName ? REGISTRY_MAP.get(openName) : undefined; - - return ( - TUIKit Preview} - footer={} - > - - - - {focus === "searching" ? "▸ " : " "} - - - {filteredItems.map((item, i) => { - if (item.kind === "separator") { - return {SEPARATOR}; - } - const isHighlighted = i === selectedIdx && focus !== "preview"; - const isOpen = item.name === openName; - return ( - - - {isHighlighted ? "▸ " : " "}{item.display} - - {isOpen ? : null} - - ); - })} - - - - {openEntry ? ( - <> - - {openName!} - - - - ) : ( - - Select a component to preview - - )} - - - - ); - } - - process.stdout.write("\x1b[?1049h"); - const app = inkRender(React.createElement(App)); - app.waitUntilExit().then(() => { - process.stdout.write("\x1b[?1049l"); - }); -} diff --git a/dist/bun/tsconfig.json b/dist/bun/tsconfig.json deleted file mode 100644 index c95c67b..0000000 --- a/dist/bun/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "jsx": "react-jsx", - "strict": true, - "module": "esnext", - "moduleResolution": "bundler", - "target": "esnext", - "esModuleInterop": true, - "skipLibCheck": true, - "types": ["bun-types"] - }, - "include": ["src/**/*.ts", "src/**/*.tsx", "demo.tsx", "demo.test.tsx"] -} diff --git a/targets/bun.md b/targets/node.md similarity index 91% rename from targets/bun.md rename to targets/node.md index 3228e0b..2d627fe 100644 --- a/targets/bun.md +++ b/targets/node.md @@ -1,8 +1,8 @@ --- kind: target -name: bun +name: node language: TypeScript -runtime: Bun 1.1+ +runtime: Node.js 20+ framework: name: Ink version: ">=5.0" @@ -14,8 +14,8 @@ styling: url: https://github.com/chalk/chalk role: ANSI color encoding (hex → SGR codes) testing: - runner: bun test - framework: bun:test (Jest-compatible) + runner: npx vitest run + framework: vitest helper: ink-testing-library output: @@ -31,13 +31,13 @@ output: index.ts: Public re-exports --- -# Bun Target — Ink + React +# Node Target — Ink + React ## Architecture pattern -This target uses the **same paradigm** as the original TUIkit (React + Ink) but -runs on the Bun runtime instead of Node.js. Components are functional React -components with hooks. +This target uses React + Ink on the Node.js runtime. Components are functional +React components with hooks. Uses npm for package management and vitest for +testing. ```tsx import { Box, Text } from "ink"; @@ -86,7 +86,7 @@ Callbacks map directly to React props — no translation needed: ```tsx // Spec: onSelect: callback(item: SelectItem) → void -// Bun/Ink: Direct prop +// Node/Ink: Direct prop interface SelectProps { onSelect: (item: SelectItem) => void; } @@ -239,14 +239,13 @@ test("moves highlight down on arrow key", () => { | `escape` | `\x1B` | | `ctrl+g` | `\x07` | -## Bun-specific notes +## Node-specific notes -- Bun natively supports TypeScript — no build step needed for development -- Use `bun test` instead of `vitest` or `jest` -- `bun:test` API is Jest-compatible: `describe`, `it`, `expect`, `beforeEach` +- Use TypeScript with `tsx` or `ts-node` for execution without a build step +- Use `vitest` for testing: `describe`, `it`, `expect`, `beforeEach` - ESM modules by default (`"type": "module"`) -- Ink works on Bun without modification -- Use `bun install` for dependency management +- Ink works on Node.js without modification +- Use `npm install` for dependency management ## Dependencies @@ -260,7 +259,9 @@ test("moves highlight down on arrow key", () => { }, "devDependencies": { "@types/react": "^18.0.0", - "ink-testing-library": "^4.0.0" + "ink-testing-library": "^4.0.0", + "vitest": "^3.0.0", + "tsx": "^4.0.0" } } ``` @@ -272,7 +273,7 @@ Every target must include a demo that renders all components interactively. ```yaml demo: entry: demo.tsx - run_command: "bun run demo.tsx" + run_command: "npx tsx demo.tsx" ``` The demo app is a multi-screen Ink application. It shows a menu of available From 773677e23f03e09c21602dbe1ce96a44b8fdb5ea Mon Sep 17 00:00:00 2001 From: Ismael Canal Date: Fri, 17 Apr 2026 14:56:28 +0200 Subject: [PATCH 12/18] Update bun target to use @opentui/react (React reconciler) Switch from imperative factory-function API to React+JSX with OpenTUI's native Zig renderer. Same mental model as the node/Ink target (hooks, functional components) but backed by OpenTUI's high-performance core. Key changes: - Use @opentui/react createRoot + JSX intrinsic elements (, , etc.) - Hooks: useKeyboard, useTerminalDimensions, useOnResize, useTimeline - Token access via React hooks (useColors, useBreakpoint) instead of functions - Dependencies: @opentui/core + @opentui/react + react 19 - TSX files, jsxImportSource: @opentui/react in tsconfig - State via useState/useReducer, same as node target Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- targets/bun.md | 351 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 targets/bun.md diff --git a/targets/bun.md b/targets/bun.md new file mode 100644 index 0000000..957937e --- /dev/null +++ b/targets/bun.md @@ -0,0 +1,351 @@ +--- +kind: target +name: bun +language: TypeScript +runtime: Bun 1.1+ +framework: + name: OpenTUI + React + version: ">=0.1" + url: https://github.com/anomalyco/opentui + paradigm: React (JSX, hooks, functional components on native Zig core) +styling: + builtin: true + role: > + OpenTUI handles styling natively via JSX props (fg, backgroundColor, + bold, dim, etc.) and the style prop. No external styling library needed. +testing: + runner: bun test + framework: bun:test (Jest-compatible) + helper: none (test against rendered output) + +output: + root: src/tuikit + structure: + tokens/colors.ts: Semantic color map + useColors() hook + tokens/icons.ts: Icon constants + semantic aliases + tokens/breakpoints.ts: Breakpoint constants + useBreakpoint() hook + components/{Name}/{Name}.tsx: Component function + components/{Name}/{Name}.test.tsx: Tests + hooks/useColors.ts: React hook for color access + hooks/useBreakpoint.ts: React hook for responsive breakpoints + index.ts: Public re-exports +--- + +# Bun Target — OpenTUI + React + +## Architecture pattern + +This target uses **React with OpenTUI's native Zig renderer**. Components are +standard React functional components with hooks and JSX — the same mental model +as the node/Ink target — but rendered through OpenTUI's high-performance native +core instead of Ink's JS-based renderer. + +### Entry point + +```tsx +import { createCliRenderer } from "@opentui/core"; +import { createRoot } from "@opentui/react"; + +function App() { + return ( + + Hello, TUIKit! + + ); +} + +const renderer = await createCliRenderer({ exitOnCtrlC: true }); +createRoot(renderer).render(); +``` + +### JSX intrinsic elements + +OpenTUI React uses **lowercase** JSX elements that map to native renderables: + +| Element | Purpose | +| ------- | ------- | +| `` | Text display with styling (fg, bold, dim, underline) | +| `` | Inline styled text (inside ``) | +| ``, `` | Bold text | +| ``, `` | Italic text | +| `` | Underlined text | +| `
` | Line break | +| `` | Link text | +| `` | Container with flexbox layout, borders, padding | +| `` | Scrollable container | +| `` | Single-line text input | +| `