# Omarmusayev Terminaltui Terminaltui

> terminaltui — TUI Website & Application Framework

- Skill: `tomevault-io/omarmusayev-terminaltui-terminaltui` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add tomevault-io/omarmusayev-terminaltui-terminaltui`
- Raw SKILL.md: https://api.skillmd.com/api/skills/tomevault-io/omarmusayev-terminaltui-terminaltui/raw
- Safety review: pending (external: skill-scanner PASS, skillspector PASS)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: tomevault-io (https://skillmd.com/u/tomevault-io)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/tomevault-io/omarmusayev-terminaltui-terminaltui

---


# terminaltui — TUI Website & Application Framework

## What It Is

terminaltui is a TypeScript framework that turns any website into a fully interactive terminal (TUI) experience. Projects use Next.js-style file-based routing — a `config.ts` for global settings plus a `pages/` directory where each file is a route. The result is an interactive terminal app navigable by keyboard that can be published to npm so anyone can run it with `npx my-site`, or hosted over SSH so anyone can connect with `ssh host -p PORT`.

## Quick Start

```bash
# Scaffold a new project
npx terminaltui init [template]

# Start dev preview
npx terminaltui dev

# Host over SSH (anyone connects with ssh)
npx terminaltui serve --port 2222

# Bundle for npm publish
npx terminaltui build
```

Minimal project:

```ts
// config.ts
import { defineConfig } from "terminaltui";

export default defineConfig({
  name: "My Site",
  theme: "cyberpunk",
});
```

```ts
// pages/home.ts
import { markdown } from "terminaltui";

export const metadata = { label: "Home", icon: "◆" };

export default function Home() {
  return [markdown("Hello world!")];
}
```

Project structure:

```
my-site/
  config.ts          # theme, banner, global settings
  pages/             # one file per route
    home.ts
  package.json       # must have "type": "module"
  tsconfig.json
```

---

## File-Based Routing

terminaltui uses Next.js-style file-based routing. Each page is its own file, layouts nest automatically, and menus are auto-generated from the filesystem.

### Project Structure

```
my-site/
├── config.ts          # Theme, site name, global settings
├── pages/
│   ├── layout.ts      # Root layout (wraps all pages)
│   ├── home.ts        # Home page
│   ├── about.ts       # /about
│   └── projects/
│       ├── index.ts   # /projects
│       └── [slug].ts  # /projects/:slug
├── api/
│   └── stats.ts       # GET /api/stats
├── components/        # Reusable components
└── lib/               # Shared data/helpers
```

### config.ts

Uses `defineConfig()` instead of `createSite()`. Contains only global settings — no pages, no content.

```ts
import { defineConfig } from "terminaltui";

export default defineConfig({
  name: "My Site",
  theme: "cyberpunk",
  banner: { font: "ANSI Shadow" },
  boot: { spinner: true },

  // Optional: override auto-generated menu
  menu: {
    order: ["home", "projects", "about", "contact"],
    labels: { projects: "Our Work", about: "About Us" },
    // Items not listed are excluded from menu
  },
  // MenuConfig type:
  // interface MenuConfig {
  //   items?: Array<{ label: string; page: string; icon?: string }>;
  //   order?: string[];
  //   labels?: Record<string, string>;
  //   icons?: Record<string, string>;
  //   exclude?: string[];
  // }

  // Lifecycle hooks
  onInit: async () => { /* ... */ },
  onExit: () => { /* ... */ },
  onError: (err) => { /* ... */ },
});
```

Omit `menu` entirely to let the framework auto-generate it from `pages/`.

### Page Files

Every `.ts` file in `pages/` becomes a page. Default export is a function returning `ContentBlock[]`.

```ts
// pages/about.ts
import { text, card } from "terminaltui";

export default function About() {
  return [
    card({ title: "About Me", body: "Full-stack developer based in..." }),
  ];
}
```

**Metadata export** — optional, controls menu label, order, transition, visibility:

```ts
// pages/projects/index.ts
import { card, row, col } from "terminaltui";

export const metadata = {
  label: "Projects",      // Menu label (default: filename titlecased)
  order: 2,               // Menu sort order (default: alphabetical)
  transition: "slide",    // Page transition
  icon: "code",           // Menu icon
  hidden: false,          // If true, excluded from auto-generated menu
};

export default function Projects() {
  return [
    row([
      col([card({ title: "Project A" })], { span: 6 }),
      col([card({ title: "Project B" })], { span: 6 }),
    ]),
  ];
}
```

**Async pages** — for data fetching:

```ts
// pages/dashboard/index.ts
import { card, row, col, text } from "terminaltui";

export default async function Dashboard() {
  const stats = await fetch("/api/stats").then(r => r.json());
  return [
    row([
      col([card({ title: "Revenue", content: [text(stats.revenue)] })], { span: 6 }),
      col([card({ title: "Users", content: [text(String(stats.users))] })], { span: 6 }),
    ]),
  ];
}
```

**Dynamic routes** — `[param]` in filename, params passed to function:

```ts
// pages/projects/[slug].ts
import { card, text, badge } from "terminaltui";

