diff --git a/.github/workflows/specs-ci.yml b/.github/workflows/specs-ci.yml new file mode 100644 index 0000000..1c74aa8 --- /dev/null +++ b/.github/workflows/specs-ci.yml @@ -0,0 +1,102 @@ +name: Specs CI + +on: + pull_request: + push: + branches: [main] + +jobs: + specs: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Spec lint + run: bun scripts/lint.ts + + - name: Discover targets + id: targets + shell: bash + run: | + set -euo pipefail + TARGETS="$(find targets -maxdepth 1 -name '*.md' -type f -exec basename {} .md \; | sort | tr '\n' ' ')" + TARGETS="$(echo "$TARGETS" | xargs)" + if [ -z "$TARGETS" ]; then + echo "No targets found under targets/*.md" >&2 + exit 1 + fi + echo "targets=$TARGETS" >> "$GITHUB_OUTPUT" + echo "Discovered targets: $TARGETS" + + - name: Compiler health (all targets) + shell: bash + run: | + set -euo pipefail + for t in ${{ steps.targets.outputs.targets }}; do + echo "== status: $t ==" + bun scripts/compile.ts status --target "$t" + done + + - name: Prompt generation smoke test (all targets) + shell: bash + run: | + set -euo pipefail + for t in ${{ steps.targets.outputs.targets }}; do + echo "== prompt: $t ==" + bun scripts/compile.ts prompt --target "$t" + done + + - name: No generated output committed + shell: bash + run: | + set -euo pipefail + if git ls-files --error-unmatch dist >/dev/null 2>&1; then + echo "dist/ is tracked but must remain generated-only." >&2 + exit 1 + fi + if git diff --name-only "origin/${{ github.base_ref || 'main' }}"...HEAD | grep -E '^dist/'; then + echo "PR includes files under dist/; remove generated output from commits." >&2 + exit 1 + fi + + - name: Changed-spec completeness + if: github.event_name == 'pull_request' + shell: bash + run: | + set -euo pipefail + BASE="origin/${{ github.base_ref }}" + git fetch --no-tags --depth=1 origin "${{ github.base_ref }}" + CHANGED="$(git diff --name-only "$BASE"...HEAD)" + FAIL=0 + while IFS= read -r file; do + [ -z "$file" ] && continue + case "$file" in + components/*/*.md) + case "$file" in + *.test.md|*.preview.md) continue ;; + esac + dir="$(dirname "$file")" + name="$(basename "$file" .md)" + test_file="$dir/$name.test.md" + preview_file="$dir/$name.preview.md" + if ! echo "$CHANGED" | grep -Fxq "$test_file"; then + echo "Missing changed test spec: $test_file (required when $file changes)" >&2 + FAIL=1 + fi + if ! echo "$CHANGED" | grep -Fxq "$preview_file"; then + echo "Missing changed preview spec: $preview_file (required when $file changes)" >&2 + FAIL=1 + fi + ;; + esac + done <<< "$CHANGED" + [ "$FAIL" -eq 0 ] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa2acd0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +targets/*.lock.json diff --git a/README.md b/README.md index e69de29..03c8a97 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,302 @@ +# 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 + +```mermaid +flowchart LR + Specs["Spec files\n(.md)"] --> Compile["compile.ts\n(prompt)"] + Compile --> Agent["LLM Agent\n(compiler)"] + Agent --> Dist["dist/{target}/\n(generated code)"] + Agent --> Lock["lock file\n(.json)"] +``` + +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 + +### Prerequisites + +- [Bun](https://bun.sh/) 1.1+ installed (`bun --version`) + +### Install dependencies + +```bash +bun install +``` + +### Common commands + +```bash +# Lint all specs against the schema +bun run lint + +# Check what needs compiling +bun run compile status + +# Generate a prompt for a target +bun run compile 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 run compile lock --target go +``` + +## Repository structure + +``` +TUIKit/ + components/ Component specs, tests, and preview definitions + tokens/ Semantic design tokens (colors, icons, breakpoints) + targets/ Target language/framework definitions + docs/ Meta-schema and design foundations + scripts/ Compiler and linter CLIs + dist/ Compiled output per target (gitignored) +``` + +## 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 `docs/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` | +| `node` | TypeScript | Ink + React (Node.js) | `targets/node.md` | +| `bun` | TypeScript | OpenTUI + React (Bun) | `targets/bun.md` | +| `rust` | Rust | Ratatui + Crossterm | `targets/rust.md` | + +### Workflow + +```bash +# 1. See what's changed +bun run compile status + +# 2. Generate the compilation prompt +bun run compile 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 run compile lock --target go +``` + +### Multi-pass compilation + +A single compilation pass across the full component suite (17 components + +tokens + demo) is usually not enough to reach production quality. We've found +that **2–3 passes** produce notably better results: + +| Pass | Focus | Typical outcome | +| ---- | ----- | --------------- | +| **1st** | Initial generation | All components scaffold correctly, most tests pass, demo wires up. Expect rough edges — missing edge cases, incomplete keybindings, demo wiring bugs. | +| **2nd** | Review & fix | Agent reviews its own output against specs, fixes test failures, fills in missing behavior, improves demo interactivity. Test count typically grows 30–50%. | +| **3rd** | Polish | Catches subtle spec violations, improves accessibility, hardens demo `--snapshot` smoke tests. Diminishing returns after this point. | + +To run a follow-up pass, generate a new prompt and tell the agent to review +and complete its existing work: + +```bash +# Generate a fresh prompt (it sees the current dist/ state) +bun run compile prompt --target go + +# Feed to the agent with instructions like: +# "Review your existing implementation against the specs. +# Fix any test failures, fill in missing behavior, +# and ensure all --snapshot smoke tests pass." +``` + +Each pass is fast because the agent builds on its own prior output rather than +starting from scratch. The demo's `--list` and `--snapshot` flags make it easy +for the agent to self-verify between passes. + +### Custom output directory + +By default, compiled code goes to `dist/`. Override with `--out`: + +```bash +# Output to a separate repo or directory +bun run compile 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 `docs/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 run compile status` — your target will show up with all specs dirty +4. Run `bun run compile prompt --target {name}` and compile + +## Linting + +```bash +# Lint all specs +bun run lint + +# Lint a single component +bun run lint --component Select + +# Show fix suggestions +bun run lint --fix + +# See all rules +bun run lint --help +``` + +The linter checks: + +- Required frontmatter fields and valid values (zod schemas) +- 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 +- Broken internal markdown links + +Rule definitions live in `scripts/lint-rules.ts` — edit that file to add or +change rules, severities, and fix hints. + +## CI checks + +The GitHub Actions workflow (`.github/workflows/specs-ci.yml`) runs on every PR: + +1. **Spec lint** — `bun run lint` +2. **Compiler health** — `bun run compile status` for each target +3. **Prompt smoke test** — `bun run compile prompt` for each target +4. **No generated output committed** — ensures `dist/` is not tracked +5. **Changed-spec completeness** — if `{Name}.md` changes, matching `.test.md` and `.preview.md` must also change + +## 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. Lock files are gitignored; + a fresh clone starts with everything dirty. + +For TUI design foundations — color systems, typography, iconography, layout +grids, accessibility patterns, keybinding conventions, and buffer management — +see [`docs/foundations.md`](docs/foundations.md). diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..46dc876 --- /dev/null +++ b/bun.lock @@ -0,0 +1,191 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "dependencies": { + "chalk": "^5.6.2", + "fast-glob": "^3.3.3", + "gray-matter": "^4.0.3", + "remark": "^15.0.1", + "remark-frontmatter": "^5.0.0", + "zod": "^4.3.6", + }, + }, + }, + "packages": { + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + + "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], + + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], + + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + + "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + + "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + + "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="], + + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + + "fault": ["fault@2.0.1", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="], + + "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="], + + "is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + + "js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], + + "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], + + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], + + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], + + "mdast-util-frontmatter": ["mdast-util-frontmatter@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "escape-string-regexp": "^5.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-extension-frontmatter": "^2.0.0" } }, "sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA=="], + + "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], + + "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="], + + "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], + + "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], + + "micromark-extension-frontmatter": ["micromark-extension-frontmatter@2.0.0", "", { "dependencies": { "fault": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg=="], + + "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], + + "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], + + "micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], + + "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="], + + "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="], + + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="], + + "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="], + + "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="], + + "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="], + + "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="], + + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + + "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="], + + "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="], + + "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="], + + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + + "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="], + + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + + "remark": ["remark@15.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A=="], + + "remark-frontmatter": ["remark-frontmatter@5.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-frontmatter": "^2.0.0", "micromark-extension-frontmatter": "^2.0.0", "unified": "^11.0.0" } }, "sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ=="], + + "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], + + "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="], + + "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], + + "strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + + "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], + + "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], + + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + + "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], + + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], + + "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + } +} 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/Screen/Screen.md b/components/Screen/Screen.md new file mode 100644 index 0000000..93dd774 --- /dev/null +++ b/components/Screen/Screen.md @@ -0,0 +1,166 @@ +--- +kind: component +name: Screen +description: Full-height page shell that composes optional header/footer with a scrollable content region. +version: 2 +category: layout + +tokens: + colors: [selectionBackground] + icons: [] + +props: + header: + type: ReactNode + required: false + description: Optional top region, usually title/status content. + + children: + type: ReactNode + required: true + description: Main body content. + + footer: + type: ReactNode + required: false + description: Optional bottom region, often keyboard hints. + + onClose: + type: callback() → void + required: false + description: Fires when Escape is pressed. + + scrollable: + type: boolean + required: false + default: true + description: When true, wraps children in ScrollBox; when false, renders children directly. + + textSelection: + type: boolean + required: false + default: true + description: Enables text selection forwarding from the internal ScrollBox. + + scrollTo: + type: number + required: false + description: Imperative display-row offset forwarded to the internal ScrollBox. + + scrollKey: + type: string | number + required: false + description: Remount key for the internal ScrollBox; changing it resets internal scroll state. + +states: + initial: active + definitions: + active: + description: Screen is mounted and rendering header/content/footer. + transitions: + close: closed + closed: + description: onClose was fired from Escape. + terminal: true + emits: onClose + +keyboard: + escape: + action: Fire onClose when provided + note: No-op when onClose is not provided. + +accessibility: + role: region + properties: + aria-label: "Screen container" + announce: + on_mount: "Screen opened" + screen_reader_adaptations: + - when: screen reader detected + change: > + Selection overlay highlighting remains non-verbal and MUST NOT add + spoken noise; only textual header/content/footer output is announced. + +dependencies: + tokens: + - name: selectionBackground + kind: color + usage: "Selection highlight color pushed to selection overlay" + required: true + components: + - name: ScrollBox + usage: "Default scrollable content wrapper when scrollable is true" + required: false + dependents: [] +--- + +# Screen + +Screen is a page-level layout primitive for full-terminal flows. It stacks: + +1. Optional header +2. Main content region +3. Optional footer + +When `scrollable` is true (default), children are rendered through `ScrollBox` +to provide scrolling, screen-region selection callbacks, and clipboard integration. + +## Visual rules + +- The component MUST render in vertical document order: header, content, footer +- Header and footer MUST keep natural height (no forced expansion) +- Content region MUST consume remaining vertical space +- With `scrollable=true`, content MUST be wrapped by `ScrollBox` +- With `scrollable=false`, children MUST render directly without `ScrollBox` + +## Rendering example + +Given: + +```yaml +header: "Title" +children: ["Line 1", "Line 2"] +footer: "Esc to close" +``` + +``` +Title +Line 1 +Line 2 +Esc to close +``` + +## Behavior + +### Escape close + +- Pressing Escape MUST fire `onClose` when `onClose` is provided +- Pressing Escape MUST do nothing when `onClose` is omitted + +### Scroll wrapper behavior + +- `scrollable=true` MUST create an internal `ScrollBox` instance +- `scrollable=false` MUST skip creating `ScrollBox` +- `scrollTo` and `scrollKey` MUST only affect behavior when `scrollable=true` + +### Selection lifecycle + +- Selection region updates from the internal `ScrollBox` MUST be forwarded to the + selection overlay when `textSelection=true` +- When `textSelection=false`, Screen MUST NOT forward region updates +- When content identity changes and an existing selection is stale, Screen MUST + clear the active selection + +## Dependencies + +| Dependency | Kind | Usage | Required | +| --------------------- | --------- | -------------------------------------------------- | -------- | +| `selectionBackground` | color | Selection highlight color for overlay | Yes | +| `ScrollBox` | component | Default content viewport when `scrollable` is true | No | + +## Edge cases + +- If `onClose` is not provided, Escape MUST NOT throw or crash +- If `header` is omitted, content MUST render at the top without blank placeholder rows +- If `footer` is omitted, content MUST render through to the bottom without footer chrome +- If content is replaced with a new tree, stale selection coordinates MUST be cleared diff --git a/components/Screen/Screen.preview.md b/components/Screen/Screen.preview.md new file mode 100644 index 0000000..4970a12 --- /dev/null +++ b/components/Screen/Screen.preview.md @@ -0,0 +1,39 @@ +--- +kind: preview +component: Screen +version: 1 +--- + +## Basic + +```props +children: + - "10:21:03 Server starting on port 3000" + - "10:21:04 Connected to database" + - "10:21:05 Registered 14 API routes" +``` + +## With header and footer + +```props +header: + - "Screen — Screen.tsx" + - "Application Log (3 entries)" +children: + - "10:21:03 Server starting on port 3000" + - "10:21:04 Connected to database" + - "10:21:05 Registered 14 API routes" +footer: "↑↓ scroll · Esc back" +``` + +## Non-scrollable + +```props +scrollable: false +header: "Static Screen" +children: + - "Line 1" + - "Line 2" + - "Line 3" +footer: "Esc back" +``` diff --git a/components/Screen/Screen.test.md b/components/Screen/Screen.test.md new file mode 100644 index 0000000..8a7b680 --- /dev/null +++ b/components/Screen/Screen.test.md @@ -0,0 +1,107 @@ +--- +kind: test +component: Screen +version: 1 +--- + +# Screen Tests + +## renders children + +```props +children: "Content" +``` + +```expect +Content +``` + +## renders header and footer in order + +```props +header: "HEADER_MARKER" +children: "CONTENT_MARKER" +footer: "FOOTER_MARKER" +``` + +```expect +HEADER_MARKER +CONTENT_MARKER +FOOTER_MARKER +``` + +## omits header when not provided + +```props +children: "CONTENT_MARKER" +footer: "FOOTER_MARKER" +``` + +```expect +CONTENT_MARKER +FOOTER_MARKER +``` + +## scrollable=false renders children directly + +```props +scrollable: false +children: "Custom scroll content" +``` + +```expect +Custom scroll content +``` + +## escape calls onClose when provided + +```props +onClose: callback +children: "Content" +``` + +```input +escape +``` + +```state +after: closed +callback_fired: onClose +``` + +## escape is safe when onClose is omitted + +```props +children: "Content" +``` + +```input +escape +``` + +```expect +Content +``` + +## textSelection=false does not forward selection region + +```props +textSelection: false +children: "Selectable text" +``` + +```state +selection_forwarded: false +``` + +## changing children identity clears stale selection + +```props +children_before: ["Tab A row 1", "Tab A row 2"] +children_after: ["Tab B row 1", "Tab B row 2"] +``` + +```state +selection_before_change: present +selection_after_change: cleared +``` diff --git a/components/ScrollBox/ScrollBox.md b/components/ScrollBox/ScrollBox.md new file mode 100644 index 0000000..527e880 --- /dev/null +++ b/components/ScrollBox/ScrollBox.md @@ -0,0 +1,267 @@ +--- +kind: component +name: ScrollBox +description: Scrollable viewport container with keyboard/mouse navigation, optional virtualization, and selection callbacks. +version: 2 +category: layout + +tokens: + colors: [borderNeutral, selected] + icons: [] + +types: + ScrollState: + fields: + offset: { type: number, required: true, description: "Current scroll offset in display rows" } + maxOffset: { type: number, required: true, description: "Maximum reachable offset for current content/viewport" } + viewportHeight: { type: number, required: true, description: "Visible viewport height in rows" } + contentHeight: { type: number, required: true, description: "Total content height in rows" } + + ScreenRegion: + fields: + startRow: { type: number, required: true, description: "Screen-row origin of selection start" } + startCol: { type: number, required: true, description: "Screen-column origin of selection start" } + endRow: { type: number, required: true, description: "Screen-row origin of selection end" } + endCol: { type: number, required: true, description: "Screen-column origin of selection end" } + + ContentSelection: + fields: + startLine: { type: number, required: true, description: "Selection start line in content coordinates" } + startChar: { type: number, required: true, description: "Selection start column in content coordinates" } + endLine: { type: number, required: true, description: "Selection end line in content coordinates" } + endChar: { type: number, required: true, description: "Selection end column in content coordinates" } + +props: + children: + type: ReactNode + required: true + description: Scrollable content body. + + mouseScroll: + type: boolean + required: false + default: true + description: Enables wheel scrolling. + + keyboardScroll: + type: boolean + required: false + default: true + description: Enables keyboard scrolling shortcuts. + + showScrollbar: + type: boolean + required: false + default: true + description: Shows/hides vertical scrollbar track/thumb. + + textSelection: + type: boolean + required: false + default: true + description: Enables drag selection reporting. + + virtualized: + type: boolean + required: false + default: false + description: > + In virtualized mode, each direct child is treated as one display row and + only visible rows are rendered. + + onFocusLine: + type: callback(index: number) → void + required: false + description: Fires when focus index changes via keyboard/mouse. + + onHoverLine: + type: callback(index: number | null) → void + required: false + description: Fires when hovered row changes. + + onContentSelection: + type: callback(selection: ContentSelection | null) → void + required: false + description: Reports content-coordinate text selection. + + onScreenRegion: + type: callback(region: ScreenRegion | null) → void + required: false + description: Reports selection region mapped into absolute screen coordinates. + + onScroll: + type: callback(state: ScrollState) → void + required: false + description: Fires on initial mount and whenever scroll offset changes. + + scrollTo: + type: number + required: false + description: Imperative scroll target offset; clamped to valid range. + +states: + initial: idle + definitions: + idle: + description: Mounted and awaiting input events. + transitions: + scroll: scrolling + select: selecting + focus: focusing + scrolling: + description: Offset changes from keyboard, mouse wheel, or scrollTo. + transitions: + settle: idle + selecting: + description: Mouse drag selection in progress. + transitions: + commit_selection: idle + clear_selection: idle + focusing: + description: Focus index navigation mode. + transitions: + settle: idle + +keyboard: + "↑": + action: Scroll up one row OR move focused line up when focus mode is active + "↓": + action: Scroll down one row OR move focused line down when focus mode is active + pageup: + action: Scroll up by one viewport page + pagedown: + action: Scroll down by one viewport page + home: + action: Jump to top (offset 0) + end: + action: Jump to bottom (offset maxOffset) + j: + action: Vim down + same_as: "↓" + k: + action: Vim up + same_as: "↑" + ctrl+d: + action: Scroll down by half viewport + ctrl+u: + action: Scroll up by half viewport + g: + action: Jump to top + G: + action: Jump to bottom + +accessibility: + role: region + properties: + aria-label: "Scrollable content" + announce: + on_mount: "Scrollable region" + on_change: "Scroll position {offset} of {maxOffset}" + screen_reader_adaptations: + - when: screen reader detected + change: > + Scrollbar chrome SHOULD be treated as decorative; logical text content + remains primary output. + +dependencies: + tokens: + - name: borderNeutral + kind: color + usage: "Scrollbar track" + required: false + - name: selected + kind: color + usage: "Scrollbar thumb" + required: false + components: [] + dependents: + - name: Screen + usage: "Main content viewport in page layouts" +--- + +# ScrollBox + +ScrollBox provides a terminal viewport over potentially large content with support +for keyboard and mouse scrolling, focus navigation callbacks, and text selection +coordinate reporting. + +## Visual rules + +- Content MUST render in a constrained viewport and clip overflow +- When `showScrollbar=true` and content exceeds viewport, a 1-column scrollbar MUST render +- Scrollbar track MUST use `borderNeutral`; thumb MUST use `selected` +- When content fits viewport, scrollbar thumb MAY occupy full track height +- In `virtualized=true`, only the visible child slice MUST render + +## Rendering example + +Given: + +```yaml +showScrollbar: true +children: + - "Item 0" + - "Item 1" + - "Item 2" + - "Item 3" + - "Item 4" +viewportHeight: 3 +``` + +``` +Item 0 │ +Item 1 │ +Item 2 │ +``` + +After one down-scroll: + +``` +Item 1 │ +Item 2 │ +Item 3 │ +``` + +## Behavior + +### Scrolling + +- Arrow keys MUST move by one row when keyboard scrolling is enabled +- PageUp/PageDown MUST move by one viewport page +- Home/g MUST jump to top; End/G MUST jump to bottom +- `ctrl+d` MUST move down by half viewport; `ctrl+u` MUST move up by half viewport +- All scrolling MUST clamp to `[0, maxOffset]` + +### Focus mode + +- When `onFocusLine` is provided, directional keys MUST update focused line index +- Focus index MUST NOT go below first or above last item +- Focus movement SHOULD auto-scroll to keep focused item visible + +### Selection callbacks + +- Drag selection MUST call `onContentSelection` with content coordinates +- Drag selection MUST call `onScreenRegion` with absolute screen coordinates +- Clearing selection MUST emit `null` to both callbacks + +### Imperative scroll + +- `scrollTo` MUST move to requested offset when provided +- `scrollTo` values above max MUST clamp to maxOffset +- `onScroll` MUST emit initial state on mount and subsequent offset updates + +## Dependencies + +| Dependency | Kind | Usage | Required | +| -------------- | --------- | --------------------------- | -------- | +| `borderNeutral`| color | Scrollbar track | No | +| `selected` | color | Scrollbar thumb | No | +| `Screen` | component | Typical parent integration | No | + +## Edge cases + +- With zero children, component MUST render safely without crashes +- With `keyboardScroll=false`, vim and arrow key scrolling MUST be disabled +- When content shrinks, current offset MUST clamp to new max offset +- Virtualized mode MUST treat each direct child as one display row +- Non-virtualized mode MUST support multi-row wrapped content trees diff --git a/components/ScrollBox/ScrollBox.preview.md b/components/ScrollBox/ScrollBox.preview.md new file mode 100644 index 0000000..6204277 --- /dev/null +++ b/components/ScrollBox/ScrollBox.preview.md @@ -0,0 +1,67 @@ +--- +kind: preview +component: ScrollBox +version: 1 +--- + +## No scroll (content fits) + +```props +children: + - " ◎ 001 [INFO ] Server starting on port 3000" + - " ✓ 002 [OK ] Connected to database" + - " ◎ 003 [INFO ] Registered 14 API routes" +``` + +## Scrollable list + +```props +children: + - " ◎ 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" +``` + +## No scrollbar + +```props +showScrollbar: false +children: + - "Item 0" + - "Item 1" + - "Item 2" + - "Item 3" + - "Item 4" + - "Item 5" +``` + +## Focusable + +```props +onFocusLine: callback +children: + - "❯ 001 [INFO ] First item" + - " 002 [WARN ] Second item" + - " 003 [OK ] Third item" + - " 004 [INFO ] Fourth item" +``` + +## Hover + virtualized + +```props +virtualized: true +onFocusLine: callback +onHoverLine: callback +children: + - "❯ 001 [INFO ] Item one" + - " 002 [INFO ] Item two" + - " 003 [WARN ] Item three" + - " 004 [OK ] Item four" + - " 005 [INFO ] Item five" + - " 006 [ERROR] Item six" +``` diff --git a/components/ScrollBox/ScrollBox.test.md b/components/ScrollBox/ScrollBox.test.md new file mode 100644 index 0000000..9170737 --- /dev/null +++ b/components/ScrollBox/ScrollBox.test.md @@ -0,0 +1,214 @@ +--- +kind: test +component: ScrollBox +version: 1 +--- + +# ScrollBox Tests + +## renders children + +```props +children: + - "Item 1" + - "Item 2" + - "Item 3" +``` + +```expect +Item 1 +Item 2 +Item 3 +``` + +## renders in virtualized mode + +```props +virtualized: true +children: + - "Item 0" + - "Item 1" + - "Item 2" +``` + +```expect +Item 0 +Item 1 +Item 2 +``` + +## down arrow advances focus line + +```props +onFocusLine: callback +children: + - "Item 0" + - "Item 1" + - "Item 2" +``` + +```input +↓ +``` + +```state +callback_fired: onFocusLine +focus_index: 1 +``` + +## up arrow moves focus back + +```props +onFocusLine: callback +children: + - "Item 0" + - "Item 1" + - "Item 2" +``` + +```input +↓ ↓ ↑ +``` + +```state +focus_index: 1 +``` + +## boundary at bottom does not overflow focus index + +```props +onFocusLine: callback +children: + - "Item 0" + - "Item 1" +``` + +```input +↓ ↓ ↓ +``` + +```state +focus_index: 1 +``` + +## vim j and k scroll or focus + +```props +children: + - "Item 0" + - "Item 1" + - "Item 2" + - "Item 3" + - "Item 4" +``` + +```input +j j k +``` + +```state +offset_changed: true +``` + +## ctrl+d and ctrl+u perform half-page jumps + +```props +children: + - "Item 0" + - "Item 1" + - "Item 2" + - "Item 3" + - "Item 4" + - "Item 5" + - "Item 6" + - "Item 7" +``` + +```input +ctrl+d ctrl+u +``` + +```state +offset_changed: true +``` + +## g and G jump to top and bottom + +```props +children: + - "Item 0" + - "Item 1" + - "Item 2" + - "Item 3" + - "Item 4" +``` + +```input +G g +``` + +```state +offset_after_G: max +offset_after_g: 0 +``` + +## scrollTo is clamped and reported by onScroll + +```props +scrollTo: 999 +onScroll: callback +children: + - "Item 0" + - "Item 1" + - "Item 2" +``` + +```state +callback_fired: onScroll +offset_clamped: true +``` + +## selection emits screen region and clears with null + +```props +onScreenRegion: callback +onContentSelection: callback +children: + - "Line A" + - "Line B" + - "Line C" +``` + +```state +selection_emitted: true +selection_cleared_emits_null: true +``` + +## keyboardScroll=false disables keyboard scrolling + +```props +keyboardScroll: false +children: + - "Item 0" + - "Item 1" + - "Item 2" + - "Item 3" +``` + +```input +j ↓ pagedown +``` + +```state +offset_changed: false +``` + +## handles empty content safely + +```props +children: [] +``` + +```expect + +``` 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..d538f86 --- /dev/null +++ b/components/SelectAutocomplete/SelectAutocomplete.md @@ -0,0 +1,360 @@ +--- +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, iconPrompt] + +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 | +| `iconPrompt` | icon | Highlighted item indicator (❯) | Yes | +| `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..fbb0236 --- /dev/null +++ b/components/TextHeading/TextHeading.preview.md @@ -0,0 +1,18 @@ +--- +kind: preview +component: TextHeading +version: 1 +--- + +## Default + +```props +children: "Section Heading" +``` + +## Error + +```props +children: "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..a28d536 --- /dev/null +++ b/components/TextTitle/TextTitle.preview.md @@ -0,0 +1,18 @@ +--- +kind: preview +component: TextTitle +version: 1 +--- + +## Default + +```props +children: "Welcome to TUIkit" +``` + +## Error + +```props +children: "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/components/previews.md b/components/previews.md new file mode 100644 index 0000000..6cf556d --- /dev/null +++ b/components/previews.md @@ -0,0 +1,279 @@ +--- +kind: demo +name: TUIkit Preview +description: > + Full-screen TUI component preview app with a sidebar browser and + main preview panel. Uses the generated TUIkit components themselves + to build the interface. +version: 3 +--- + +## Architecture + +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 │ │ +│ │ TextTitle │ +│ ▸ Search... │ ────────────────────────────────────── │ +│ │ │ +│ Colors ◂ │ ## Default │ +│ Icons │ ┌──────────────────────────────────┐ │ +│ Breakpoints │ │ My Page Title │ │ +│ ────────────────── │ └──────────────────────────────────┘ │ +│ Dialog │ │ +│ HintBar │ ## Error variant │ +│ Input │ ┌──────────────────────────────────┐ │ +│ Link │ │ Error Title │ │ +│ Metric │ └──────────────────────────────────┘ │ +│ QrCode │ │ +│ Screen │ │ +│ ScrollBox │ │ +│ Select │ │ +│ SelectAutocompl. │ │ +│ TabBar │ │ +│ Table │ │ +│ TextHeading │ │ +│ TextSpinner │ │ +│ TextTitle │ │ +│ TimelineItem │ │ +│ │ │ +├──────────────────────┴──────────────────────────────────────────┤ +│ ↑↓ navigate · enter open · / search · q quit │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Sidebar (left panel) + +- **Width**: Fixed, ~24 columns. MUST NOT resize or collapse. +- **Header**: `"TUIkit Preview"` rendered with **TextTitle**. +- **Search input**: A text input at the top of the sidebar for fuzzy filtering. + Typing narrows the component list in real time. The search input only receives + keyboard events when the sidebar has focus. +- **Component list**: All components and tokens listed alphabetically. Tokens + appear first, separated from components by a dim horizontal rule. The + currently highlighted item is visually marked (e.g., `▸` prefix or + inverted colors). When a component is open in the main panel, show an + indicator (e.g., `◂`) next to its name. +- **Scrolling**: If the list exceeds the sidebar height, it MUST scroll to + keep the highlighted item visible. + +### Main panel (right panel) + +- **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 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 + +The app has exactly two focus states: **sidebar** and **preview**. + +### Sidebar focus (default) + +- `↑` / `↓` — Move highlight up/down in the component list. +- `Enter` — Open the highlighted component in the main panel and switch focus + to the preview. +- `/` or typing any character — Activate the search input and begin filtering. +- `Escape` (while searching) — Clear search and return to full list. +- `q` — Quit the app (restore main screen buffer and exit). + +### Preview focus + +- All keyboard events are forwarded to the active preview component (e.g., + arrow keys for Select, typing for Input, tab switching for TabBar). +- `Escape` — Unmount the component preview, clear the main panel, and return + focus to the sidebar. The sidebar selection stays on the same component. + +**Important**: While the preview has focus, the sidebar MUST NOT respond to +keyboard events. The sidebar remains visible but inert until `Escape` returns +focus to it. + +## Preview rendering + +Each component's preview screen is built from its `.preview.md` file: + +- Each `## heading` in the preview spec becomes a **TextHeading** label. +- Each `props` block is parsed and passed as **initial** props to a live + component instance rendered below the label. +- Variants render **top to bottom** in the main panel. +- For token previews (colors, icons, breakpoints), render values in a + readable grid or list format. + +### Interactivity examples + +- A **Select** preview MUST allow arrow-key navigation and item selection. +- An **Input** preview MUST accept typed text and display it live. +- A **TabBar** preview MUST allow switching between tabs. +- A **Dialog** preview MUST allow confirming or cancelling. +- A **TextSpinner** preview MUST animate its spinner frames. +- A **ScrollBox** preview MUST scroll its content on key press. + +Display-only components (TextTitle, TextHeading, Link, Metric, etc.) render +with the given props and MUST use live token values. + +## HintBar (always visible at bottom) + +The bottom row spans the full width and shows contextual keyboard hints: + +- **Sidebar focus**: `↑↓ navigate · enter open · / search · q quit` +- **Sidebar focus + searching**: `↑↓ navigate · enter open · esc clear · q quit` +- **Preview focus**: Component-specific hints plus `esc back · q quit`. + For example: `↑↓ navigate · enter select · esc back · q quit` for Select, + `type to input · esc back · q quit` for Input. + +## Styling + +- MUST use semantic color tokens — no hardcoded ANSI codes or hex values. +- The sidebar border uses `borderSecondary` token. +- The highlighted item in the sidebar uses `backgroundHighlight` + `textPrimary`. +- The main panel background is the default terminal background. +- Dim text (`textSecondary`) for the placeholder message and separator rules. + +## Verification checklist + +Before considering the demo complete, verify: + +1. App takes over the full terminal (alternate screen buffer). +2. Sidebar and main panel render side by side at startup. +3. All components and tokens appear in the sidebar list. +4. `↑` / `↓` moves the sidebar highlight; `Enter` opens the component. +5. Main panel shows all preview variants as live, interactive instances. +6. While previewing, the sidebar is visible but does not respond to keys. +7. `Escape` from preview returns focus to the sidebar; component unmounts. +8. Fuzzy search filters the sidebar list in real time. +9. HintBar updates to match the current focus state. +10. `q` quits the app cleanly (restores terminal state). +11. All styling uses semantic color tokens. +12. All CLI subcommands below work correctly. + +## CLI interface + +The demo MUST support the following command-line flags in addition to the +default interactive TUI mode. These enable automated testing by LLM agents +and CI pipelines without requiring interactive PTY access. + +### Flags + +``` +(no flags) Launch full interactive TUI (default) +--list Print all component/token names, one per line, then exit +--component Open directly into that component's preview (skip sidebar) +--component --variant Render only the named variant +--component --snapshot Render one frame of all variants to stdout and exit +--component --variant --snapshot Render one frame of a single variant and exit +``` + +### `--list` + +Print every available component and token name to stdout, one per line, +sorted alphabetically (tokens first, then components). Exit with code 0. + +``` +breakpoints +colors +icons +Dialog +HintBar +Input +... +``` + +This lets agents discover what's available without launching the TUI. + +### `--component ` + +Skip the sidebar and open directly into the named component's preview +screen. The component renders in full-screen with all its variants, fully +interactive. `Escape` or `q` exits the app (no sidebar to return to). + +The `` MUST match exactly (case-sensitive) one of the names from `--list`. +If the name is not found, print an error message and exit with code 1. + +### `--variant ` + +Requires `--component`. Renders only the named variant (matching the +`## heading` from the `.preview.md` file). If the variant name is not +found, print an error to stderr and exit with code 1. + +### `--snapshot` + +Requires `--component`. Renders one frame of the component preview to +stdout and exits immediately with code 0. Does NOT enter the alternate +screen buffer or start the interactive event loop. The output is the +exact same rendered text that the TUI would display — same code path, +same token resolution, same layout — just captured as a single frame. + +This is the primary mechanism for automated testing: an agent can run +`--component Select --snapshot` and inspect the output to verify correct +rendering without needing to interact with a TUI. + +When combined with `--variant`, only that variant's frame is rendered. + +### Examples + +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/docs/foundations.md b/docs/foundations.md new file mode 100644 index 0000000..84c9ede --- /dev/null +++ b/docs/foundations.md @@ -0,0 +1,686 @@ +# Foundations + +This document captures the design principles and best practices for building command-line interfaces. + +| Section | What it covers | +| --------------------------------------------- | ---------------------------------------------------------- | +| [Web vs CLI](#web-vs-cli--a-different-medium) | Mental model shift for web developers | +| [Color](#color) | Semantic tokens, degradation, palette generation, contrast | +| [Typography](#typography) | Text styles, Unicode width, whitespace | +| [Icons](#icons) | Choosing icons, accessibility, ASCII fallbacks | +| [Illustrations](#illustrations) | ASCII art, braille patterns, ANSI illustrations | +| [Animations](#animations) | Color vs frame animation, performance, SSH | +| [Layout](#layout) | Alignment, responsive width, stability, borders | +| [TUI](#tui) | When to use TUI, feedback, interactivity | +| [Accessibility](#accessibility) | Screen readers, keyboard navigation, contrast | +| [Help](#help) | `--help` structure, discoverability, hint bars | +| [Keybinds](#keybinds) | Conventions, Ctrl+C handling, modifier keys | +| [Flags & Commands](#flags--commands) | Naming, output, errors, composability | +| [Prompting](#prompting-and-user-interruption) | Minimize interruptions, opinionated defaults, autopilot | +| [Buffer](#buffer) | Main vs alt screen, raw/cooked mode, hybrid rendering | +| [Environment](#environment) | TTY detection, keyboard protocols, CI, config precedence | + +## Intro + +A CLI is a conversation. The user types, the program responds. Every design decision should reduce friction in that exchange: + +- **Conversation-first**: CLIs are text-based UI optimized for conversational interactions. +- **Respect the terminal**: Don't fight the user's environment. +- **Degrade gracefully**: Not every terminal supports every feature. Always have a fallback. +- **Show, don't block**: Prefer streaming output over waiting in silence. + +Running a program is rarely a single invocation. Users iterate, they type, get an error, adjust, try again. This trial-and-error loop is a conversation, and your program should be a good conversational partner. + +--- + +## Web vs CLI: A different medium + +If you're coming from web or mobile development, the terminal is a fundamentally different canvas. Understanding what's absent helps you design within the constraints rather than fighting them. + +### What the terminal doesn't have + +| Web concept | Terminal reality | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Pixel-level layout** | Fixed-width character grid. Every glyph occupies 1 or 2 columns. No sub-pixel positioning. | +| **CSS / Flexbox** | Ink provides a flexbox subset, but there's no cascade, no `z-index`, no overlapping layers. | +| **Scrollable views** | No native scroll containers. The terminal scrolls the entire buffer, or you build your own. | +| **Mouse interaction** | Optional and inconsistent. Keyboard is the primary input. | +| **Rich media** | No images (unless Kitty/iTerm2 inline images). No video. No audio. | +| **Fonts & sizing** | One font, one size, decided by the user. You can't change it. | +| **Hover states** | Don't exist. Discovery must happen through other means (hint bars, help text). | +| **Opacity / layers** | No transparency, no overlays. Everything occupies the same plane. | +| **Responsive units** | No `rem`, `vh`. You get column count, row count, and percentage widths (Ink supports `%`). Responsive design means measuring terminal width and adapting layout at breakpoints. | +| **Animation** | No CSS transitions. Frame-by-frame updates to characters, foreground and background colors. Use sparingly — redraws are expensive and animation is generally inaccessible. | + +### What the terminal does better + +The constraints aren't just limitations — they create advantages: + +- **Instant alignment**: Monospace means columns line up without effort. Tables are natural. +- **Universal accessibility**: A character grid is inherently more screen-reader friendly than a visual layout. +- **Zero latency UI**: No network round-trips, no rendering pipeline. Keypress to output in milliseconds. +- **Composability**: Pipes, redirects, and scripting let users combine your tool with others. Web UIs are islands. +- **Information density**: More content per screen than most GUIs. Power users love this. +- **Keyboard-first**: No mouse-keyboard mode switching. Every action is a keypress away. + +### Mental model shift + +Stop thinking in pixels and DOM nodes. Think in: + +- **Characters and columns**: Your unit of measurement. The terminal is a grid of fixed-width cells, each holding one character plus metadata (foreground color, background color, style attributes like bold or underline). +- **Lines and vertical flow**: Content stacks top-to-bottom +- **Bold and color**: Your main visual hierarchy tools (see Typography for why we avoid dim) +- **Escape sequences**: All styling and cursor movement happens through special byte sequences. Your framework handles this, but understanding it helps debug issues. +- **Keyboard events**: Your only reliable input. + +The best CLI UIs feel fast, dense, and predictable. They don't try to be web apps in a terminal. + +> For an interactive deep-dive into how the grid model, escape sequences, and keyboard input work, see [How Terminals Work](https://how-terminals-work.vercel.app/). + +--- + +## Color + +Color in the terminal is functional, not decorative. It directs attention, encodes status, and groups related information. + +### Use color semantically + +Every color choice should answer: _what does this color mean?_ If you can't answer, you probably don't need color. + +| Purpose | Example | +| --------- | ------------------------------------- | +| Status | Green for success, red for error | +| Hierarchy | Bright for primary, dim for secondary | +| Grouping | Same color for related items | + +Note that brand colors are unpredictable when using the user's terminal theme, you can't guarantee how your palette will look. Your branding should live in illustrations, icons, and overall tone of voice rather than relying on specific color values. + +Avoid using color as the _only_ signal. Pair it with text, icons, or position, this helps users with color blindness and monochrome terminals. + +**How we do it:** The `useColors()` hook provides semantic color tokens (`textPrimary`, `statusError`, `diffTextAdditions`, etc.) that automatically adapt to the active theme. Components destructure only the tokens they need. + +### Color depth and degradation + +Terminals support different color depths. Design for the lowest common denominator and enhance upward: + +| Depth | Colors | Support | +| ---------- | ---------------- | ---------------------------------------- | +| No color | 0 | Piped output, `NO_COLOR` | +| Basic | 16 (ANSI) | Nearly universal | +| 256-color | 256 | Most modern terminals | +| True color | 16M (24-bit RGB) | iTerm2, Ghostty, Kitty, Windows Terminal | + +Always respect `NO_COLOR` (https://no-color.org) and `FORCE_COLOR` environment variables. + +**How we do it:** The color engine (`colorEngine.ts`) handles graceful degradation automatically — it detects the terminal's color capability and falls back through truecolor → 256-color → 16-color → no color, with hand-designed ANSI fallback tokens at each level. Components never need to worry about color support; they just use semantic tokens and the engine handles the rest. + +### The palette generation problem + +The terminal's 256-color palette seems like a good middle ground — more expressive than 16 ANSI colors, less overhead than truecolor. But the default palette clashes with custom base16 themes, has poor readability in dark shades, and produces inconsistent perceived brightness across hues. + +The solution, [proposed by the Ghostty team](https://gist.github.com/jake-stewart/0a8ea46159a7da2c808e5be2177e1783), is to **generate the extended palette from the user's base16 colors** using perceptually uniform interpolation in CIELAB color space. This ensures: + +- Colors stay harmonious with the user's chosen theme +- Shade steps have consistent perceived brightness across hues +- Light/dark theme switching works without per-app configuration + +**How we do it:** Our color system (`tokens/colors.ts`) was directly inspired by this approach. We use the [Rampa SDK](https://github.com/basiclines/rampa-studio) to build perceptually uniform color ramps from the terminal's actual ANSI palette using CIELAB interpolation. Each ANSI color becomes a 2D ramp of saturation and lightness steps, and a neutral ramp provides a gradient from background to foreground. Semantic tokens are then picked from specific positions on these ramps. + +By default, the color system queries the terminal's real colors and feeds them into the ramp builder. Named themes provide static palettes. Both paths flow through the same interpolation, so contrast is perceptually correct regardless of the user's terminal. + +### Reading the user's palette + +To adapt to the user's terminal theme, we query the terminal's actual colors via OSC escape sequences (see the terminal glossary). However, not all terminals respond reliably — some return stale defaults, some swallow the query entirely, and multiplexers like tmux may interfere. When the query fails or returns invalid data, the color engine falls back to hand-designed ANSI ramps that render correctly at any color depth. See `colorEngine.ts` for the full degradation logic. + +### Contrast + +Text must be readable against both light and dark backgrounds. Avoid hardcoding specific colors — use semantic tokens that adapt to the theme. Test your output with at least one light and one dark theme. + +We validate contrast across 60+ real terminal themes (loaded from Ghostty's builtin palettes) using APCA (Accessible Perceptual Contrast Algorithm) with tiered thresholds: body text requires Lc ≥ 30, decorative elements Lc ≥ 15. + +--- + +## Typography + +Terminal typography is constrained: monospace fonts, fixed-width grids, limited styling. These constraints are also strengths — alignment is trivial, tables are natural, and spacing is precise. + +### The Unicode width problem + +Terminal typography maps Unicode into a fixed-width grid. Characters occupy one or two cells, combining marks overlay previous characters, and emoji sequences collapse into single glyphs. When terminals and applications disagree on width, layout breaks. + +This is not a solved problem — terminal support varies dramatically. Avoid complex Unicode sequences (emoji ZWJ, exotic scripts) in critical UI elements. Stick to well-supported characters from common scripts and established icon sets. + +### Text styles + +Terminals support a small set of text decorations. Use them sparingly: + +| Style | ANSI | Use for | +| ------------- | ----- | ---------------------------------------------------------------------------------------------------------------------- | +| **Bold** | SGR 1 | Titles, headings | +| _Dim_ | SGR 2 | Inconsistent across terminals, only provides two levels (normal/dim). Use semantic color tokens for hierarchy instead. | +| _Italic_ | SGR 3 | Paths, metadata (limited support) | +| Underline | SGR 4 | Links (in supporting terminals) | +| Strikethrough | SGR 9 | Avoid (poor support) | + +Bold is useful for titles and headings. Italic support varies — never rely on it alone. For text hierarchy beyond bold, use the color system's multiple lightness levels (`textPrimary`, `textSecondary`, `textTertiary`) rather than dim, which is unreliable and limits you to only two depths. + +**How we do it:** TUIkit provides `TextTitle` (bold, primary color) and `TextHeading` (bold, secondary color) to establish a two-level heading hierarchy. Both support a `type="error"` variant that switches to the error status color for context-aware emphasis. + +### Whitespace + +Use blank lines to separate sections. Use indentation to show hierarchy. Consistent spacing makes dense output scannable: + +``` +✓ 3 files changed + + src/auth.ts +12 −3 + src/config.ts +4 −1 + test/auth.test.ts +28 + +No issues found. +``` + +**How we do it:** Components use Ink's `marginBottom`, `marginTop`, and `paddingX` for consistent spacing. Key-value layouts use `.padEnd(n)` for column alignment. The TUIkit Table component handles column alignment and text wrapping automatically — it calculates column widths from the data, pads cells, and wraps on word boundaries, so you get aligned output without manual spacing. + +--- + +## Icons + +Icons in the terminal are Unicode characters. They add visual cues and reduce cognitive load — but only when used carefully. + +### Choosing icons + +- **Use widely supported Unicode** — Stick to characters in common monospace fonts. Avoid Nerd Fonts and emoji as primary icons (they have inconsistent width and rendering). +- **Prefer established conventions** — `✓` for success, `✗` for failure, `●` for active, `→` for navigation. Users already know these. +- **Keep icons small** — Single-character icons work best. Multi-character symbols can misalign in tables and columns. + +### Meaningful vs decorative + +Every icon is either _meaningful_ (conveys information) or _decorative_ (purely visual). This distinction matters for accessibility: + +- **Meaningful icons** need a text alternative (label) for screen readers +- **Decorative icons** should be hidden from screen readers + +**How we do it:** TUIkit Icon components reinforce color with meaning by default. Each icon bundles a glyph, a semantic color, and a text label for screen readers — so `IconSuccess` always carries the success color and the label "Success", and `IconError` always carries the error color and "Error". Icons can be flagged as decorative to hide them from assistive technology. This ensures every status indicator communicates through color, shape, _and_ text simultaneously. + +### Internationalization + +Unicode icon rendering varies across operating systems and terminal emulators. Test your icons on macOS, Linux, and Windows. Some characters that render perfectly on macOS may show as boxes or wrong-width glyphs elsewhere. When in doubt, provide ASCII-safe fallbacks: + +``` +✓ → * ✗ → x ● → @ → → > • → - +``` + +--- + +## Illustrations + +Terminals can't display images natively (with rare exceptions like Kitty and iTerm2 inline images). When you need diagrams, charts, or visual explanations in terminal output, you need to render them as text. + +### ASCII art and diagrams + +Simple diagrams using box-drawing characters (`─`, `│`, `┌`, `┐`, `└`, `┘`, `├`, `┤`) and ASCII art can communicate structure that paragraphs of text cannot — architecture diagrams, flow charts, directory trees, relationship maps. + +``` +┌──────────┐ ┌──────────┐ ┌──────────┐ +│ Input │────▶│ Process │────▶│ Output │ +└──────────┘ └──────────┘ └──────────┘ +``` + +Keep diagrams simple and monochrome. Box-drawing characters are widely supported, but test across platforms — some terminals and fonts render them with gaps. + +### Braille patterns + +Braille characters (U+2800–U+28FF) are the highest-resolution graphics available in text mode. Each character represents a 2×4 grid of dots, giving you 8 individually controllable "pixels" per cell. They're ideal for sparklines, small charts, and anywhere you need finer detail than block elements provide: + +``` +Sparklines: ⣀⣤⣶⣿⣿⣷⣦ +Progress: ████████░░░░ 67% +``` + +### ANSI illustrations + +Avoid rendering photos or 3D scenes in the terminal — the fidelity is never worth the complexity. Instead, consider building purpose-made ANSI illustrations as a branding element. ASCII characters have _shape_, not just density — a `T` is top-heavy, an `L` is bottom-left-heavy, a `/` is diagonal. Tools that match character shapes to contours (rather than just mapping brightness to density) produce dramatically sharper results, making hand-crafted terminal art a viable way to add personality to your CLI. + +**How we do it:** We use [ASCII Motion](https://ascii-motion.app/) by [@cameronfoxly](https://github.com/cameronfoxly) to create and animate ASCII illustrations for the CLI. It provides a visual editor for designing text-based art and animations that render consistently in the terminal. + +--- + +## Animations + +Animations in the terminal must be deliberate. Unlike the web, every frame rewrite risks visual flicker, layout shifts, and wasted bandwidth over slow connections. The key insight by: **color is continuous, but position is discrete**. Characters snap to fixed grid positions — movement always appears as jumps from one cell to the next. But with truecolor you have millions of hues to interpolate between smoothly. Use color for smoothness and energy; use character changes for structure and state. + +### Color-only animation + +The safest animation technique is changing color while keeping text and layout identical. No characters move, no widths change — only the appearance shifts. This eliminates flicker and layout reflow entirely. + +**How we do it:** `TextSpinner` includes a shimmer effect that sweeps a highlight color across text character-by-character without altering the text content — the string stays identical, only per-character color changes. Shimmer variants (`brand`, `info`, `selected`, `placeholder`) use semantic color pairs so they adapt to the active theme. Speed adapts to context: faster in alt-screen where the UI has dedicated attention, slower inline where it competes with reading. + +### Frame-based animation + +When you must animate content — spinners, progress indicators — keep the visual footprint constant. Every frame must occupy the same number of cells to prevent layout shifts. + +**How we do it:** `TextSpinner` cycles through single-width Unicode characters (e.g., `∙ ◉ ◎`) where every frame occupies the same cell width. Alt-screen spinners use more frames and faster intervals; inline spinners are simpler and slower. + +### Accessibility + +Animations must be invisible to screen readers. A screen reader announcing every spinner frame is unusable. + +**How we do it:** When `useIsScreenReaderEnabled()` returns true, all animation is disabled: + +- `TextSpinner` returns static text instead of cycling frames + +### Performance + +At 60 FPS you have ~16ms per frame — that's your entire budget for logic, rendering, and terminal I/O. Not all terminals are equal: GPU-accelerated terminals (Kitty, Alacritty, Ghostty) handle this easily; legacy terminals and SSH connections struggle. Design for degradation — a smooth 30 FPS experience beats a stuttery 60. + +Use a **two-tier animation strategy**: idle mode (event-driven, 0 FPS, CPU at rest) when nothing animates, and active mode (target frame rate) only when animations are in progress. Batch all output into a single write per frame, only update cells that changed (dirty rectangles), and skip identical frames entirely. + +**SSH and remote sessions:** Every animation frame is transmitted over the network. A busy spinner running for minutes can generate gigabytes of escape sequence data over SSH. On slow connections or loaded servers, this degrades the session for all users. Remote sessions can be detected via `SSH_TTY`, `SSH_CONNECTION`, and `SSH_CLIENT` environment variables — we already do this with `isRemoteTerminal()` in `terminalFeatures.ts`, using it to gate features like clipboard access. + +### Guidelines + +- **Prefer color animation** over content animation — zero layout risk +- **Fixed cell width** for any frame-based animation — every frame must be the same width +- **Respect reduced motion** — disable animation for screen readers and when accessibility requires it +- **Slower inline, faster fullscreen** — inline animation competes with reading; fullscreen has dedicated attention +- **Configurable loop count** — not everything should animate forever; `TextSpinner` supports single, N, or infinite loops +- **Degrade for slow environments** — reduce animation complexity on slow connections rather than dropping frames + +--- + +## Layout + +In a monospace grid, alignment is readability. Misaligned content forces the eye to scan character-by-character. Aligned content lets users absorb structure at a glance. + +### Column alignment + +When displaying key-value pairs, labels, or any repeating structure, align columns consistently. This is the single most impactful readability technique in terminal output. + +``` +Bad: Good: +Type: stdio Type: stdio +Status: Connected Status: Connected +Error: Connection refused Error: Connection refused +``` + +**How we do it:** Labels use `.padEnd(n)` with a width based on the longest label in the group. For dynamic content where label width varies (like server lists), the maximum width is computed from the data and all labels padded to match. For numeric data like line numbers, `.padStart()` right-aligns within a gutter width. + +### Vertical flow and grouping + +Content flows top-to-bottom. Group related items together and separate groups with whitespace. Use `marginBottom` / `marginTop` for section breaks, not empty text elements. Nest related content inside bordered containers with `paddingX` for breathing room. + +### Horizontal layout + +Use row layouts sparingly — they only work well when elements are short. For wider terminals, switch from stacked to side-by-side using breakpoint hooks. For hint bars and status lines, space-between justification pushes content to edges. + +### Borders as containers + +Use borders to visually isolate distinct content areas. They're the terminal equivalent of cards or panels. Reserve round borders for primary containers and single-line borders for nested/secondary content. Always include horizontal padding inside bordered boxes — text touching the border is hard to read. + +Bear in mind that copying text from bordered elements will also copy the border's Unicode characters. Use borders sparingly if the user might need to copy values from within — or use them only in the alt screen buffer where you control the selection context. + +### Responsive width + +Terminal output must adapt to the available width. Don't assume 80 or 120 columns — many users split terminals, use small windows, or work on laptops. When longer output is needed, wrap gracefully rather than truncating. + +**How we do it:** `useBreakpoint()` defines three width tiers — compact (< 80), narrow (80–119), wide (≥ 120) — so components can switch between stacked and side-by-side layouts at each tier. The TUIkit Table component auto-sizes columns to fit the terminal, shrinks proportionally when space is tight, and wraps on word boundaries rather than truncating. The TabBar ensures the active tab is always visible, even when there are more tabs than the terminal can display. + +### Layout stability + +Layout shifts — content jumping when elements appear, disappear, or resize — are the terminal equivalent of [Cumulative Layout Shift](https://web.dev/cls/) on the web. They disorient users and make content impossible to read during updates. + +The core principle: **reserve space before you need it.** + +**Fixed-width prefixes.** The timeline uses a constant 4-character prefix column. Status icons change (spinner → checkmark → error) but always occupy the same width. Content to the right never shifts. + +**Explicit container widths.** When content changes dynamically, compute and set explicit widths instead of relying on percentage-based or flex auto-sizing. This prevents reflow when content length changes between renders. + +**Batched state transitions.** When removing one element and adding another (e.g., replacing a loading spinner with a result), do both in the same render cycle. If they happen in separate frames, users see a flash of collapsed layout. + +**Footer pinning.** Interactive UI areas (input fields, hint bars) must never be pushed off-screen by growing content. Use flex-shrink on fixed elements and allow only the scrollable content area to compress. + +### Indentation and hierarchy + +Use padding-left for nesting depth. Each level should indent by 1–2 characters — more than that wastes horizontal space. For tree-like structures, use connectors (`└`, `├`) as decorative elements with indentation to show parent-child relationships. + +--- + +## TUI + +A TUI (Terminal User Interface) is any interface that goes beyond simple line-by-line output — selections, progress indicators, interactive forms, live updates. Use TUI patterns when they reduce friction; avoid them when plain text would suffice. + +### When to use TUI vs plain output + +| Use TUI when... | Use plain text when... | +| ----------------------------------- | ----------------------------------- | +| User must choose from options | Output is informational only | +| Progress needs to update in place | A single status line suffices | +| Data is best explored interactively | Data should be pipeable / greppable | +| Context helps decision-making | User already knows what they want | + +### Feedback and progress + +Never leave the user staring at a blank screen. **Responsive is more important than fast** — print something within 100ms. If you're making a network request, say so before you start, not after. + +- **Spinners** for indeterminate progress (something is happening) +- **Progress bars** for determinate progress (X of Y complete) +- **Streaming output** for operations producing incremental results +- **Status lines** for multi-phase operations (Phase 1/3: Compiling...) + +If stdout is not an interactive terminal, don't display any animations — spinners become Christmas trees in CI logs. + +When state changes, tell the user what happened. Don't just silently succeed — confirm the new state so they can build a mental model of the system. + +**How we do it:** `TextSpinner` combines a rotating icon with a shimmer text effect (see Animations). Beyond visual spinners, `useTerminalProgress` emits OSC 9;4 sequences to show native progress indicators in terminal title bars (Windows Terminal, iTerm2) — a subtle enhancement that helps when the terminal is backgrounded. + +### Interactivity + +Interactive elements should feel immediate: + +- Respond to every keypress even if just to show the input was received +- Highlight the focused element clearly +- Show available actions (hint bars, key legends) +- Allow escape/cancel from any interactive state +- **Let the user escape** — make it obvious how to get out +- **Make it recoverable** — if the program fails mid-operation, the user should be able to hit ↑ Enter and pick up where they left off + +--- + +## Accessibility + +Accessibility in the terminal means supporting screen readers, keyboard-only navigation, and users with visual impairments. These aren't edge cases — they're design constraints that improve the experience for everyone. + +### Screen readers + +Terminal screen readers (VoiceOver, NVDA, JAWS) read text content linearly. Design with this in mind: + +- **Provide text alternatives** for visual indicators (icons, colors, progress bars) +- **Hide decorative content** that would be noise (ASCII art, box-drawing characters, ornamental icons) +- **Use semantic structure** — screen readers can't see your visual hierarchy, so make it textual + +**How we do it:** Components check `useIsScreenReaderEnabled()` and adapt their output. Decorative content (ASCII art mascots, ornamental icons) is hidden entirely. Table borders switch to "none" to reduce noise. Visual indicators get text alternatives — for example, a checkmark icon becomes the text " (current)" for screen readers. + +### Keyboard navigation + +All interactive elements must be keyboard accessible. This is usually free in terminals, but TUI components need explicit handling: + +- **Focus order** should follow visual order (top-to-bottom, left-to-right) +- **Arrow keys** for navigation within a component +- **Enter** to confirm/select +- **Escape** to cancel/dismiss +- **Tab** for moving between components (when applicable) + +**How we do it:** Interactive components like `SelectInput` support multiple navigation paradigms — arrow keys, vim-style `j`/`k`, Emacs-style `Ctrl+N`/`Ctrl+P`, and direct number keys `1`–`9` for quick selection — so users can navigate with whatever muscle memory they bring. + +### Contrast and legibility + +- Ensure text is readable in both light and dark themes +- Test with high-contrast terminal themes +- See Color section for semantic token approach and APCA validation + +--- + +## Help + +Help is how users discover what your CLI can do. It should be immediate, contextual, and scannable. + +### `--help` output + +Every command and subcommand should support `--help`. Structure it as: usage pattern → one-line summary → longer description → arguments → options → examples → learn more. + +Commands should have both a **one-line summary** (what it does) and a **multi-line description** (the most critically important information a user needs). The summary appears in parent command listings; the description appears in `--help` output for that specific command. + +### Principles + +- **Lead with examples** — Users reach for examples before anything else. Show common use cases first, with real output when it helps. +- **Lead with usage** — Show the syntax pattern at the top. +- **One-line summary + description** — Every command gets a short summary for listings and a longer description for its own `--help`. Every flag gets a single-line explanation. +- **Display common flags first** — Don't bury the most-used options at the bottom. +- **Group logically** — Separate commands, options, and examples. +- **Align columns** — Use consistent indentation for scanability. +- **Concise by default, extensive on demand** — When run without arguments, show a brief description, a couple of examples, and a pointer to `--help`. Save the full listing for `--help`. + +### Discoverability + +CLIs don't have the luxury of visible menus. Make functionality discoverable through other means: + +- **Suggest next commands** after operations complete — like `git status` suggesting `git add` and `git commit`. +- **Suggest corrections** when input is wrong — ask if they want to run the corrected version, but don't force it. +- **Provide a support path** — a URL or GitHub link in top-level help text for feedback and issues. +- **Link to web docs** from terminal help, especially for complex topics with more detail online. + +### Contextual help + +Beyond `--help`, surface help where users need it: + +- **Error messages** that suggest the fix (see Commands > Error messages) +- **Hint bars** in interactive UIs showing available keybinds +- **Progressive disclosure** — `--help` should show concise, essential information. `--verbose` should increase detail during execution. Both are progressive disclosure but serve different moments. + +**How we do it:** The `HintBar` component auto-formats key names (arrow keys → Unicode symbols, "esc" → "Esc") and renders them in a consistent style. Every interactive component (pickers, selects, dialogs) includes a HintBar, so users always know their available actions. + +--- + +## Keybinds + +Keyboard shortcuts are the primary interaction model in terminals. They must be discoverable, consistent, and unsurprising. + +### Conventions + +Follow established terminal conventions. Users have decades of muscle memory: + +| Key | Convention | +| --------- | -------------------------- | +| `Ctrl+C` | Interrupt / cancel | +| `Ctrl+D` | End of input / exit | +| `Ctrl+L` | Clear screen | +| `Ctrl+Z` | Suspend (Unix) | +| `↑` / `↓` | Navigate lists, history | +| `Enter` | Confirm / submit | +| `Escape` | Cancel / dismiss / go back | +| `Tab` | Autocomplete / next field | + +Never override `Ctrl+C`. It is the user's emergency exit. When pressed, exit as soon as possible — say something immediately, before starting cleanup. If cleanup might take time, a second `Ctrl+C` should skip it. + +**How we do it:** `Ctrl+C` uses a progressive exit strategy — each press cancels the most specific active operation before escalating: first cancel running shell executions, then close open dialogs, then abort the agent, then clear the input field, and finally shut down the application. `Ctrl+Z` properly suspends and resumes, restoring alt screen and Kitty protocol state on resume. + +### Discoverability + +Keybinds are useless if users don't know they exist: + +- Show a **hint bar** at the bottom of interactive views with key actions +- List shortcuts in `--help` when relevant +- Use **consistent keybinds** across all interactive components — don't make `Enter` confirm in one place and `Space` confirm in another + +**How we do it:** The `useInput` hook maps `Ctrl+N`/`Ctrl+P` as synonyms for down/up arrows, supporting both standard and Emacs navigation conventions without requiring user configuration. + +Not all modifier key combinations work across terminals — `Alt+` is inconsistent on macOS, `Shift+` requires Kitty protocol, and function keys vary. Stick to simple keybinds; if you need modifiers, require only `Ctrl` and provide alternatives. + +--- + +## Flags & Commands + +### Flag naming + +- **Use `--long-names`** for clarity — have full-length versions of all flags +- **Only use single-letter flags for common flags** — don't pollute the short-flag namespace +- **Use kebab-case**: `--no-color` not `--noColor` +- **Be consistent** across commands and **use standard names** when they exist + +Common standard flags: + +| Flag | Convention | +| ----------------- | ------------------------------------------------------------------------- | +| `-h`, `--help` | Show help | +| `-v`, `--verbose` | More output (note: `-v` is `--version` in some CLIs, including `copilot`) | +| `-q`, `--quiet` | Less output | +| `-f`, `--force` | Skip confirmations | +| `-n`, `--dry-run` | Show what would happen without doing it | +| `--json` | Machine-readable JSON output | +| `--no-color` | Disable color | + +### Flag values + +- `--flag` enables, `--no-flag` disables — don't require `=true` or `=false` +- Support both `--format=json` and `--format json` +- Validate early and fail with a clear message listing valid options +- Default to the safer or more common option + +### Dangerous operations + +Match confirmation to severity: mild operations may not need confirmation, moderate ones prompt `y/yes` interactively (require `--force` in scripts), severe ones require typing the resource name. + +### Command structure + +Follow `tool [subcommand] [flags] [args]`. Use `noun verb` for multi-level subcommands (`gh pr list`). Use verbs for actions (`create`, `delete`, `list`), nouns for resource groups. Be consistent — same verbs across all resource types. Avoid abbreviations and don't allow implicit prefix matching. + +### Output + +- **Human-friendly by default** — Pretty-printed, colored, readable +- **Machine-friendly on demand** — `--json` for structured output, `--plain` for strict line-based output +- **Respect pipes** — Detect when stdout is not a TTY and simplify output. Send data to stdout, messages to stderr. +- **Exit codes** — `0` for success, non-zero for failure +- **Don't be silent on success** — Confirm what happened, but keep it brief + +### Error messages + +Errors are documentation. A good error message guides the user toward a fix. + +- **Catch errors and rewrite them for humans** — don't surface raw library errors +- **Include the fix, not just the problem** +- **Put the most important info last** — the eye is drawn to the end of output + +``` +✗ File not found: src/missing.ts + + Check the path and try again. Run "tool list" to see available files. +``` + +**How we do it:** `formatCapiError()` cascades through increasingly generic fallbacks — API-provided message → status+code-specific message → generic fallback. HTML error pages are detected and stripped rather than displayed as garbage. + +### Composability + +Your tool will become part of larger systems — scripts, CI pipelines, aliases: + +- **stdout is for data, stderr is for messages** +- **Support `-` to read from stdin or write to stdout** +- **Don't require prompts** — always provide a flag-based alternative +- **Make it idempotent where possible** — running the same command twice should be safe +- **Make it crash-only** — exit immediately on interrupt, recover on next run +- **Never accept secrets via flags** — they leak into `ps` output and shell history. Use files, stdin, or a secret manager. +- **Prefer flags to positional args** — `tool --file foo.txt` is clearer and easier to extend than `tool foo.txt` + +--- + +## Prompting and user interruption + +CLI sessions can be long-lived and unattended — the user may step away while a task runs. Every prompt is friction, and every unnecessary interruption breaks flow. Design for autonomy: have opinionated defaults, make reasonable choices automatically, and only prompt the user when advancing is truly impossible without their input. + +When you must prompt, make the default action the one that lets work continue. Never block on a confirmation that could be a flag. If the user wanted to be asked, they wouldn't have run the command. + +**How we do it:** Autopilot mode is the clearest example — the agent runs continuously, making decisions and executing tools without asking for confirmation at each step. It only pauses when it genuinely cannot proceed without user input. This is the gold standard for long-lived CLI sessions: trust the defaults, minimize interruptions, and let the user review the outcome rather than approving every step. + +**Non-interactive mode:** When stdin is not a TTY or stdout is piped, prompting is impossible — the user can't respond. In this case, never prompt: either use defaults or error immediately with a clear message explaining which flag to pass. This is an area we haven't fully standardized yet, but the principle is simple: if the user can't answer, don't ask. + +--- + +## Buffer + +Terminals have two screen buffers: the **main buffer** (normal scrollback) and the **alternate screen buffer** (full-screen overlay). Choosing the right buffer is a critical UX decision. + +### Main buffer (default) + +Content stays in scrollback after the program exits. The user can scroll up to see it. + +**Use for:** + +- Command output that the user might want to reference later +- Logs, build results, test output +- Short-lived prompts and confirmations + +### Alternate screen buffer + +A clean canvas that disappears when the program exits, restoring the previous terminal content. + +**Use for:** + +- Full-screen interactive applications (editors, file browsers, dashboards) +- UIs where the process replaces the screen entirely +- Interfaces that the user explicitly enters and exits + +### Guidelines + +- **Default to the alt screen buffer** — Copilot uses the alternate screen to avoid flickering issues with the main buffer. Most interactive CLI sessions benefit from a clean canvas. +- **Consider the main buffer** for non-interactive output (logs, build results) where the user might want to scroll back after the program exits +- **Consider hybrid rendering** — Tools like `fzf` render inline on the main screen so selections remain visible in scrollback after exit. This respects the terminal as a shared space rather than a blank canvas. Think about whether users will want to reference your output later. +- **Clean up on exit** — Always restore the previous buffer. A crashed program that leaves the terminal in alt-screen mode is hostile +- **Handle `Ctrl+C` gracefully** — Ensure buffer restoration even on interrupt + +### Raw mode vs cooked mode + +Terminals operate in two input modes: + +- **Cooked mode** — The default. The kernel handles line editing and signals. Input arrives line-by-line after Enter. +- **Raw mode** — Every keypress is sent immediately. The program handles everything. This is what TUI apps use. + +Always restore terminal mode on exit, including on crash. + +**How we do it:** Alt screen entry is conditional — non-interactive modes avoid it entirely. On entry, Kitty keyboard protocol must be re-enabled since buffer switches reset keyboard mode. An `exitTrap` provides last-resort cleanup — even on unhandled crashes, the terminal is restored to a usable state. Suspend (`Ctrl+Z`) properly exits and re-enters the alt buffer on resume. + +--- + +## Environment + +A CLI must adapt to its environment. Terminal capabilities vary widely, and detecting them correctly is the difference between a polished and broken experience. + +### Terminal glossary + +| Term | What it is | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **TTY** | TeleType — originally a physical device, now means "interactive terminal." When code checks `isTTY`, it asks: is a human at the other end, or is this a pipe? | +| **PTY** | Pseudo-TTY — a kernel abstraction that simulates a hardware terminal. A pair of file descriptors (master + slave) that sits between the terminal emulator and your program. | +| **ConPTY** | Windows equivalent of PTY. Introduced in Windows 10 to replace the legacy console API. node-pty uses ConPTY on Windows. | +| **node-pty** | Node.js library that creates PTY pairs. Our CLI uses it to spawn shell processes on all platforms (PTY on Unix, ConPTY on Windows). | +| **Terminal emulator** | The GUI app (Ghostty, iTerm2, Windows Terminal, Alacritty) that renders the character grid. Owns the master side of the PTY. | +| **ANSI escapes** | Byte sequences (e.g., `\x1b[31m` for red) that control color, cursor position, and text style. Interpreted by the terminal emulator. | +| **Kitty protocol** | Enhanced keyboard protocol. Standard terminals send ambiguous escape sequences; Kitty sends unambiguous key codes with modifiers and release events. Supported by Ghostty, Kitty, WezTerm. | +| **Bracketed paste** | Protocol that wraps pasted text in escape markers so programs can distinguish paste from typed input. | +| **SGR** | Select Graphic Rendition — the ANSI escape subset for text styling (bold, italic, color). e.g., SGR 1 = bold, SGR 31 = red foreground. | +| **OSC** | Operating System Command — escape sequences for terminal-level features: setting window title, clipboard access, hyperlinks, color queries. | +| **CSI** | Control Sequence Introducer (`\x1b[`) — the prefix for most ANSI escapes: cursor movement, scrolling, SGR, Kitty key reports. | +| **`TERM`** | Environment variable declaring terminal type (e.g., `xterm-256color`, `dumb`). Programs use it to decide what capabilities are available. | +| **`NO_COLOR`** | Convention (no-color.org) — when set, programs must disable color output. | +| **`COLORTERM`** | Signals color depth. `truecolor` or `24bit` means 16M colors. | +| **`TERM_PROGRAM`** | Identifies the terminal emulator (e.g., `iTerm.app`, `ghostty`, `WezTerm`). Useful for enabling emulator-specific features. | +| **`SSH_TTY`** | Set when connected over SSH. Signals a remote session — we use this to gate features like clipboard access and animation intensity. | + +### TTY detection + +Always check if stdout is a TTY before using interactive features. If it's a pipe, output plain text with no control codes. Terminals reporting `TERM=dumb` should also be treated as non-interactive. + +### Keyboard and input + +Modern terminals support the Kitty keyboard protocol (unambiguous key events), bracketed paste, and mouse reporting. Detect support before enabling and fall back to legacy escape sequences when unavailable. + +### Non-interactive environments + +CLIs run in CI, Docker, cron, and SSH. When there's no TTY, skip interactive prompts and require flags instead. Default to no color, don't assume terminal width, and provide ASCII fallbacks for Unicode icons. + +### Configuration precedence + +Flags → environment variables → project config → user config → system defaults. Check well-known env vars (`NO_COLOR`, `FORCE_COLOR`, `EDITOR`, `PAGER`, `TERM`, `COLUMNS`) before inventing your own. + +--- + +## References + +- [Command Line Interface Guidelines](https://clig.dev/) — Aanand Prasad, Ben Firshman, Carl Tashian, Eva Parish. An open-source guide to CLI design, updating UNIX principles for the modern day. +- [How Terminals Work](https://how-terminals-work.vercel.app/) — Interactive guide covering the grid model, escape sequences, keyboard input, PTY, raw/cooked mode, and alternate screen buffers. +- [no-color.org](https://no-color.org/) — Convention for disabling color output +- [State of Terminal Emulators in 2025](https://www.jeffquast.com/post/state-of-terminal-emulation-2025/) — Jeff Quast. Unicode width challenges, terminal capability fragmentation, and the state of emoji rendering across emulators. +- [Terminals Should Generate the 256-Color Palette from the User's Base16 Theme](https://gist.github.com/jake-stewart/0a8ea46159a7da2c808e5be2177e1783) — Jake Stewart (Ghostty team). The case for generating harmonious 256-color palettes via CIELAB interpolation from base16 colors, directly inspiring our color ramp architecture. +- [Awesome TUIs](https://github.com/rothgar/awesome-tuis) — Curated list of terminal user interfaces for inspiration and reference. +- [TUI Design Guide](https://gist.github.com/toby/bf1325449585be869a6b01a03d4cac44) — Toby. Comprehensive guide covering the grid model, screen modes, color history, OSC sequences, Unicode graphics, performance budgets, animation strategy, and tmux-based testing. +- [ASCII Rendering](https://alexharri.com/blog/ascii-rendering) — Alex Harri. Deep dive into shape-aware image-to-ASCII conversion, using character contours rather than just brightness for sharp terminal renderings. +- [ANSI escape codes](https://en.wikipedia.org/wiki/ANSI_escape_code) - Useful Wikipedia article diving into the history and technical aspects of various escape codes mentioned here diff --git a/docs/schema.md b/docs/schema.md new file mode 100644 index 0000000..9c4e102 --- /dev/null +++ b/docs/schema.md @@ -0,0 +1,774 @@ +--- +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/ + docs/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 + {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 scripts/compile.ts status + +# Show status for a specific target +bun scripts/compile.ts status --target go + +# Generate compilation prompt for all dirty specs +bun scripts/compile.ts prompt --target go + +# Generate prompt for a single component +bun scripts/compile.ts prompt --target go --component HintBar + +# Lock spec hashes after successful compilation +bun scripts/compile.ts lock --target go + +# Remove lock file and generated prompts +bun scripts/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. + +### Token providers + +Every token MUST be exposed at runtime through a **token provider** — the +runtime layer between static token definitions and live component rendering. +A token provider is a function, hook, method, or trait that resolves token +values from the current environment (terminal width, color mode, theme). + +Components MUST consume tokens through providers, never as hardcoded values. +Providers MUST recompute when their input context changes (e.g., terminal +resize triggers breakpoint recalculation, theme change triggers color update). + +The provider pattern is idiomatic to each target: + +- **React/Ink**: Hooks (e.g., `useColors()`, `useBreakpoint()`) that + trigger re-renders when the environment changes. +- **Go/Bubbletea**: Methods on the model or a context struct, updated + via messages (e.g., `WindowSizeMsg`). +- **Rust/Ratatui**: Methods on a state struct or trait implementations + that read from shared application state. + +### 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 as **fully interactive, live instances**. + +The `props` block defines **initial** props for the variant. It does NOT mean +"render a static snapshot." Every variant MUST be a real, running component +instance that responds to keyboard input, updates state, and re-renders in +real time. + +### 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 **initial** props passed to the component +- Every variant MUST be a live, interactive instance — not a static render +- Variants render **top to bottom** in the main preview panel +- For token previews, the `props` block contains display configuration + (e.g., which token groups to show) +- See `components/previews.md` for full demo app architecture (sidebar + + main panel layout, focus model, keyboard handling) +- 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/package.json b/package.json new file mode 100644 index 0000000..a763e54 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "scripts": { + "lint": "bun scripts/lint.ts", + "compile": "bun scripts/compile.ts" + }, + "dependencies": { + "chalk": "^5.6.2", + "fast-glob": "^3.3.3", + "gray-matter": "^4.0.3", + "remark": "^15.0.1", + "remark-frontmatter": "^5.0.0", + "zod": "^4.3.6" + } +} \ No newline at end of file diff --git a/scripts/compile.ts b/scripts/compile.ts new file mode 100644 index 0000000..31969a5 --- /dev/null +++ b/scripts/compile.ts @@ -0,0 +1,588 @@ +#!/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 run compile status [--target ] + * bun run compile prompt --target [--component ] + * bun run compile lock --target [--component ] | --all-targets + * bun run compile clean --target | --all-targets + */ + +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { dirname, join, relative } from "node:path"; +import chalk from "chalk"; + +// biome-ignore lint/suspicious/noConsole: CLI tool — stdout is the interface +const log = (...args: unknown[]) => console.log(...args); + +// ── Paths ────────────────────────────────────────────────────────────────── + +const SPECS_DIR = join(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, "docs", "schema.md"); +const DEMO_PATH = join(SPECS_DIR, "components", "previews.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 = {}; + const lines = match[1].split("\n"); + let currentKey: string | null = null; + let currentValue = ""; + + for (const line of lines) { + // Indented line → either nested key (skip) or continuation of a folded scalar + if (line.match(/^\s+/) && line.trim().length > 0) { + if (currentKey && currentValue === ">") { + // Folded scalar continuation — take the first indented line as the value + fm[currentKey] = line.trim(); + currentKey = null; + currentValue = ""; + } + // Skip nested/indented keys (props, tokens, etc.) + continue; + } + // Top-level key + const sep = line.indexOf(":"); + if (sep > 0) { + // Save previous folded scalar if not yet resolved + if (currentKey && currentValue !== ">") { + fm[currentKey] = currentValue; + } + currentKey = line.slice(0, sep).trim(); + currentValue = line.slice(sep + 1).trim(); + if (currentValue !== ">") { + fm[currentKey] = currentValue; + currentKey = null; + } + } + } + // Handle trailing folded scalar that was never resolved + if (currentKey && currentValue === ">") { + fm[currentKey] = ""; + } + 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 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 on the specs referenced below."); + sections.push(""); + sections.push("This prompt is an **index** — it lists every spec with a summary and file"); + sections.push("paths. Read the full spec files from disk before implementing each component."); + sections.push(""); + sections.push("## Key reference files"); + sections.push(""); + sections.push("Read these files **first** to understand the spec format, design principles, and target framework:"); + sections.push(""); + sections.push(`- **Meta-schema** (spec format, frontmatter rules, compilation workflow): \`docs/schema.md\``); + sections.push(`- **Foundations** (color, typography, icons, layout, accessibility, keybinds): \`docs/foundations.md\``); + sections.push(""); + + sections.push("---"); + sections.push("## Target definition"); + sections.push(""); + sections.push(targetSpec); + sections.push(""); + + sections.push("---"); + sections.push("## Token specs"); + sections.push(""); + sections.push("Tokens define color, icon, and breakpoint values used across all components."); + sections.push("**Read the full file content from disk** before implementing each one."); + sections.push(""); + sections.push("Each token MUST be implemented as a **token provider** — a runtime function,"); + sections.push("hook, method, or trait that resolves token values from the current"); + sections.push("environment (terminal width, color mode, theme) at runtime. Components"); + sections.push("MUST consume tokens through these providers, never as hardcoded values."); + sections.push("Token providers are the runtime layer between static token definitions"); + sections.push("and live component rendering."); + sections.push(""); + sections.push("The provider pattern is idiomatic to each target. For example:"); + sections.push("- **React/Ink**: `useColors()`, `useBreakpoint()` hooks that re-render on change."); + sections.push("- **Go/Bubbletea**: Methods on the model or a context struct updated via messages."); + sections.push("- **Rust/Ratatui**: Methods on a state struct or trait implementations."); + sections.push(""); + sections.push("Providers MUST recompute when their input context changes (e.g., terminal"); + sections.push("resize triggers breakpoint recalculation, theme change triggers color update)."); + sections.push(""); + for (const token of tokenSpecs) { + const content = readFile(token.specPath); + const fm = extractFrontmatter(content); + const name = token.name.replace("tokens/", ""); + const description = fm.description || "No description"; + + sections.push(`### ${name}`); + sections.push(""); + sections.push(`- **Description**: ${description}`); + sections.push(`- **Spec**: \`${relative(SPECS_DIR, token.specPath)}\``); + if (token.previewPath) { + sections.push(`- **Preview**: \`${relative(SPECS_DIR, token.previewPath)}\``); + } + sections.push(""); + } + + // Components as index — summary + file paths + if (componentSpecs.length > 0) { + sections.push("---"); + sections.push("## Components to compile"); + sections.push(""); + sections.push("Each component lists its description, category, and file paths."); + sections.push("**Read the full file content from disk** before implementing each one."); + sections.push(""); + + for (const comp of componentSpecs) { + const content = readFile(comp.specPath); + const fm = extractFrontmatter(content); + const name = comp.name.replace("components/", ""); + const description = fm.description || "No description"; + const category = fm.category || "unknown"; + + sections.push(`### ${name}`); + sections.push(""); + sections.push(`- **Description**: ${description}`); + sections.push(`- **Category**: ${category}`); + sections.push(`- **Spec**: \`${relative(SPECS_DIR, comp.specPath)}\``); + if (comp.testPath) { + sections.push(`- **Tests**: \`${relative(SPECS_DIR, comp.testPath)}\``); + } + if (comp.previewPath) { + sections.push(`- **Preview**: \`${relative(SPECS_DIR, 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. For each component listed above, **read the full spec file** from disk, then implement it."); + sections.push("3. For each component with a test file, **read the test spec** and implement runnable tests."); + sections.push("4. For each component with a preview file, **read the preview spec** and build a demo screen."); + sections.push("5. For each token listed above, **read the full spec file** from disk, then implement it."); + 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("The demo app is an interactive component preview browser."); + sections.push(`Read the full spec before building the demo: \`${relative(SPECS_DIR, DEMO_PATH)}\``); + sections.push(""); + } + + sections.push("---"); + sections.push("## Verification (REQUIRED)"); + sections.push(""); + sections.push("After generating ALL files, you MUST verify in this order:"); + sections.push(""); + 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("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"); +} + +// ── 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${chalk.cyan("●")} TUIkit specs: ${chalk.bold(String(specs.length))} ${chalk.dim(`(${tokenCount} tokens, ${componentCount} components)`)}`); + log(`${chalk.cyan("●")} Schema hash: ${chalk.dim(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 ? chalk.green("✓") : chalk.yellow("⚑"); + log(`${icon} ${chalk.bold(target)}: ${dirty.length > 0 ? chalk.yellow(`${dirty.length} dirty`) : chalk.green("0 dirty")}, ${locked} locked`); + + if (dirty.length > 0) { + const maxLen = Math.max(...dirty.map((d) => d.spec.name.length)); + for (const d of dirty) { + const tag = d.reason === "new" ? "NEW" : d.reason.toUpperCase(); + log(` ${chalk.dim("├─")} ${d.spec.name.padEnd(maxLen)} ${chalk.dim(`[${tag}]`)}`); + } + log(` ${chalk.dim("└─")} ${chalk.cyan(`bun run compile prompt --target ${target}`)}`); + } + + if (lock) { + log(` ${chalk.dim(` 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(`${chalk.green("✓")} 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${chalk.cyan("●")} Compilation prompt for "${chalk.bold(target)}" ${chalk.dim(`(${dirty.length} dirty specs)`)}:`); + log(` ${chalk.dim(`→ ${relative(SPECS_DIR, outPath)}`)}`); + log(""); + log("Dirty specs included:"); + const maxLen = Math.max(...dirty.map((d) => d.spec.name.length)); + for (const d of dirty) { + log(` ${chalk.dim("├─")} ${d.spec.name.padEnd(maxLen)} ${chalk.dim(`[${d.reason}]`)}`); + } + log(""); + log("Feed this prompt to an LLM agent, then run:"); + log(` bun run compile 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(`${chalk.green("✓")} Locked ${toLock.length} specs for "${target}"`); + log(` ${chalk.dim(`→ ${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(`${chalk.green("✓")} Removed lock file for "${target}"`); + } else { + log(chalk.dim(`No lock file found for "${target}"`)); + } + + const promptPath = join(distDir, target, "_compile-prompt.md"); + if (existsSync(promptPath)) { + rmSync(promptPath); + log(`${chalk.green("✓")} 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 + --all-targets Generate prompts for all targets + lock --target [--component ] Snapshot spec hashes to lock file + --all-targets Lock all targets + clean --target Remove lock file + prompt + --all-targets Clean all targets + +Options: + --out Output directory for compiled code (default: specs/dist/) + +Examples: + bun run compile status + bun run compile prompt --target go + bun run compile prompt --target go --out ./my-tuikit + bun run compile prompt --target rust --component HintBar + bun run compile lock --target go + bun run compile clean --target bun +`); +} + +function parseArgs(argv: string[]): { command: string; target?: string; allTargets: boolean; component?: string; out?: string } { + const command = argv[0] || "status"; + let target: string | undefined; + let allTargets = false; + 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] === "--all-targets") { + allTargets = true; + } 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, allTargets, component, out }; +} + +const args = parseArgs(process.argv.slice(2)); +const distDir = args.out ? join(process.cwd(), args.out) : DEFAULT_DIST_DIR; +const noSubcommand = process.argv.length <= 2; + +switch (args.command) { + case "status": + if (noSubcommand) usage(); + cmdStatus(args.target); + break; + case "prompt": + if (args.allTargets) { + for (const t of discoverTargets()) { + cmdPrompt(t, args.component, distDir); + } + } else if (args.target) { + cmdPrompt(args.target, args.component, distDir); + } else { + log("Error: --target or --all-targets is required for prompt command"); + process.exit(1); + } + break; + case "lock": + if (args.allTargets) { + for (const t of discoverTargets()) { + cmdLock(t, args.component); + } + } else if (args.target) { + cmdLock(args.target, args.component); + } else { + log("Error: --target or --all-targets is required for lock command"); + process.exit(1); + } + break; + case "clean": + if (args.allTargets) { + for (const t of discoverTargets()) { + cmdClean(t, distDir); + } + } else if (args.target) { + cmdClean(args.target, distDir); + } else { + log("Error: --target or --all-targets is required for clean command"); + process.exit(1); + } + break; + case "help": + case "--help": + case "-h": + usage(); + break; + default: + log(`Unknown command: ${args.command}`); + usage(); + process.exit(1); +} diff --git a/scripts/lint-rules.ts b/scripts/lint-rules.ts new file mode 100644 index 0000000..f8a8d52 --- /dev/null +++ b/scripts/lint-rules.ts @@ -0,0 +1,157 @@ +/** + * TUIkit lint rules configuration + * + * Central definition of all frontmatter schemas, body section requirements, + * rule metadata, and validation patterns. The linter imports this config + * and uses it to drive all checks — no rule definitions live in lint.ts. + */ + +import { z } from "zod"; + +// ── Types ────────────────────────────────────────────────────────────────── + +export type Severity = "error" | "warn"; +export type SpecKind = "component" | "token" | "test" | "preview" | "target"; + +export interface RuleDef { + id: string; + severity: Severity; + description: string; + kinds: SpecKind[] | "*"; + fix?: string; +} + +// ── Constants ────────────────────────────────────────────────────────────── + +export const VALID_CATEGORIES = [ + "input", + "display", + "navigation", + "layout", + "feedback", +] as const; + +// ── Frontmatter schemas (one per kind) ───────────────────────────────────── + +export const ComponentFM = z.object({ + kind: z.literal("component"), + name: z.string().min(1), + description: z.string().min(1), + version: z.number().int().nonnegative(), + category: z.enum(VALID_CATEGORIES), + dependencies: z.unknown(), +}).passthrough(); + +export const TokenFM = z.object({ + kind: z.literal("token"), + name: z.string().min(1), + description: z.string().min(1), + version: z.number().int().nonnegative(), +}).passthrough(); + +export const TestFM = z.object({ + kind: z.literal("test"), + component: z.string().min(1), + version: z.number().int().nonnegative(), +}).passthrough(); + +export const PreviewFM = z.object({ + kind: z.literal("preview"), + component: z.string().min(1), + version: z.number().int().nonnegative(), +}).passthrough(); + +export const TargetFM = z.object({ + kind: z.literal("target"), + name: z.string().min(1), + language: z.string().min(1), + runtime: z.string().min(1), + framework: z.unknown().refine((v) => v != null, "framework is required"), +}).passthrough(); + +export const SchemaByKind: Record = { + component: ComponentFM, + token: TokenFM, + test: TestFM, + preview: PreviewFM, + target: TargetFM, +}; + +// ── Required body sections ───────────────────────────────────────────────── + +export const REQUIRED_SECTIONS: Record = { + component: { + sections: ["Visual rules", "Rendering example", "Dependencies"], + severity: "error", + exact: true, + }, + target: { + sections: [ + "Architecture pattern", "Type mapping", "Callback translation", + "State machine translation", "Token access", "Composition", + "Test pattern", "Key mapping", "Dependencies", "Demo CLI", + ], + severity: "warn", + exact: false, // fuzzy match (lowercase includes) + }, +}; + +// ── RFC 2119 patterns ────────────────────────────────────────────────────── + +export const RFC2119_KEYWORDS = /\b(MUST NOT|MUST|SHOULD NOT|SHOULD|MAY)\b/; +export const INFORMAL_WORDS = /\b(always|never|should(?!\s+NOT))\b/i; +export const NORMATIVE_SECTIONS = ["Visual rules", "Behavior", "Edge cases"]; + +// ── Rule registry ────────────────────────────────────────────────────────── + +export const RULES: RuleDef[] = [ + // Frontmatter (zod-driven, auto-generated rule IDs like fm-) + { id: "fm-kind", severity: "error", description: "kind field is present and valid", kinds: "*" }, + { id: "fm-name", severity: "error", description: "name is present", kinds: ["component", "token", "target"] }, + { id: "fm-name-case", severity: "warn", description: "name follows PascalCase convention", kinds: ["component"] }, + { id: "fm-description", severity: "error", description: "description is present", kinds: ["component", "token"] }, + { id: "fm-version", severity: "error", description: "version is present and numeric", kinds: ["component", "token", "test", "preview"] }, + { id: "fm-category", severity: "error", description: "category is present and valid", kinds: ["component"] }, + { id: "fm-dependencies", severity: "error", description: "dependencies section is present", kinds: ["component"] }, + { id: "fm-props", severity: "warn", description: "props section is present", kinds: ["component"] }, + { id: "fm-tokens-deps-sync", severity: "warn", description: "tokens and dependencies are in sync", kinds: ["component"] }, + { id: "fm-component", severity: "error", description: "test/preview spec references a component", kinds: ["test", "preview"] }, + { id: "fm-component-ref", severity: "error", description: "referenced component spec exists", kinds: ["test", "preview"] }, + { id: "fm-language", severity: "error", description: "target language is specified", kinds: ["target"] }, + { id: "fm-runtime", severity: "error", description: "target runtime is specified", kinds: ["target"] }, + { id: "fm-framework", severity: "error", description: "target framework is specified", kinds: ["target"] }, + + // Accessibility + { id: "fm-a11y-interactive", severity: "error", description: "interactive components define accessibility", kinds: ["component"], + fix: "Add accessibility: with role, announce.on_mount, and screen_reader_adaptations" }, + { id: "fm-a11y-display", severity: "warn", description: "display components should define accessibility", kinds: ["component"] }, + + // Body sections + { id: "body-visual-rules", severity: "error", description: '"## Visual rules" section exists', kinds: ["component"] }, + { id: "body-rendering-example", severity: "error", description: '"## Rendering example" section exists', kinds: ["component"] }, + { id: "body-dependencies", severity: "error", description: '"## Dependencies" section exists', kinds: ["component"] }, + { id: "body-behavior", severity: "warn", description: "interactive components have behavior section", kinds: ["component"] }, + { id: "target-section", severity: "warn", description: "target spec has recommended body sections", kinds: ["target"] }, + { id: "test-empty", severity: "warn", description: "test spec has at least one test case", kinds: ["test"] }, + + // RFC 2119 + { id: "rfc2119-missing", severity: "warn", description: "normative sections use RFC 2119 keywords", kinds: ["component"], + fix: "Replace informal language with MUST/SHOULD/MAY" }, + { id: "rfc2119-informal", severity: "warn", description: "no informal language in normative sections", kinds: ["component"], + fix: "Replace with RFC 2119 keyword" }, + + // Cross-references + { id: "xref-token", severity: "warn", description: "token references resolve to known tokens", kinds: ["component"] }, + + // File naming + { id: "naming-dir", severity: "warn", description: "component directory is PascalCase", kinds: ["component"] }, + { id: "naming-spec", severity: "error", description: "{Name}.md exists in component directory", kinds: ["component"] }, + { id: "naming-test", severity: "error", description: "{Name}.test.md exists in component directory", kinds: ["component"] }, + + // Links + { id: "link-broken", severity: "error", description: "internal markdown links resolve to existing files", kinds: "*", + fix: "Fix path or remove link" }, +]; + +// Lookup helper +export const RULES_BY_ID = new Map(RULES.map((r) => [r.id, r])); diff --git a/scripts/lint.ts b/scripts/lint.ts new file mode 100644 index 0000000..699a890 --- /dev/null +++ b/scripts/lint.ts @@ -0,0 +1,418 @@ +#!/usr/bin/env bun +/** + * TUIkit spec linter + * + * Validates component, token, test, and target specs against the schema. + * Rule definitions and schemas live in lint-rules.ts — this file is + * purely execution logic. + * + * Usage: + * bun run lint # lint all specs + * bun run lint --component HintBar # lint one component + * bun run lint --fix # show suggested fixes + */ + +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join, relative } from "node:path"; +import chalk from "chalk"; +import fg from "fast-glob"; +import matter from "gray-matter"; +import { + INFORMAL_WORDS, + NORMATIVE_SECTIONS, + REQUIRED_SECTIONS, + RFC2119_KEYWORDS, + RULES, + RULES_BY_ID, + SchemaByKind, + type Severity, +} from "./lint-rules.js"; + +const log = (...args: unknown[]) => console.log(...args); + +// ── Paths ────────────────────────────────────────────────────────────────── + +const SPECS_DIR = join(dirname(new URL(import.meta.url).pathname), ".."); +const COMPONENTS_DIR = join(SPECS_DIR, "components"); +const TOKENS_DIR = join(SPECS_DIR, "tokens"); +const TARGETS_DIR = join(SPECS_DIR, "targets"); + +// ── Types ────────────────────────────────────────────────────────────────── + +interface Diagnostic { + file: string; + severity: Severity; + rule: string; + message: string; + fix?: string; +} + +interface ParsedSpec { + path: string; + rel: string; + fm: Record; + body: string; + sections: string[]; +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function ruleSeverity(id: string, fallback: Severity = "error"): Severity { + return RULES_BY_ID.get(id)?.severity ?? fallback; +} + +function ruleFix(id: string): string | undefined { + return RULES_BY_ID.get(id)?.fix; +} + +function diag(file: string, rule: string, message: string, fix?: string): Diagnostic { + return { file, severity: ruleSeverity(rule), rule, message, fix: fix ?? ruleFix(rule) }; +} + +// ── Frontmatter parsing ─────────────────────────────────────────────────── + +function parseFrontmatter(content: string): Record { + try { + return matter(content).data; + } catch { + // gray-matter uses strict YAML; some specs have non-YAML-safe type + // signatures. Fall back to minimal line-by-line parser. + const start = content.startsWith("---\n"); + const end = start ? content.indexOf("\n---\n", 4) : -1; + if (!start || end === -1) return {}; + const header = content.slice(4, end); + const data: Record = {}; + for (const line of header.split("\n")) { + const m = line.match(/^([A-Za-z_]\w*):/); + if (m && !line.startsWith(" ") && !line.startsWith("\t")) { + const key = m[1]; + const raw = line.slice(line.indexOf(":") + 1).trim(); + if (!raw || raw.startsWith(">") || raw.startsWith("|")) data[key] = {}; + else if (/^\d+$/.test(raw)) data[key] = Number.parseInt(raw, 10); + else data[key] = raw.replace(/^['"]|['"]$/g, ""); + } + } + return data; + } +} + +function parseSpec(path: string): ParsedSpec { + const content = readFileSync(path, "utf-8"); + const fmBlock = content.match(/^---\n[\s\S]*?\n---\n/); + const body = fmBlock ? content.slice(fmBlock[0].length) : content; + return { + path, + rel: relative(SPECS_DIR, path), + fm: parseFrontmatter(content), + body, + sections: [...body.matchAll(/^## (.+)$/gm)].map((m) => m[1]), + }; +} + +// ── Schema validation ───────────────────────────────────────────────────── + +function lintFrontmatter(spec: ParsedSpec, diagnostics: Diagnostic[]): void { + const { fm, rel } = spec; + const kind = fm.kind as string | undefined; + + if (!kind) { + diagnostics.push(diag(rel, "fm-kind", "Missing required field: kind")); + return; + } + + const schema = SchemaByKind[kind]; + if (!schema) { + diagnostics.push(diag(rel, "fm-kind", `Unknown kind: "${kind}"`)); + return; + } + + const result = schema.safeParse(fm); + if (!result.success) { + for (const issue of result.error.issues) { + const key = issue.path[0] ?? "frontmatter"; + diagnostics.push(diag(rel, `fm-${String(key)}`, `${String(key)}: ${issue.message}`)); + } + } + + if (kind === "component") { + if (typeof fm.name === "string" && !/^[A-Z][a-zA-Z0-9]+$/.test(fm.name)) { + diagnostics.push(diag(rel, "fm-name-case", `name "${fm.name}" SHOULD be PascalCase`)); + } + if (!fm.props) { + diagnostics.push(diag(rel, "fm-props", "No props defined")); + } + if (fm.tokens && !fm.dependencies) { + diagnostics.push(diag(rel, "fm-tokens-deps-sync", "Has tokens: but no dependencies:")); + } + } +} + +// ── Accessibility ────────────────────────────────────────────────────────── + +function lintAccessibility(spec: ParsedSpec, diagnostics: Diagnostic[]): void { + const { fm, rel } = spec; + if (fm.kind !== "component") return; + + const interactive = !!fm.states || !!fm.keyboard; + if (!fm.accessibility) { + const rule = interactive ? "fm-a11y-interactive" : "fm-a11y-display"; + const msg = interactive + ? "Interactive component MUST define accessibility section" + : "Display component SHOULD define accessibility: with at least role"; + diagnostics.push(diag(rel, rule, msg)); + } +} + +// ── Body sections (config-driven) ────────────────────────────────────────── + +function lintBodySections(spec: ParsedSpec, diagnostics: Diagnostic[]): void { + const { fm, rel, sections } = spec; + const kind = fm.kind as string; + + const req = REQUIRED_SECTIONS[kind]; + if (req) { + for (const s of req.sections) { + const found = req.exact + ? sections.includes(s) + : sections.some((h) => h.toLowerCase().includes(s.toLowerCase())); + if (!found) { + const ruleId = req.exact + ? `body-${s.toLowerCase().replace(/ /g, "-")}` + : "target-section"; + diagnostics.push(diag(rel, ruleId, `Missing ${req.severity === "error" ? "required" : "recommended"} section: "## ${s}"`)); + } + } + } + + if (kind === "component") { + const interactive = !!fm.states || !!fm.keyboard; + if (interactive && !sections.some((s) => s.startsWith("Behavior"))) { + diagnostics.push(diag(rel, "body-behavior", 'Interactive component SHOULD have "## Behavior"')); + } + } + + if (kind === "test" && sections.length === 0) { + diagnostics.push(diag(rel, "test-empty", "Test spec has no test cases (## headings)")); + } +} + +// ── RFC 2119 conformance (config-driven patterns) ────────────────────────── + +function lintRFC2119(spec: ParsedSpec, diagnostics: Diagnostic[]): void { + if (spec.fm.kind !== "component") return; + const { body, rel } = spec; + + for (const name of NORMATIVE_SECTIONS) { + const re = new RegExp(`^## ${name}\\b.*$`, "m"); + const match = body.match(re); + if (!match) continue; + + const start = body.indexOf(match[0]) + match[0].length; + const next = body.slice(start).match(/^## /m); + const text = body.slice(start, next ? start + (next.index ?? 0) : undefined); + + if (!RFC2119_KEYWORDS.test(text)) { + diagnostics.push(diag(rel, "rfc2119-missing", `"${name}" has no RFC 2119 keywords (MUST/SHOULD/MAY)`)); + } + + for (const line of text.split("\n").filter((l) => /^\s*[-*]/.test(l))) { + if (INFORMAL_WORDS.test(line) && !RFC2119_KEYWORDS.test(line)) { + diagnostics.push(diag(rel, "rfc2119-informal", `"${name}" informal language: "${line.trim().slice(0, 80)}…"`)); + } + } + } +} + +// ── Cross-reference validation ───────────────────────────────────────────── + +function lintCrossReferences(components: ParsedSpec[], tokens: ParsedSpec[], diagnostics: Diagnostic[]): void { + const knownTokens = new Set(); + for (const spec of tokens) { + const content = readFileSync(spec.path, "utf-8"); + for (const m of content.matchAll(/^\s{4}(\w+):/gm)) knownTokens.add(m[1]); + for (const m of content.matchAll(/^\s+(\w+):\s*\{\s*char:/gm)) knownTokens.add(m[1]); + for (const m of content.matchAll(/^\s+(icon\w+):\s*\w+/gm)) knownTokens.add(m[1]); + } + + for (const spec of components) { + const content = readFileSync(spec.path, "utf-8"); + for (const re of [/colors:\s*\[([^\]]*)\]/, /icons:\s*\[([^\]]*)\]/]) { + const match = content.match(re); + if (!match?.[1]?.trim()) continue; + for (const tok of match[1].split(",").map((s) => s.trim()).filter(Boolean)) { + if (!knownTokens.has(tok)) { + diagnostics.push(diag(spec.rel, "xref-token", `Unknown token "${tok}"`)); + } + } + } + } +} + +// ── Test component-ref validation ────────────────────────────────────────── + +function lintTestRef(spec: ParsedSpec, diagnostics: Diagnostic[]): void { + const comp = spec.fm.component; + if (typeof comp !== "string") return; + if (!existsSync(join(COMPONENTS_DIR, comp, `${comp}.md`))) { + diagnostics.push(diag(spec.rel, "fm-component-ref", `References "${comp}" but ${comp}/${comp}.md does not exist`)); + } +} + +// ── File naming ──────────────────────────────────────────────────────────── + +function lintFileNaming(diagnostics: Diagnostic[]): void { + for (const dir of fg.sync("*/", { cwd: COMPONENTS_DIR, onlyDirectories: true })) { + const name = dir.replace(/\/$/, ""); + const rel = `components/${name}`; + if (!/^[A-Z][a-zA-Z0-9]+$/.test(name)) { + diagnostics.push(diag(rel, "naming-dir", `"${name}" SHOULD be PascalCase`)); + } + if (!existsSync(join(COMPONENTS_DIR, name, `${name}.md`))) { + diagnostics.push(diag(rel, "naming-spec", `Missing ${name}.md`)); + } + if (!existsSync(join(COMPONENTS_DIR, name, `${name}.test.md`))) { + diagnostics.push(diag(rel, "naming-test", `Missing ${name}.test.md`)); + } + } +} + +// ── Broken links ─────────────────────────────────────────────────────────── + +function lintBrokenLinks(diagnostics: Diagnostic[]): void { + const files = fg.sync("**/*.md", { cwd: SPECS_DIR, ignore: ["node_modules/**", "dist/**"], absolute: true }); + const mdLink = /\[[^\]]+\]\(([^)]+)\)/g; + + for (const file of files) { + const content = readFileSync(file, "utf-8"); + const rel = relative(SPECS_DIR, file); + let m: RegExpExecArray | null; + while ((m = mdLink.exec(content)) !== null) { + const target = m[1].trim(); + if (!target || /^(https?:|mailto:|#)/.test(target)) continue; + const clean = target.split("#")[0].split("?")[0]; + if (!clean) continue; + const resolved = target.startsWith("/") ? join(SPECS_DIR, clean.slice(1)) : join(dirname(file), clean); + if (!existsSync(resolved)) { + diagnostics.push(diag(rel, "link-broken", `Broken link: '${target}'`)); + } + } + } +} + +// ── Discovery + orchestration ────────────────────────────────────────────── + +function discoverAndLint(componentFilter?: string): Diagnostic[] { + const diagnostics: Diagnostic[] = []; + + lintFileNaming(diagnostics); + + const tokenSpecs: ParsedSpec[] = []; + for (const f of fg.sync("*.md", { cwd: TOKENS_DIR, absolute: true, ignore: ["*.preview.*"] })) { + const spec = parseSpec(f); + tokenSpecs.push(spec); + lintFrontmatter(spec, diagnostics); + } + + const componentSpecs: ParsedSpec[] = []; + const pattern = componentFilter ? `${componentFilter}/${componentFilter}.md` : "*/*.md"; + for (const f of fg.sync(pattern, { cwd: COMPONENTS_DIR, absolute: true, ignore: ["*.test.*", "*.preview.*", "previews.md"] })) { + const spec = parseSpec(f); + componentSpecs.push(spec); + lintFrontmatter(spec, diagnostics); + lintAccessibility(spec, diagnostics); + lintBodySections(spec, diagnostics); + lintRFC2119(spec, diagnostics); + } + + const testPattern = componentFilter ? `${componentFilter}/${componentFilter}.test.md` : "*/*.test.md"; + for (const f of fg.sync(testPattern, { cwd: COMPONENTS_DIR, absolute: true })) { + const spec = parseSpec(f); + lintFrontmatter(spec, diagnostics); + lintBodySections(spec, diagnostics); + lintTestRef(spec, diagnostics); + } + + if (!componentFilter) { + for (const f of fg.sync("*.md", { cwd: TARGETS_DIR, absolute: true })) { + const spec = parseSpec(f); + lintFrontmatter(spec, diagnostics); + lintBodySections(spec, diagnostics); + } + } + + if (!componentFilter) { + lintCrossReferences(componentSpecs, tokenSpecs, diagnostics); + lintBrokenLinks(diagnostics); + } + + return diagnostics; +} + +// ── Output ───────────────────────────────────────────────────────────────── + +function formatDiagnostics(diagnostics: Diagnostic[], showFix: boolean): void { + if (diagnostics.length === 0) { + log(`\n${chalk.green("✓")} All specs valid.\n`); + return; + } + + const errors = diagnostics.filter((d) => d.severity === "error"); + const warnings = diagnostics.filter((d) => d.severity === "warn"); + + 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(` ${chalk.bold(file)}`); + for (const d of diags) { + const icon = d.severity === "error" ? chalk.red(" ✗") : chalk.yellow(" !"); + log(`${icon} ${chalk.dim(`[${d.rule}]`)} ${d.message}`); + if (showFix && d.fix) { + log(` ${chalk.dim(`→ ${d.fix}`)}`); + } + } + log(""); + } + + log(chalk.dim("───────────────────────────────────────")); + log(` ${chalk.red(`${errors.length} error(s)`)}, ${chalk.yellow(`${warnings.length} warning(s)`)}`); + log(""); + + if (errors.length > 0) process.exitCode = 1; +} + +// ── CLI ──────────────────────────────────────────────────────────────────── + +function usage(): void { + log(`\n${chalk.bold("TUIkit spec linter")} — validate specs against the schema.\n`); + log(`${chalk.dim("Usage:")}`); + log(" bun run lint Lint all specs"); + log(" bun run lint --component HintBar Lint one component"); + log(" bun run lint --fix Show suggested fixes"); + log(`\n${chalk.dim("Rules:")}`); + for (const r of RULES) { + const sev = r.severity === "error" ? chalk.red("error") : chalk.yellow("warn "); + log(` ${sev} ${chalk.dim(r.id.padEnd(22))} ${r.description}`); + } + log(""); +} + +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 (["help", "--help", "-h"].includes(argv[i])) { 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..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 | +| `