---
title: TUI Design Decisions
description: Canonical design rules for the jackin' terminal UI — authoritative reference for all TUI development
---

import RepoFile from '../../../components/RepoFile.astro';

This document is the **canonical record of every binding TUI design decision** — navigation conventions, focusability rules, component reuse contracts, color palette, modal sizing, scroll semantics, and more. Read it before implementing any TUI change. When a new decision is made, add it here immediately.

If a proposed implementation conflicts with a rule here, fix the implementation, not the rule (or open a discussion to amend the rule first). Violations are bugs and must be fixed before a PR lands.

---

## Navigation conventions

Jackin's TUI follows the **W3C ARIA Tabs interaction pattern** for all tabbed interfaces and the corresponding area-scoped arrow-key model everywhere else.

### Key roles

**Tab / BackTab** are the designated cross-area navigation keys. They move focus between logical areas: from the field area to the button row, back from the button row to the field area, and between a tab list and its tab panel (content area). Tab always moves forward in the cycle; BackTab always moves backward.

**Left / Right** are intra-area horizontal keys. They move focus within the current horizontal group (e.g. between buttons in a button row, or between tabs when the tab list has focus). They must never jump between distinct areas.

**Up / Down (and j / k aliases)** are intra-area vertical keys. They move focus within the current vertical field group. They must never cross the boundary from a field area into the button area or vice versa.

### W3C Tabs pattern (required for all tabbed interfaces)

All tabbed surfaces (workspace editor, settings) must implement the W3C tablist/tabpanel pattern:

1. **Tab list** (the row of tab labels) is its own focus area:
   - `←` / `→` cycle between tabs.
   - `Tab` or `↓` moves focus into the first block inside the tab panel (content area).
   - The active tab is visually distinguished even without tab-list focus; when the tab list IS focused, an additional highlight (e.g. bold or underline) makes this clear.

2. **Tab panel** (the content below the tabs) may contain one or more blocks:
   - `↑` / `↓` navigate within the focused block.
   - `Tab` advances to the next block within the panel; after the last block `Tab` cycles back to the tab list.
   - `BackTab` / `Esc` returns focus to the tab list.

3. Entry into a tabbed stage (opening Settings, opening the workspace editor) must start with **tab-list focus** — the operator sees the tab labels highlighted and uses `←` / `→` to pick a tab before `Tab` / `↓` drops them into the content.

This rule applies to every tabbed surface that ships; add a `tab_bar_focused: bool` field to the relevant state struct and update input dispatch and rendering accordingly whenever a new tabbed surface is added.

### Other area-boundary rules

- Down from the last field in a form's field area is a no-op. Tab is the key that crosses into the button area.
- Up from the first button in the button area is a no-op. BackTab is the key that crosses back into the field area.

**Exception — tree header expand/collapse:** Right = expand and Left = collapse on tree-style header rows (Secrets AgentHeader, Auth RoleHeader, Environments RoleHeader, and workspace manager list rows) is a correct intra-area semantic action. This absorption pattern is intentional and must be preserved. For the workspace manager list specifically: `→` on a workspace row expands it to reveal instance child rows; `←` on an instance row collapses the parent workspace and moves the cursor to it; `←` on an already-expanded workspace row collapses it in place. The `h`/`l` bindings are reserved for horizontal scrolling of the details pane and must not be aliased to expand/collapse.

**Exception — flat button-strip widgets:** Self-contained single-row button widgets (`confirm.rs`, `save_discard.rs`, `mount_dst_choice.rs`, `source_picker.rs`, `scope_picker.rs`) may treat Left / Right as BackTab / Tab equivalents. The entire widget is a single flat area with no area boundary to cross, so there is no violation.

### Navigation hints (exhaustive — no hidden keys)

**Every keyboard shortcut that is active on the current screen must appear in the footer hint bar. No key may be silently available — users must never have to guess.** The hint bar updates whenever the focused area or row context changes.