export const metadata = { hidden: true }; // Dynamic routes excluded from menu

export default async function ProjectDetail({ params }: { params: { slug: string } }) {
  const project = await fetch(`/api/projects/${params.slug}`).then(r => r.json());
  return [
    card({ title: project.name, content: [badge({ text: project.status }), text(project.description)] }),
  ];
}
```

### Page Visibility

Control which pages appear in the menu:

- `metadata.hidden = true` — page exists and is navigable but excluded from auto-generated menu
- Pages without `hidden: true` appear in the menu by default
- Dynamic route pages (`[slug].ts`) should always be `hidden: true`
- In `defineConfig({ menu })`:
  - `menu.order: ["home", "about"]` — reorder the menu by page name
  - `menu.exclude: ["secret"]` — explicitly hide specific pages
  - `menu.items: [{ id, label, icon }]` — fully manual menu (overrides auto-generation)

### Layout Files

A `layout.ts` in any directory wraps all sibling and descendant pages. Receives `children` (the rendered page content).

```ts
// pages/layout.ts — Root layout, wraps everything
import { columns, panel, menu } from "terminaltui";
import type { ContentBlock } from "terminaltui";

export default function RootLayout({ children }: { children: ContentBlock[] }) {
  return [
    columns([
      panel({ width: "25%", content: [menu({ source: "auto" })] }),
      panel({ width: "75%", content: children }),
    ]),
  ];
}
```

```ts
// pages/dashboard/layout.ts — wraps /dashboard/* pages only
import { columns, panel, menu, text } from "terminaltui";
import type { ContentBlock } from "terminaltui";

export default function DashboardLayout({ children }: { children: ContentBlock[] }) {
  return [
    text("Dashboard"),
    columns([
      panel({ width: "20%", content: [menu({ items: [
        { label: "Overview", page: "dashboard" },
        { label: "Analytics", page: "dashboard/analytics" },
        { label: "Settings", page: "dashboard/settings" },
      ]})] }),
      panel({ width: "80%", content: children }),
    ]),
  ];
}
```

**Nesting:** Layouts compose from outside in. For `/dashboard/analytics`:
`RootLayout` -> `DashboardLayout` -> `AnalyticsPage`

If no `layout.ts` exists at a level, pages use the nearest parent layout.

### API Routes

Export named functions matching HTTP methods. File path maps to endpoint.

```ts
// api/stats.ts → GET /api/stats
export async function GET() {
  return { revenue: "$1.2M", users: 45231 };
}
```

```ts
// api/contact.ts → POST /api/contact
export async function POST(request: { body: any }) {
  const { name, email, message } = request.body;
  return { success: true };
}
```

```ts
// api/projects/[id].ts → /api/projects/:id
export async function GET({ params }: { params: { id: string } }) {
  return projects.find(p => p.id === params.id) ?? { error: "Not found" };
}

export async function DELETE({ params }: { params: { id: string } }) {
  return { success: true };
}
```

Route mapping: `api/stats.ts` -> `/api/stats`, `api/projects/[id].ts` -> `/api/projects/:id`.

### Auto-Generated Menu

When `config.ts` omits `menu`, the framework scans `pages/` and builds the menu automatically.

**Rules:**
- Every `.ts` file directly in `pages/` becomes a top-level menu item
- Directories with `index.ts` become a top-level menu item (name from directory)
- Sub-pages inside directories (other than `index.ts`) are NOT in the top menu
- `home.ts` is always first
- `layout.ts` files are never menu items
- `metadata.hidden = true` pages are excluded
- Dynamic route files (`[param].ts`) are excluded

**Ordering:** `metadata.order` (lowest first), then alphabetical for unordered items.

**Labels:** `metadata.label` > `metadata.icon` + titlecased filename > titlecased filename (`about.ts` -> "About", `our-team.ts` -> "Our Team").

**Manual override in config.ts:**

```ts
export default defineConfig({
  name: "My Site",
  menu: {
    items: [
      { label: "Home", page: "home", icon: "terminal" },
      { label: "Work", page: "projects" },
      { label: "About Me", page: "about" },
    ],
  },
});
```

### menu() Component

Use `menu({ source: "auto" })` in any page or layout to render the auto-generated menu:

```ts
import { hero, menu } from "terminaltui";

export default function Home() {
  return [
    hero({ title: "My Site", subtitle: "Welcome" }),
    menu({ source: "auto" }), // Resolved at render time from pages/
  ];
}
```

**Important:** The framework renders the navigation menu automatically on the home screen. Do NOT add `menu({ source: 'auto' })` to your `home.ts` -- it creates a duplicate menu.

If `home.ts` doesn't exist, the framework auto-generates a home page with `hero()` + `menu({ source: "auto" })`.

---

## Focus & Scroll Model — CRITICAL FOR GOOD UX

TUI navigation is fundamentally **up/down arrow keys** moving a focus cursor between items. The viewport scrolls to follow the focused item. Understanding which components are focusable is essential for building good TUI experiences.

**Default layout philosophy:** Use vertical scrolling with flat card layouts. Use `divider("Label")` to visually separate sections rather than nesting them inside containers like `tabs()`.

### Focusability per Component

| Component | Focusable? | Behavior |
|-----------|-----------|----------|
| `card()` | **Yes** — individually | Each card is a separate focus target. Best for browsable lists. |
| `link()` | **Yes** — individually | Opens URL on Enter. |
| `hero()` | **Yes** — individually | Opens CTA URL on Enter (if `cta` set). |
| `accordion()` | **Yes** — per item | Each accordion item is separately focusable. Enter toggles open/close. |
| `tabs()` | **Yes** — as one block | Enter cycles through tabs. Not ideal for many sections. |
| `textInput()` | **Yes** — individually | Enter starts editing, Escape exits. |
| `textArea()` | **Yes** — individually | Same as textInput but multi-line. |
| `select()` | **Yes** — individually | Enter opens dropdown, arrow keys pick option. |
| `checkbox()` | **Yes** — individually | Enter/Space toggles. |
| `toggle()` | **Yes** — individually | Enter/Space toggles. |
| `radioGroup()` | **Yes** — individually | Enter starts selection, arrows move between options. |
| `numberInput()` | **Yes** — individually | Left/Right changes value. |
| `searchInput()` | **Yes** — individually | Type to filter, arrows to pick result, Enter to select. |
| `chat()` | **Yes** — individually | Enter starts typing, sends message on Enter, Escape exits. |
| `button()` | **Yes** — individually | Enter triggers action. |
| `timeline()` | **Yes** — per item | Each timeline item is focusable but display-only (no action on Enter). |
| `markdown()` | No | Passive text. Not focusable. |
| `table()` | No | Passive data display. Not focusable. |
| `list()` | No | Passive list. Items not individually focusable. |
| `quote()` | No | Passive text. Not focusable. |
| `progressBar()` / `skillBar()` | No | Passive display. |
| `badge()` | No | Inline label. Not focusable. |
| `divider()` | No | Visual separator. Not focusable. |
| `spacer()` | No | Vertical spacing. Not focusable. |
| `image()` | No | Passive display. |
| `section()` | No — wrapper | Children inherit their own focusability. |
| `form()` | No — wrapper | Children (inputs, buttons) are individually focusable. |
| `dynamic()` | No — wrapper | Children inherit their own focusability. |
| `columns()` | No — layout | Left/Right + Tab switch panels. Up/Down navigates items. Enter activates. Escape = back. |
| `rows()` | No — layout | Left/Right + Tab switch panels. Up/Down navigates items. Enter activates. Escape = back. |
| `grid()` | No — layout | Left/Right + Tab switch panels. Up/Down navigates items. Enter activates. Escape = back. |
| `panel()` | No — wrapper | Used inside layout components. Children inherit focusability. |

### TUI UX Patterns — What to Use When

| UX Need | Use | Avoid |
|---------|-----|-------|
| Scrollable list of items | Flat `card()` blocks | `timeline()`, `list()` |
| Sectioned long page | `divider("Label")` + cards below | `tabs()` (forces horizontal switching) |
| Toggle between views of same data | `tabs()` | n/a |
| Dense reference data | `table()` | Many cards for tabular data |
| Expandable FAQ / details | `accordion()` | Long `markdown()` blocks |
| Work history / education | Individual `card()` blocks with period as subtitle | `timeline()` (items aren't actionable) |
| Skills / tech stack | `skillBar()` or `list()` (passive reference) | Cards (overkill for simple data) |
| Dashboard with sidebar | `columns()` — sidebar panel + main panel | Flat layout (loses spatial structure) |
| Monitoring grid | `grid()` with metric panels | Single-column cards (wastes space) |
| Split editor/preview | `columns([panel({…}), panel({…})])` | Tabs (can't see both at once) |
| Log viewer + controls | `rows()` — controls on top, logs below | Interleaved cards |

### Bad → Good Patterns

```ts
// BAD: tabs for resume sections + timeline for entries
// timeline is one block, tabs force left/right switching
tabs([
  { label: "Experience", content: [timeline([
    { title: "Engineer", subtitle: "Acme", period: "2023–now" }
  ])] },
  { label: "Education", content: [timeline([...])] },
])