Rules:
- `↑/↓ navigate` must appear whenever a list, table, or form with multiple rows is active. It is a global footer item, not a conditional one.
- Every action key (`Enter`, `Space`, `D`, `A`, `R`, `N`, `1`, `2`, `3`, `O`, `P`, `M`, etc.) that is live on the current row must be shown alongside its one-word description.
- When a key applies only under certain conditions (e.g. `O open in GitHub` only for GitHub-origin mounts), show it only when the condition holds — but never suppress a key that is unconditionally active.
- `Tab switch tab`, `S save`, and `Esc back/discard` are always shown in the global footer.
- Keep each hint concise: one symbol or key name, one word label. Use `·` separators between hints.

This rule applies to every TUI surface: list view, workspace editor (all tabs), settings (all tabs), all modals and overlays.

### Space vs Enter — W3C semantic distinction

**`Space`** is the key for **toggle** semantics (on/off, checked/unchecked, trusted/untrusted, allowed/disallowed). It matches the W3C ARIA patterns for checkbox, switch, and radio group. Never bind Enter to the same action as Space on a toggle widget.

**`Enter`** is the key for **action** semantics (activate a button, open a dialog, confirm a choice, navigate into detail, submit a form). It is also the key for shortcut-letter bindings (`Y` yes, `N` no, `S` save, `D` discard, etc.) on confirmation dialogs.

Binding both `Space` and `Enter` to the same toggle action is a W3C violation — Space is sufficient and Enter would collide with its role as the action key. Binding both to a button widget is acceptable (W3C requires both on standard buttons), but jackin's button-strip widgets use letter shortcuts + Enter to avoid this; don't add Space to them.

Summary:
- Toggle row (keep_awake, git_pull, trusted, allowed role): `Space` only.
- Action button (Save, Cancel, Discard, Yes, No): `Enter` only (plus letter shortcut if applicable).
- Open/navigate (expand detail, open picker, rename): `Enter` only.
- Cycle among options in a slot (auth mode): `Space` (radio-button pattern per W3C).
- Re-open a picker for an already-set value (1Password op-ref, source): `Enter` (action). `P` is also supported as a shortcut where op_available is true, but `Enter` must always work.

### Hints: footer only, never inside dialogs

All keyboard hint text belongs in the footer bar at the bottom of the screen. Dialogs, modals, and overlays **must not** render their own internal hint line. When a modal is open, the main footer must show the modal's available keys instead of the keys for the content behind it.

Enforcement:
- Every widget in `src/console/widgets/` must expose a `footer_items` function (or equivalent) and remove its internal hint row.
- `render_editor` checks `state.modal` first; if a modal is open it calls `modal_footer_items(modal)` and skips the normal contextual items.
- `render_settings` checks the three settings modal chains (`auth.modal`, `env.modal`, `mounts.modal`) in priority order; if any is `Some`, calls the corresponding `*_modal_footer_items` function.
- No `Constraint::Length` row for a hint line may remain inside any widget layout.

---

## Focusability

**A block is focusable only when its content overflows the visible area in at least one axis (horizontal or vertical).**

When a block's content fits entirely within its viewport, clicking it must not show the green border and keyboard scroll keys must not change its scroll offset. A block that is not scrollable in any direction has nothing to communicate with a focus highlight, so showing one is actively misleading — the green border tells the operator "you can scroll here" and the operator will try, get no response, and lose trust in the UI.

### Why this matters

The green border is the affordance for scroll. Operators use it as a signal: click to focus, then use H/L/Up/Down/scroll-wheel to navigate content that doesn't fit. If a block highlights green but has no overflow, the signal is a lie. Every interaction after that — pressing scroll keys, watching nothing happen — erodes trust in the UI conventions.

### Implementation

`render_scrollable_block` enforces the rule automatically: it computes `content_width`, `content_height`, `viewport_w`, and `viewport_h` from the lines it is about to render, checks `is_scrollable(content_width, viewport_w) || is_scrollable(content_height, viewport_h)`, and ignores the `focused` flag when the result is false. Callers pass `focused = true` or `false` from their state; the renderer suppresses the green border if the content currently fits.

This means the rule is **impossible to accidentally violate** through the shared renderer. What callers must still ensure:

1. **Mouse click handler (`update_scroll_focus`)** — set the `focused` / `scroll_focused` state field for any block whose hit-test rect contains the click, **regardless of whether the block is currently scrollable**. The renderer already suppresses the green border when content fits; the state field only needs to be accurate for keyboard scroll dispatch (if the block isn't scrollable, scroll keys are no-ops anyway). Do NOT add `is_scrollable` guards in the click handler — that causes a different bug where clicking a non-scrollable block silently clears focus from the previously focused block without giving it to the clicked one.

2. **Resize guard** — when the terminal shrinks and content that previously overflowed now fits, `list_scroll_focus` (list view) is cleared by `focused_block_still_scrollable`. Similar guards must exist for any persistent focus state that a terminal resize can invalidate.

Both checks must use the **same content height and width values** as `render_scrollable_block` uses for the actual rendered lines. Any mismatch causes the bug where the input handler clamps scroll to the wrong maximum — the scrollbar thumb stops short of the track end even though the operator has not reached the actual content limit.

### Click hit-test area must cover the full rendered block

**The mouse hit-test rect for a scrollable block must exactly match (or be a superset of) the `area` passed to `render_scrollable_block`.** Never compute the hit-test area from content dimensions or row counts — use the full rendered block height.

**Why this matters:** A block that holds N content rows plus an empty trailing area is still one block. The empty rows are inside the border; they are visually part of the same scrollable surface. Operators intuitively click anywhere inside the block — including blank space below the last row — and expect focus to switch. If the hit-test rect is computed from content height alone (e.g. `height = rows + 2`), clicks in the empty trailing area fall outside the rect and silently do nothing. The operator sees their click ignored and infers the UI is broken.

**Implementation rule:** Every `point_in(mouse, area)` check in `update_scroll_focus` must use the same `area` that the renderer passes to `render_scrollable_block`. In practice this means:

- For full-body blocks (editor Mounts tab, editor other tabs, settings content): use `editor_content_area(term_size)` — the full body rect below header + tab strip. Do not shrink this to content height.
- For per-block sub-areas in the list view right pane (Workspace mounts, Global mounts, Roles): use the height computed by the same block-height function the renderer's `Constraint::Length(...)` uses.

In `mouse.rs`, `editor_scroll_area` wraps `editor_content_area` to guarantee they stay in sync — `editor_scroll_area` must never compute its own area dimensions independently.

A violated hit-test rect is a silent bug: no compile error, no test failure, and no obvious visual artifact — the block just refuses to focus when clicked in the empty trailing area.

### Content height formula for each block type

These are the exact formulas that match what each render function passes to `render_scrollable_block`:

| Block | `content_height` |
|---|---|
| Workspace mounts | `1 + max(data_rows, 1)` where `data_rows = sum of 1-or-2 per mount (1 if src==dst, 2 otherwise)` |
| Global mounts / Role global mounts | `1 + data_rows` where `data_rows = sum of 1-or-2 per mount (1 if src==dst, 2 otherwise)`; empty sections render one placeholder row |
| Roles | `2 + agent_count` (1 "Default …" row + 1 blank row + agent rows) |
| Left sidebar (workspace names) | no vertical scroll — use `clamp_list_scroll_for_area` horizontal-only path |

The workspace mounts formula uses `.max(1)` because an empty mount list renders a "(none)" placeholder row, so even zero mounts produce 1 data row.

---

## Scrollable Blocks — Shared Component

**All bordered scrollable content blocks must use `render_scrollable_block` from `render/mod.rs`.** Never hand-roll block creation, Paragraph scroll, or scrollbar rendering inline.

`render_scrollable_block` guarantees:
- Border color `PHOSPHOR_GREEN` when focused, `PHOSPHOR_DARK` otherwise.
- Horizontal scrollbar rendered only when content overflows horizontally.
- Vertical scrollbar rendered only when content overflows vertically.
- `*scroll_x` and `*scroll_y` clamped to valid range in-place every frame (eliminates stale overshoot).
- Scrollbar thumbs are proportional to visible content in both axes, so small overflow produces a long thumb and large overflow produces a short thumb, while a block scrolled to its maximum offset still shows the thumb at the visible end of the track.
- Scrollbar thumb length is invariant for a fixed `content_length` + `viewport` + track length. Scrolling moves the thumb; it must never resize by one cell because of offset-dependent rounding.
- Horizontal scrolling preserves matching trailing padding for each row's leading padding, so two-space-indented tables do not pin their final visible character against the right border or scrollbar column at maximum horizontal offset.

Input hit-testing, focusability, cursor-follow scrolling, selected-list windowing, scrollbar dragging, wheel/key max-offset math, right-padding content width, and scrollbar rendering must use the shared scroll helpers in <RepoFile path="src/console/widgets/scrollable.rs" /> (`viewport_width`, `viewport_height`, `is_scrollable`, `max_offset`, `effective_offset`, `cursor_follow_offset`, `render_selected_lines_in_area`, `render_lines_with_offset_in_area`, `horizontal_scrollbar_area`, `vertical_scrollbar_area`, `scrollbar_position_for_offset`, `scrollbar_offset_for_track_position`, `apply_scroll_delta`, and `apply_horizontal_scroll_delta`). Do not reimplement `area.width - 2`, `area.height - 2`, `content - viewport`, cursor-follow viewport offsets, selected-list slicing, proportional thumb sizing, trailing scroll padding, scrollbar hit areas, track-position formulas, or wheel/key offset math in input handlers or widget renderers; drift between input geometry and renderer geometry is a scroll bug. Do not delegate scrollbar thumb geometry to Ratatui's `ScrollbarState`: its thumb size is computed via floating-point division from `content_length`, so passing a wrong `content_length` produces an offset-dependent size; and even with the correct argument, independent rounding of thumb start and end can still produce a one-cell resize at the boundaries.

Signature:
```rust
pub(crate) fn render_scrollable_block(
    frame: &mut Frame,
    area: Rect,
    lines: Vec<Line<'_>>,
    scroll_x: &mut u16,
    scroll_y: &mut u16,
    focused: bool,
    title: Option<&str>,
)
```

Pass `None` for `title` to suppress the block title. Pass `Some(" Title ")` (with surrounding spaces) to match the standard label style.

---

## Focus Persistence

**`list_names_focused` (left sidebar) is cleared only when:**
1. The user clicks in the right pane — `update_scroll_focus` sets it to `false`.
2. The terminal resizes so the content fits — `clamp_list_scroll_for_area` sets it to `false`.

It must **not** be cleared by `reset_list_scroll`. Workspace selection changes keep the user in the left pane; the focus state should persist.

**`list_scroll_focus` (right-pane blocks) is cleared when:**
1. Content no longer overflows after resize — `focused_block_still_scrollable` returns `false`.
2. User clicks left pane — `update_scroll_focus` clears it.
3. Workspace changes — `reset_list_scroll` sets it to `None`.

---

## Mouse Scroll (Hover-Scroll)

Mouse `ScrollLeft`/`ScrollRight` scroll whichever block the cursor is currently over **without requiring a prior click**. This is hover-scroll semantics, not click-then-scroll.

In `scroll_active_panel`, dispatch order for the list stage:
1. If `list_names_focused` → scroll `list_names_scroll_x` (left pane).
2. Otherwise → compute `list_scroll_areas`, route to the block under the pointer.

The left pane scroll uses `apply_scroll_delta` (no clamping to content width) because `render_scrollable_block` clamps on every frame anyway.

---

## PHOSPHOR Color Palette

Canonical definitions live in <RepoFile path="src/console/widgets/mod.rs" />. All other files use `pub(super) use` re-exports from `render/mod.rs`. Never define local constants.

| Constant | Value | Usage |
|---|---|---|
| `PHOSPHOR_GREEN` | `Rgb(0, 255, 65)` | Active/focused elements, selected text |
| `PHOSPHOR_DIM` | `Rgb(0, 140, 30)` | Inactive text, scrollbar thumbs |
| `PHOSPHOR_DARK` | `Rgb(0, 80, 18)` | Borders (unfocused), separators |
| `WHITE` | `Rgb(255, 255, 255)` | Labels, keys, headings |

---

## Block Border Colors

- **Focused and scrollable**: `PHOSPHOR_GREEN` border — both `focused = true` AND content overflows in at least one axis.
- **Focused but not scrollable**: `PHOSPHOR_DARK` border — `focused = true` is ignored when content fits; no false affordance.
- **Unfocused**: `PHOSPHOR_DARK` border regardless of scrollability.

`render_scrollable_block` enforces this logic internally. Callers only supply `focused: bool` from state; the renderer decides whether to use green. Never set border style outside `render_scrollable_block`.

---

## Left Sidebar (Workspace Name List)

- Horizontal scroll only — no vertical scroll.
- Uses `render_scrollable_block` with `scroll_y = 0u16` (a throwaway variable; vertical scrollbar never appears because line count always fits the list height).
- H/L keys and `ScrollLeft`/`ScrollRight` both update `state.list_names_scroll_x`.
- Focus set on click via `update_scroll_focus`; persists until user clicks the right pane.
- Focus cleared by `clamp_list_scroll_for_area` when content width ≤ viewport (content fits horizontally).

---

## Tab Bar

**W3C ARIA Tabs pattern** (see `AGENTS.md` → TUI navigation conventions for full spec). Summary:

- Tab bar has its own focus area (`tab_bar_focused: bool` in state).
- **Tab bar focused**: active tab shows `fg(WHITE) + BOLD`; underline bar (`━━━━`) beneath the active tab label.
- **Content focused** (tab bar not focused): active tab shows `bg(PHOSPHOR_GREEN) + fg(Black) + BOLD`; no underline bar.
- `←`/`→` cycle tabs when tab bar is focused.
- `Tab`/`↓` moves focus from tab bar into the first content block.
- `BackTab`/`Esc` returns focus to the tab bar from content.

---

## Modal Sizing Rules

Modals use `centered_rect_fixed(outer, pct_w, rows)`. `rows` is the **total outer height** including both borders. Inner rows = `rows - 2`.

| Modal | `pct_w` | `rows` | Reasoning |
|---|---|---|---|
| Scope picker | 50 | 4 | 1 button row + 2 borders + 1 spacer |
| Text input | 60 | 5 | 1 input row + 2 borders + 2 label/padding |
| Role picker | auto | `filtered.len() + 6` | filter row + spacer + roles + border overhead |

Never add excess padding rows to modals. The outer rect must be exactly tight enough to render the widget's layout constraints without blank overflow rows.

---

## Hints / Footer Bar

Every keyboard shortcut active on the current screen must appear in the footer. No hidden keys. See `AGENTS.md` → **Navigation hints** for the complete rule.

When a scrollable block is focused, the footer must show scroll direction hints:
- Horizontal-only block: `←/→ scroll block`
- Vertical-only block: `↑/↓ scroll block`  
- Both axes: `↑/↓/←/→ scroll block`

The hints update dynamically based on which block is focused and which axes overflow.

---

## Env / Secrets Sentinel Indentation

`+ Add environment variable` sentinel at workspace level must align with workspace-level content (2-char `cursor_col` indent only). It must **not** pick up the 5-space `marker_col` that role-level rows use.

Role-level sentinels (`RoleAddSentinel`) and role section headers keep their `"     "` (5-space) indentation to visually group them under the role section.

---

## Settings ↔ Workspace Editor Parity

The **settings panel** (`ManagerStage::Settings`, global config) and the **workspace editor** (`ManagerStage::Editor`, per-workspace config) are parallel tabbed surfaces. Their tabs mirror each other:

| Settings tab | Workspace editor analog |
|---|---|
| Mounts | Mounts |
| Environments | Secrets |
| Auth | Auth |
| Trust | (no direct analog) |

They share the same visual language, the same tab-bar interaction pattern, and the same scrollable-block conventions. Operators move between them constantly and expect consistent behavior.

**Rule: every fix or addition that lands in one panel must immediately be audited against the other panel.** If the same issue exists there, fix it in the same PR. This is not optional polish — inconsistent behavior between the two panels is a bug.

### What to check on every change

When modifying settings tabs, verify the workspace editor tabs (and vice versa):

- **Scroll focus wiring**: every tab must have a `scroll_focused` field (or use a shared field like `tab_content_scroll_focused`) that is set by the mouse click handler and passed as `focused` to `render_scrollable_block`. No tab may hardcode `focused = false`.
- **Vertical scroll state**: every tab that can overflow vertically must have a `scroll_y` state field that `render_scrollable_block` mutates in-place. Never use a throwaway local `no_scroll_y = 0u16` unless the tab is genuinely designed to be non-scrollable vertically (and even then, verify the counterpart tab makes the same choice intentionally).
- **Content width agreement**: the content width used by the mouse wheel and drag handlers must be computed from the same line-building function the renderer uses. Mismatched width computations make the scrollbar thumb stop short of the track end.
- **Mouse wheel vertical dispatch**: `scroll_active_panel_vertical` must route to the correct `scroll_y` field for every tab in both panels. A missing branch silently drops scroll events.
- **Focus highlight**: the green border must appear when and only when the block is focused and scrollable, in both panels identically.

### Why this rule exists

Operators use both surfaces regularly. When Settings Auth scrolls but workspace editor Auth does not (or vice versa), the UI feels broken even if each panel works correctly in isolation. The two panels look alike; they must act alike. Divergence accumulates invisibly until a new operator encounters it and files a bug.

The **shared renderer** (`render_scrollable_block`, `render_scrollable_block_focused`) and **shared scroll helpers** (`scrollable.rs`) enforce visual consistency automatically. The state wiring and mouse handlers are the remaining surface where inconsistency can creep in — which is why this explicit audit rule exists.

## Error Surface — ErrorPopup Only, No Ephemeral Overlays

All error conditions surfaced to the operator must use the red-border `ErrorPopup` dialog (<RepoFile path="src/console/widgets/error_popup.rs" />). Ephemeral overlays (shimmer banners, auto-expiring toasts, single-line status strips) are banned for error display.

**Why:** Auto-expiring errors vanish before the operator reads them, have no scroll support for long messages, and render over content rather than blocking it. `ErrorPopup` is dismissible (Enter / Esc / O), scrollable, and modal — the operator cannot miss it or accidentally dismiss it.

**Scope:** Every error path in every stage (list, editor, settings, create-prelude) must route through the stage's designated error slot:

- **List stage**: `ManagerState.list_modal = Some(Modal::ErrorPopup { .. })`
- **Editor stage**: `EditorState.modal = Some(Modal::ErrorPopup { .. })`
- **Settings stage**: `SettingsState.error_popup = Some(ErrorPopupState::new(title, body))`; the `after_settings_event` helper promotes errors from sub-tab `.error` fields into this slot.

**Background errors** (e.g., `refresh_instances` index parse failures) use the list-stage `list_modal` slot with a dedup gate (`instances_last_error`) to prevent the popup from reopening on every 20 Hz refresh tick.

**Success feedback** is silent. Navigation back to the workspace list after a successful save is sufficient confirmation. No success toast, no success popup.

## Component Reuse — Strict Rule

When two TUI surfaces implement the same flow, do not fork modal sizing, rendering, input handling, footer wording, row layout, or picker behavior into a second near-copy. Extract or extend a shared widget, renderer, input helper, or state adapter and have both surfaces call it.

Settings screens that intentionally mirror workspace screens must reuse the workspace widgets and flow helpers wherever behavior is the same; keep separate code only for the different persistence target or config scope. Treat visual drift between duplicated TUI components as a bug. If reuse is blocked, call that out in the PR and keep the duplicate small and temporary.

Every visual pattern that appears in more than one place **must** use one shared implementation — a single widget, render function, or helper. Never create a second near-copy of an existing widget or render function. When the existing implementation cannot serve a new call site without modification, extend it (add parameters, generalize) rather than forking it.

When a genuinely new component is needed (no existing implementation fits the UX pattern):
1. The agent proposes the design with a sketch or description.
2. The operator explicitly approves the design.
3. Only then may the agent implement it.

Violations — two functions that render the same kind of content with slightly different styling or behavior — must be fixed before a PR lands by consolidating them into one.