// GOOD: flat cards with divider sections — everything scrolls vertically
divider("Experience"),
card({ title: "Senior Engineer", subtitle: "Acme Corp — 2023–present", body: "Leading platform team..." }),
card({ title: "Junior Dev", subtitle: "Startup — 2021–2023", body: "Built core features..." }),
divider("Education"),
card({ title: "BS Computer Science", subtitle: "State University — 2021" }),
// Each card is focusable, everything scrolls naturally with ↑↓
```

**When to use `timeline()`:** Only when you want a visual connected-dot timeline aesthetic AND the items are passive (no action needed on Enter). For anything users need to browse, navigate, or interact with, use `card()` blocks instead.

**When to use `tabs()`:** Only for mutually exclusive views of the same data (e.g., "Grid view" vs "List view"). NOT for organizing sequential sections of a page — use `divider("Label")` for that. If two views should be visible simultaneously (e.g., Day 1 and Day 2 of a conference schedule), use `columns([panel({…}), panel({…})])` instead.

### Layout Mapping Guide

| Site Pattern | Layout | Example |
|---|---|---|
| Dashboard with sidebar navigation | `columns()` — narrow first panel (20-25%), wide main panel | Server dashboard |
| Dashboard with multiple data views | `columns()` + nested `grid()` | System monitor with CPU/Memory/Disk metrics |
| Pricing comparison (2-4 tiers) | `columns()` — one panel per tier | SaaS pricing page |
| Side-by-side content (text + skills) | `columns([panel({…}), panel({…})])` | Portfolio about page |
| Day 1 / Day 2 schedule | `columns([panel({…}), panel({…})])` | Conference schedule |
| Food menu (categories) | `columns([panel({…}), panel({…})])` — dishes left, drinks right | Restaurant menu |
| Hours + location info | `columns()` — hours table left, address right | Restaurant/shop hours |
| Project/portfolio cards | `grid({ cols: 2 })` — cards in a grid | Freelancer work page |
| Feature cards | `grid({ cols: 2 })` | SaaS features page |
| Speaker/team bios | `grid({ cols: 2 })` | Conference speakers |
| Sponsor logos by tier | `grid({ cols: 3 })` per tier | Conference sponsors |
| Log viewer | `columns()` — service list left, log stream right | Server logs |
| Container table + details | `rows([panel({…}), panel({…})])` — table top, details bottom | Container management |
| Precise multi-column layout | `row()` + `col()` — 12-column grid system | Complex dashboards |
| Responsive card grid | `row()` with `xs:12, sm:6, lg:4` — cards reflow by terminal width | Portfolio, features |
| Centered narrow content | `container({ maxWidth: 80 })` — centered with max width | Blog posts, forms |

### 12-Column Grid System

`row()`, `col()`, and `container()` provide a Bootstrap-style 12-column grid for precise layouts.

```ts
import { row, col, container } from "terminaltui";

// Basic: 2 equal columns (span:6 each = 50%)
row([
  col([card({ title: "Left" })], { span: 6 }),
  col([card({ title: "Right" })], { span: 6 }),
])

// 3-column layout: sidebar + main + aside
row([
  col([menu], { span: 3 }),         // 25%
  col([mainContent], { span: 6 }),   // 50%
  col([aside], { span: 3 }),         // 25%
])

// Responsive — cards reflow based on terminal width
row([
  col([card1], { span: 4, sm: 6, xs: 12 }),  // 33% wide, 50% medium, full narrow
  col([card2], { span: 4, sm: 6, xs: 12 }),
  col([card3], { span: 4, sm: 12, xs: 12 }),
], { gap: 1 })

// Container — centers content with max width
container([
  row([
    col([hero(...)], { span: 12 }),    // full width
  ]),
  row([
    col([sidebar], { span: 3 }),
    col([content], { span: 9 }),
  ]),
], { maxWidth: 100, padding: 2 })
```

**ColConfig options:** `span` (1-12), `offset` (0-11), `xs`/`sm`/`md`/`lg` (responsive spans), `padding`.
**RowConfig options:** `gap` (between cols, default: 1).
**ContainerConfig options:** `maxWidth`, `padding`, `center` (default: true).

**Responsive breakpoints:** xs (<60 cols), sm (60-89), md (90-119), lg (>=120).

Spatial navigation works automatically — arrow keys move between col content based on screen position.

---

## Full API Reference

Every function below is imported from `"terminaltui"`.

### defineConfig(config): FileBasedConfig

Top-level project config. Default-export from `config.ts`.

```ts
interface FileBasedConfig {
  name: string;                                   // Required. Site name
  handle?: string;                                // Handle shown on home (e.g. "@user")
  tagline?: string;                               // Subtitle below the banner
  banner?: BannerConfig;                          // ASCII art banner (use ascii() helper)
  theme?: Theme | BuiltinThemeName;               // Theme object or name. Default: "dracula"
  borders?: BorderStyle;                          // Border style for cards/tables. Default: "rounded"
  animations?: AnimationConfig;                   // Boot animation + exit message
  navigation?: NavigationConfig;                  // Navigation behavior options
  middleware?: MiddlewareFn[];                    // Global middleware chain
  easterEggs?: EasterEggConfig;                   // Konami code and custom commands
  footer?: string | ContentBlock;                 // Footer content
  statusBar?: boolean | StatusBarConfig;          // Status bar configuration
  menu?: MenuConfig;                              // Auto-menu overrides
  serve?: ServeConfig;                            // SSH hosting config (see Hosting section)
  env?: Record<string, unknown>;                  // Env defaults
  artDir?: string | false;                        // Custom art directory path

  // Lifecycle hooks
  onInit?: (app: AppContext) => Promise<void> | void;
  onExit?: (app: AppContext) => Promise<void> | void;
  onNavigate?: (from: string, to: string, params?: RouteParams) => void;
  onError?: (error: Error, context: ErrorContext) => ContentBlock[] | void;
}
```

```ts
// config.ts
export default defineConfig({
  name: "My Site",
  handle: "@me",
  tagline: "a cool terminal site",
  banner: ascii("My Site", { font: "ANSI Shadow", gradient: ["#ff6b6b", "#4ecdc4"] }),
  theme: "dracula",
  borders: "rounded",
  animations: { boot: true, exitMessage: "Goodbye!", speed: "normal" },
  middleware: [requireEnv(["API_KEY"])],
  onInit: async (app) => { /* setup */ },
  onError: (err, ctx) => [markdown(`Error: ${err.message}`)],
});
```

### Page files

A page is any `.ts` file under `pages/`. Default-export a function returning content blocks; optionally export `metadata`.

```ts
// pages/about.ts
import { markdown, card } from "terminaltui";

export const metadata = {
  label: "About Me",            // menu label (default: title-cased filename)
  icon: "◆",                    // single char shown before label
  order: 2,                     // sort order in menu (lower first)
  hidden: false,                // hide from auto-menu (page still routable)
  middleware: [/* ... */],      // page-level middleware chain
};

export default function About() {
  return [markdown("Hello!"), card({ title: "Hi", body: "..." })];
}
```

Common icons: `"◆"` `"◈"` `"▣"` `"▤"` `"◉"` `"▸"` `"✦"` `"★"` `"●"` `"■"` `"▲"` `"♦"`

### Dynamic routes — `pages/[param].ts`

A bracketed filename creates a dynamic route. Params come in via the function arg:

```ts
// pages/projects/[slug].ts
export const metadata = { hidden: true };

export default async function Project({ params }: { params: { slug: string } }) {
  const data = await fetchProject(params.slug);
  return [card({ title: data.name, body: data.description })];
}
```

### navigate(pageId: string, params?: RouteParams): void

Programmatic navigation from anywhere (event handlers, middleware, etc.).

```ts
navigate("home");
navigate("projects/[slug]", { slug: "my-app" });
```

---

### Content Blocks

#### markdown(text: string): TextBlock

Renders text with markdown formatting (bold, italic, inline code, code blocks).

```ts
markdown("This is **bold** and *italic* with `code`.")
```

#### card(config): CardBlock

A bordered card with title, optional subtitle, body, tags, URL, and action.

```ts
interface CardBlock {
  title: string;          // Card heading
  subtitle?: string;      // Secondary text (price, date, star count)
  body?: string;          // Body text
  tags?: string[];        // Tags shown as badges
  url?: string;           // URL opened on Enter
  border?: BorderStyle;   // Override border style
  action?: CardAction;    // Action on select (navigate, onPress, etc.)
}

interface CardAction {
  label?: string;
  style?: "primary" | "secondary" | "danger";
  confirm?: string;                         // Confirmation prompt text
  onPress?: () => void | Promise<void>;
  navigate?: string;                        // Navigate to a page/route
  params?: RouteParams;                     // Route parameters
}
```

```ts
card({
  title: "My Project",
  subtitle: "★ 200",
  body: "A brief description.",
  tags: ["TypeScript", "Open Source"],
  url: "https://github.com/user/repo",
  action: { navigate: "project", params: { name: "my-project" } },
})
```

**List-to-Detail navigation pattern:** Use `action.navigate` on cards to link to detail pages. Mark detail pages as hidden so they don't appear in the menu.

```ts
// List page (pages/blog.ts)
export default function Blog() {
  return [
    card({ title: "First Post", action: { navigate: "blog-1" } }),
    card({ title: "Second Post", action: { navigate: "blog-2" } }),
  ];
}

// Detail page (pages/blog-1.ts)
export const metadata = { hidden: true };

export default function BlogPost1() {
  return [card({ title: "First Post", body: "Full content here..." })];
}
```

#### timeline(items: TimelineItem[]): TimelineBlock

Vertical timeline with connected entries. Great for work history, changelog, education.

```ts
interface TimelineItem {
  title: string;       // Entry heading
  subtitle?: string;   // Organization/company
  period?: string;     // Time range
  description?: string; // Details
}
```

```ts
timeline([
  { title: "Senior Engineer", subtitle: "Acme Corp", period: "2023 — present", description: "Leading platform team" },
  { title: "BS Computer Science", subtitle: "University", period: "2017 — 2021" },
])
```

#### table(headers: string[], rows: string[][]): TableBlock

A bordered data table.

```ts
table(
  ["Plan", "Price", "Features"],
  [
    ["Free", "$0/mo", "Basic features"],
    ["Pro", "$10/mo", "Everything + priority support"],
  ]
)
```

#### list(items: string[], style?): ListBlock

A styled list. Style: `"bullet"` (default) | `"number"` | `"dash"` | `"check"` | `"arrow"`.

```ts
list(["First item", "Second item", "Third item"], "check")
```

#### quote(text: string, attribution?: string): QuoteBlock

Block quote with optional attribution.

```ts
quote("The best way to predict the future is to invent it.", "— Alan Kay")
```

#### hero(config): HeroBlock

Large hero section with title, subtitle, CTA, and optional ASCII art.

```ts
interface HeroBlock {
  title: string;                              // Large heading
  subtitle?: string;                          // Description
  cta?: { label: string; url: string };       // Call-to-action link
  art?: string;                               // Custom ASCII art string
}
```

```ts
hero({ title: "Welcome", subtitle: "Build terminal apps.", cta: { label: "Get Started →", url: "https://..." } })
```

#### gallery(items): GalleryBlock

Grid of cards. Items use the same shape as `card()` (without `type`).

```ts
gallery([
  { title: "Photo 1", body: "Description", tags: ["nature"] },
  { title: "Photo 2", body: "Description", tags: ["urban"] },
])
```

#### tabs(items): TabsBlock

Tabbed content. Each tab has a label and nested content blocks.

```ts
tabs([
  { label: "Frontend", content: [list(["React", "Vue", "Svelte"], "check")] },
  { label: "Backend", content: [list(["Node.js", "Python", "Go"], "check")] },
])
```

#### accordion(items): AccordionBlock

Collapsible sections. Same shape as tabs. Great for FAQs.

```ts
accordion([
  { label: "What is terminaltui?", content: [markdown("A framework for building terminal websites.")] },
  { label: "How do I deploy?", content: [markdown("Run `terminaltui build` then `npm publish`.")] },
])
```

#### link(label: string, url: string, options?: LinkOptions): LinkBlock

A clickable link. Opens in the user's browser when selected.

```ts
interface LinkOptions {
  icon?: string;   // Icon character before the label
}
```

```ts
link("GitHub", "https://github.com/user")
link("Email", "mailto:hello@example.com", { icon: "✉" })
```

#### progressBar(label: string, value: number, max?: number): ProgressBarBlock

Generic progress bar. Max defaults to 100. Always shows percent.

```ts
progressBar("Project Alpha", 7, 10)
progressBar("Completion", 65)
```

#### skillBar(label: string, value: number): ProgressBarBlock

Shorthand for `progressBar(label, value, 100)` with `showPercent: true`.

```ts
skillBar("TypeScript", 90)
skillBar("Rust", 75)
```

#### badge(text: string, color?: string): BadgeBlock

An inline badge/tag. Color is a hex string.

```ts
badge("v2.0")
badge("NEW", "#50fa7b")
```

#### image(path: string, options?): ImageBlock

Renders an image in the terminal.

```ts
image("./logo.png")
image("./photo.jpg", { width: 60, mode: "braille" })
```

Options: `width?: number`, `mode?: "ascii" | "braille" | "blocks"`.

#### section(title: string, content: ContentBlock[]): SectionBlock

Groups content under a titled section header with a divider line.

```ts
section("Appetizers", [
  card({ title: "Bruschetta", subtitle: "$12", body: "Toasted bread with tomatoes" }),
])
```

#### divider(style?, label?): DividerBlock

Horizontal divider line. Styles: `"solid"` | `"dashed"` | `"dotted"` | `"double"` | `"label"`. If the first arg is not a known style, it becomes a label automatically.

```ts
divider()                    // solid line
divider("dashed")            // dashed line
divider("My Section")        // labeled divider (auto-detected)
divider("label", "Section")  // explicit label style
```

#### spacer(lines?: number): SpacerBlock

Vertical whitespace. Defaults to 1 line.

```ts
spacer()     // 1 blank line
spacer(3)    // 3 blank lines
```

#### dynamic(renderFn) / dynamic(deps, renderFn): DynamicBlock

Reactive content block that re-renders when state changes. Currently all dynamic blocks re-render on any state change. The deps array is accepted for forward compatibility.

```ts
// Re-renders on any state change
dynamic(() => markdown(`Count: ${state.get("count")}`))

// Deps accepted for forward compatibility (currently re-renders on any change)
dynamic(["count"], () => markdown(`Count: ${state.get("count")}`))
```

#### asyncContent(config): AsyncContentBlock

Lazily-loaded async content.

```ts
asyncContent({
  load: async () => {
    const data = await fetchData();
    return [card({ title: data.name, body: data.description })];
  },
  loading: "Loading data...",
  fallback: [markdown("Failed to load.")],
})
```

---

### Box Model

Every component uses a unified box model via `computeBoxDimensions()` from `src/layout/box-model.ts`. One function, one contract, one source of truth for width calculations.

```
+---------------- allocated width -----------------+
| margin                                           |
|  +------------ outer width -----------------+   |
|  | border                                    |   |
|  |  +-------- inner width ---------------+   |   |
|  |  | padding                            |   |   |
|  |  |  +---- content width -----------+  |   |   |
|  |  |  |                              |  |   |   |
|  |  |  |  Text wraps here.            |  |   |   |
|  |  |  |  Children render here.       |  |   |   |
|  |  |  |                              |  |   |   |
|  |  |  +------------------------------+  |   |   |
|  |  +------------------------------------+   |   |
|  +-------------------------------------------+  |
+--------------------------------------------------+

content = allocated - (margin * 2) - (border * 2) - (padding * 2)
```

**Width cascade:**
```
Terminal width (e.g. 120 cols)
  -> createRenderContext(): ctx.width = Math.min(terminalWidth, 100)
    -> renderContentPage(): blockWidth = ctx.width - 1 (focus prefix)
      -> Component gets blockWidth as ctx.width
        -> dims = computeBoxDimensions(ctx.width, COMPONENT_DEFAULTS.componentType)
          -> Text wraps at dims.content
          -> Child blocks receive dims.content as their width
```

**API:**
```ts
import { computeBoxDimensions, COMPONENT_DEFAULTS } from "terminaltui";
import type { BoxDimensions, BoxOptions } from "terminaltui";

const dims = computeBoxDimensions(80, { border: true, padding: 1 });
// dims.content = 76 (80 - 2 border - 2 padding)

// Using component defaults
const cardDims = computeBoxDimensions(80, COMPONENT_DEFAULTS.card);
// cardDims.content = 76

// Override per-instance
const widePad = computeBoxDimensions(80, { ...COMPONENT_DEFAULTS.card, padding: 2 });
// widePad.content = 74
```

**Defaults quick reference:**

| Component    | Border | Padding | Margin | Chrome | Content at w=80 |
|-------------|--------|---------|--------|--------|-----------------|
| card         | 1      | 1       | 0      | 4      | 76              |
| text         | 0      | 0       | 0      | 0      | 80              |
| hero         | 0      | 0       | 0      | 0      | 80              |
| table        | 1      | 0       | 0      | 2      | 78              |
| quote        | 1      | 1       | 1      | 6      | 74              |
| timeline     | 1      | 1       | 1      | 6      | 74              |
| accordion    | 0      | 2       | 0      | 4      | 76              |
| tabs         | 0      | 2       | 0      | 4      | 76              |
| textInput    | 1      | 1       | 0      | 4      | 76              |
| select       | 1      | 1       | 0      | 4      | 76              |
| button       | 1      | 2       | 1      | 8      | 72              |
| badge        | 0      | 0       | 0      | 0      | 80              |
| progressBar  | 0      | 0       | 0      | 0      | 80              |
| divider      | 0      | 0       | 0      | 0      | 80              |
| image        | 1      | 0       | 0      | 2      | 78              |

**Rules:**
- Every component calls `computeBoxDimensions()`. No exceptions.
- Layout components (columns, rows, grid, panel, row, col, container) divide width among children — they do NOT call `computeBoxDimensions()` for themselves.
- Text always wraps at `dims.content`.
- Child blocks receive `dims.content` as their allocated width.
- No manual `ctx.width - N` in component files. All chrome subtraction goes through the box model.

---

### Layout Components

Layout components divide the terminal into panels — side-by-side, stacked, or in grids. Each panel is an independent area with its own content. Panels can have borders, titles, and content clipping.

**Navigation**: Tab/Shift+Tab switches between panels. Arrow keys navigate within the active panel. The active panel gets an accent-colored border.

**Responsive**: If the terminal is too narrow for side-by-side panels (<20 chars per panel), columns automatically collapse to vertical stacking.

#### columns(panels: PanelConfig[]): ColumnsBlock

Side-by-side panels. Each panel gets a `width` (percentage, fixed chars, or auto).

```ts
columns([
  panel({ width: "60%", content: [
    table(["Name", "Status"], [["nginx", "running"], ["postgres", "running"]]),
  ]}),
  panel({ width: "40%", content: [
    markdown("## Stats"),
    progressBar("CPU", 45),
    progressBar("Memory", 72),
  ]}),
])
```

#### rows(panels: PanelConfig[]): RowsBlock

Vertically stacked panels with fixed/flex heights.

```ts
rows([
  panel({ height: "30%", content: [
    markdown("## Active Containers"),
    table(["Name", "Status"], [["nginx", "up"], ["postgres", "up"]]),
  ]}),
  panel({ height: "70%", content: [
    markdown("## Logs"),
    markdown("12:00:01 [nginx] GET /health 200"),
    markdown("12:00:02 [nginx] GET /users 200"),
  ]}),
])
```

> **Deprecated:** `split({ direction, ratio, first, second })` still works but is now a thin wrapper that returns a `columns()` (horizontal) or `rows()` (vertical) block. Will be removed in v2.0. Prefer the explicit form: `columns([panel({ width: "30%", content: first }), panel({ width: "70%", content: second })])`.

#### grid(config: GridConfig): GridBlock

N×M grid of panels. `cols`: number of columns. `gap`: character gap between cells (default 1).

```ts
grid({
  cols: 2,
  gap: 1,
  items: [
    panel({ title: "CPU", content: [progressBar("Usage", 45)] }),
    panel({ title: "Memory", content: [progressBar("RAM", 72)] }),
    panel({ title: "Disk", content: [progressBar("Usage", 31)] }),
    panel({ title: "Network", content: [markdown("125 Mbps")] }),
  ],
})
```

#### panel(config: PanelConfig): PanelBlock

A single panel with optional border, title, padding, and content clipping. Used inside `columns()`, `rows()`, `grid()`, or standalone.

```ts
interface PanelConfig {
  content: ContentBlock[];
  width?: string | number;      // "50%", "40%", 30 (chars). For columns.
  height?: string | number;     // "50%", "40%", 10 (rows). For rows.
  title?: string;               // Title in the top border
  border?: boolean | BorderStyle; // Show border (default: true in layouts)
  padding?: number;             // Interior padding (default: 0)
  scrollable?: boolean;         // Independent scrolling (default: true)
  focusable?: boolean;          // Can receive focus (default: true if has focusable content)
}
```

#### Nested Layouts

Layouts can be nested for complex dashboards:

```ts
columns([
  panel({ width: "25%", title: "Navigation", content: [
    link("Dashboard", "#"),
    link("Logs", "#"),
    link("Settings", "#"),
  ]}),
  panel({ width: "75%", content: [
    rows([
      panel({ height: "60%", content: [
        markdown("## Main Content"),
        table(["Name", "Status"], [["nginx", "running"]]),
      ]}),
      panel({ height: "40%", title: "Logs", content: [
        markdown("Log output here..."),
      ]}),
    ]),
  ]}),
])
```

#### Sizing Reference

| Context | Property | Values |
|---------|----------|--------|
| columns | `width` | `"50%"`, `30` (chars), `"auto"` (default: equal split) |
| rows | `height` | `"50%"`, `10` (rows), `"auto"` (default: equal split) |
| grid | `cols` | Number of columns |
| grid | `gap` | Gap in characters (default: 1) |

---

#### container(content: ContentBlock[], config?): ContainerBlock

Wrap content in a centered container with an optional max width and padding. Use as the outermost wrapper of a page when you want a Bootstrap-style centered layout.

```ts
container([
  hero({ title: "Welcome" }),
  row([
    col([card({ title: "Left" })], { span: 6 }),
    col([card({ title: "Right" })], { span: 6 }),
  ]),
], { maxWidth: 100, padding: 2, center: true })
```

```ts
interface ContainerConfig {
  maxWidth?: number;   // Max width in columns (default: terminal width)
  padding?: number;    // Horizontal padding (default: 0)
  center?: boolean;    // Center the container (default: true)
}
```

#### row(cols: ColBlock[], config?): RowBlock

A 12-column grid row. Children must be `col(...)` blocks. Rows auto-wrap when the sum of effective spans exceeds 12 at the current breakpoint.

```ts
row([
  col([statsCard], { span: 3, xs: 12 }),
  col([chartCard], { span: 9, xs: 12 }),
], { gap: 1 })
```

```ts
interface RowConfig {
  gap?: number;  // Spacing between cols, in chars (default: 1)
}
```

#### col(content: ContentBlock[], config: ColConfig): ColBlock

A 12-column grid cell. `span` is required; `xs`/`sm`/`md`/`lg` override `span` at each breakpoint.

```ts
col([card({ title: "Stats" })], {
  span: 4,
  offset: 0,
  xs: 12, sm: 6, md: 4, lg: 3,
})
```

```ts
interface ColConfig {
  span: number;     // 1-12. Width as a fraction of 12 columns.
  offset?: number;  // 0-11. Empty columns to the left.
  padding?: number; // Interior padding
  xs?: number;      // Override span for xs (<60 cols)
  sm?: number;      // Override span for sm (60-89)
  md?: number;      // Override span for md (90-119)
  lg?: number;      // Override span for lg (≥120)
}
```

**Breakpoints:** xs (<60 cols), sm (60-89), md (90-119), lg (≥120). Spatial navigation works automatically across grid cells.

> Removed in this release: `box()`. For a bordered region, use `panel({ border: true, padding: 1, content: […] })`. For padding/margin only, use `container({ padding: 1, content: […] })`.

#### menu(config: MenuConfig): MenuBlock

Inline menu block. The `auto` source resolves at render time from the file-based router's discovered pages.

```ts
import { menu } from "terminaltui";

menu({ source: "auto" })

menu({
  source: "manual",
  items: [
    { id: "home", label: "Home", icon: "◆" },
    { id: "projects", label: "Projects", icon: "▣" },
    { id: "contact", label: "Contact", icon: "◉" },
  ],
})
```

```ts
interface MenuConfig {
  source: "auto" | "manual";
  items?: MenuItemConfig[];   // required when source === "manual"
}

interface MenuItemConfig {
  id: string;        // page id or route
  label: string;
  icon?: string;
  hidden?: boolean;
}
```

> The framework already renders the home menu automatically. Don't add `menu({ source: "auto" })` to `pages/home.ts` — it'll duplicate the menu.

---

### Input Components

All input components create interactive form elements. In navigation mode, press Enter on an input to enter edit mode; press Escape to return to navigation.

#### textInput(config): TextInputBlock

```ts
interface TextInputBlock {
  id: string;                                    // Unique input ID
  label: string;                                 // Label text
  placeholder?: string;                          // Placeholder text
  defaultValue?: string;                         // Initial value
  maxLength?: number;                            // Max character count
  validate?: (value: string) => string | null;   // Return error message or null
  mask?: boolean;

…(truncated)
