TUI Design

Navigation & Input

Key binding roles, W3C Tabs pattern, navigation hints, focusability rules, hover-scroll semantics, and debug output constraints.

jackin 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.

PageUp / PageDown are page-scrolling keys for long list-like modals that expose them, currently the File Browser. They move the selected row by the visible listing height, use the same selection-follow math as rendering, and saturate at the first or last row instead of wrapping. A modal that accepts PageUp/PageDown must advertise them in the footer hints.

Focus layers

Focus state has three layers, and new components must use the narrowest layer that matches the interaction:

  1. jackin_tui::runtime::SurfaceFocus<Target> is the product projection over TermRock's FocusRing for tabbed screens. It answers which region owns input: the tab bar, or one named content block. Borders, tab underlines, cursors, and footer scope derive from that one owner value.
  2. A per-dialog semantic enum is element-level focus inside a button dialog. Use named states such as Yes / No or Save / Discard / Cancel, and project them through the shared TermRock focus mechanics; do not store a parallel raw index or bool.
  3. PanelEmphasis is border styling only. It is computed from screen-level or dialog-level focus state when rendering; do not store it as independent state.

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, / use the selected row's own action first, then fall back to horizontal scrolling:

  1. If the selected row can perform the pressed tree action, expands it or collapses it. on an instance child row collapses the parent workspace and moves the cursor to that parent row.
  2. Otherwise, if the focused sidebar list overflows horizontally, / scroll that list left or right.
  3. Otherwise, the key is a no-op.

The h/l bindings remain horizontal-scroll aliases; they do not expand or collapse tree rows.

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.

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.

Shared keys use termrock::keymap::glyph constants. A hint PR introducing Tab, ↑/↓, ←/→, PgUp PgDn, or +-joined modifiers is a design violation.

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.

Long-running work must be visible while it is happening

Any TUI action that can take noticeable time, waits on I/O, talks to Docker, touches git/network state, waits on a background worker, or otherwise keeps the operator from immediately seeing the result must show an in-surface progress or status state for the whole wait. A static unchanged screen after an operator commits an action is a bug: it makes the interface feel stuck even when work is happening correctly.

This rule is the TUI version of visibility of system status. Nielsen Norman Group's visibility-of-system-status heuristic says systems should keep people informed through timely feedback, and specifically calls out progress indicators for work that needs longer to finish. Material's progress-indicator guidance defines progress indicators as the component that informs users about ongoing processes such as loading or submitting work. jackin applies that guidance to terminal UI: the operator must be able to glance at the screen and answer "what is happening now?"

Required implementation shape:

  1. Foreground waits get a foreground status surface. If the action blocks the current flow, show a modal/status popup, launch cockpit, progress rail, spinner, or equivalent visual state. The role editor's Loading role popup and the launch cockpit are the exemplars: the operator sees the action has started, the UI remains alive, and completion transitions to the next decision or error surface.
  2. Background work still needs a visible owner. If a worker thread, async task, Docker command, git clone, cache refresh, token mint, or diagnostics-producing command is running because of an operator action, the screen that accepted the action owns the loading state until the worker reports success or failure. Do not leave the previous input modal, list row, or unchanged editor frame on screen while the work happens.
  3. Use determinate progress when it is real, otherwise use indeterminate status. A stage rail, percent, or progress bar is appropriate only when the implementation has meaningful progress signals. Unknown duration uses an indeterminate spinner/status text such as Loading role, Building image, or Minting token; do not fake percentages.
  4. Name the work in operator language. The status text should say what jackin is doing now, not expose implementation plumbing. Use Loading role, Building derived image, Opening hardline, or Refreshing 1Password items; avoid raw command names as the primary status.
  5. Completion must transition visibly. Success moves to the next visible state (for example a trust prompt, updated row, opened session, or saved confirmation). Failure moves to the shared red error surface with the same diagnostics/error detail rules as the rest of the TUI. A worker result must never silently close the screen or leave the operator guessing whether anything changed.
  6. Motion is optional; status is not. JACKIN_NO_MOTION=1 and low-capability renderers may freeze animation or replace a spinner with static text, but they must keep the same explicit "work is happening" state.

Review checklist: whenever a PR adds or changes a TUI action that can wait, reviewers must ask: after the operator presses the committing key or clicks the action, what visible state appears before the result? If the answer is "the screen stays the same until it finishes," the PR violates this rule.

Clickable targets must look clickable (hover + hand pointer — non-negotiable)

This mirrors the web/W3C convention: on a website you know something is clickable because (1) the cursor turns into a hand pointer over it, and (2) it changes appearance on hover. Every jackin TUI element that performs an action on click must expose both, plus a distinct resting style:

  1. Hand pointer on hover. When the pointer is over a clickable element, the terminal cursor must switch to the hand/pointer shape via OSC 22 (\x1b]22;pointer\x1b\\); it returns to the default shape (\x1b]22;default) when the pointer leaves. This is the single most important "you can click this" signal. Both surfaces already own the mechanism — the in-container multiplexer's PointerShape in crates/jackin-capsule/src/tui.rs, OSC 22 encoder in crates/jackin-capsule/src/tui/terminal.rs, and update_pointer_shape_for_mouse in crates/jackin-capsule/src/tui/daemon/mouse_input.rs, plus the console's pointer handling — so a new clickable wires into the existing hover→pointer-shape resolution rather than inventing its own.
  2. Hover style change. The element must visibly change while hovered — a lifted background, brighter foreground, or equivalent — modest but unmistakable, matching the website's "this is interactive" clarity without overpowering the surrounding chrome. The gold-standard exemplar is TermRock Tabs: hovering a tab lifts its background and its render-produced regions keep hover and click in lock-step. Model new clickables on it.
  3. Distinct resting style. The element looks different from inert text even when not hovered (e.g. the blue instance-id link chip, the emphasized copy value).

The visual target must be the exact value or control that will act. For copy dialogs, only the value copied by Enter is rendered as the emphasized clickable value; informational rows stay in the normal value color. When a footer says Enter copy <value>, clicking that same emphasized value must perform the same copy action and show visible feedback. Do not make a whole dialog row look actionable if only one value is copied.

A click handler without both a hover style change and an OSC 22 hand-pointer is a design-decision violation. This applies to every surface that handles mouse input — the workspace manager, the launch cockpit (the build-log button on the loading screen is a clickable and must hover + show the hand pointer), and every in-container multiplexer dialog. Reviewers reject a new clickable that lacks either cue.

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 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.

Preview rows stay visible and inert

Rows that display derived state are preview-only: they must remain visible, never receive the cursor, never be focusable by keyboard or mouse, and never respond to Enter, D, or any other mutating key. Demoting a row to preview-only must not hide it; hiding the row is a design-decision violation because the operator loses the explanation of the effective state.

All surfaces that act on a selectable row must share one focusability predicate for that row family. Cursor movement, Enter dispatch, destructive-key dispatch, footer mode selection, and mouse hit-testing must all ask that predicate instead of each carrying its own row list. A row that renders as preview-only but still opens a picker, clears a value, or shows a value-edit footer is a fail.

Grouped settings edit through dialogs

Grouped settings panels are preview/navigation surfaces. They show the current mode, source, folder, or other derived values, but the panel itself does not mutate those values. The group has exactly one dialog entry point: its primary row, such as an Auth Mode row. Pressing Enter there opens the edit dialog; inside the dialog, Space cycles enumerated values, Enter opens choosers, and the dialog's Save stages the change. Pressing Enter on a preview value row in the panel must be a no-op.

This rule applies equally to the workspace editor and Settings screen. If a value can be edited both inline from the panel and inside its dialog, the implementation violates the grouped-edit contract.

Value-row footers name the primary action first

Footer hints must lead with the focused row's primary action. A mode row leads with ␣ cycle; a source folder row inside a dialog leads with ↵ browse; a credential source row leads with ↵ set. ⇥ button row is allowed as a trailing hint, but it must never be the leading hint on a value row. A footer that advertises a mutating action for a preview-only row is a bug.

Tab strip and content are mutually exclusive focus owners

When a tabbed screen's tab bar owns focus, its active tab shows a PHOSPHOR_GREEN underline and no content block has a green border. When a content block owns focus, the active tab underline switches to WHITE and the content block gets the PHOSPHOR_GREEN border. Exactly one of (tab bar, content block) shows the green signal at a time — never both.

Implementation: tab_bar_focused: bool is the single owner flag. The tab strip render calls TabStrip::new(...).focused(tab_bar_focused) — green when true, white when false. Content blocks derive their PanelEmphasis from !tab_bar_focused && scroll_focused. A tab switch (keyboard or click) that returns focus to the tab bar must clear all scroll_focused booleans for that screen so no content block keeps a stale green border.

and are equivalent for entering tab content

Documented under Selection highlight / / enter-content (see above).

Selection highlight is always full row width

Every selectable list — workspace sidebar, pickers, file browser, settings tables, capsule dialogs, lookbook sidebar — paints the selected row's background across the full available width, not only across the text. The canonical example is the workspace sidebar: the selected row background fills the entire row using highlight_style(bg(PHOSPHOR_GREEN) fg(PHOSPHOR_DARK)) on the ratatui List widget. The cursor prefix is always reserved via highlight_spacing(HighlightSpacing::Always) so unselected rows align.

A narrow text-only highlight (background stops after the last character) is a design-decision violation. The highlight must stop before any scrollbar gutter or border-owned scrollbar cell; scrollbar cells always win over row background. Route every selectable list through the shared SelectList component or the same List + highlight_style pattern the workspace sidebar uses.

cursor appears on the selected row only when the panel has focus

The prefix on the selected row is focus-gated: it must appear if and only if the enclosing panel currently owns focus. When the panel is unfocused, suppress the — the selected row is still indicated by its row style (bold text, or the row highlight color at reduced intensity), but the cursor glyph must not show. Displaying in an unfocused panel misleads the operator into thinking the panel is the active keyboard target.

Implementation: compute show_cursor = panel_focused && modal.is_none() (or the equivalent expression for the surface) and thread it through every row-line builder. Use highlight_spacing(HighlightSpacing::Always) on the ratatui List so the blank prefix column is reserved even when show_cursor is false — this keeps row alignment stable across focus transitions. A visible in an unfocused panel is a design-decision violation.

When a child prompt or modal is open above a selectable surface, the child owns focus. Background surfaces keep their selection state but suppress both the cursor and the bright focused border. There must be exactly one focused selectable surface per layer.

Mouse-wheel axis classification is centralized

Every wheel handler — on every surface, for dialog bodies and main scrollable panels — maps a wheel event to a scroll axis through the one shared classifier in TermRock source (mouse_scroll_delta / mouse_scroll_delta_with_step), never a local MouseEventKind match. The classifier treats ScrollLeft/ScrollRight as horizontal and Shift+ScrollUp/ScrollDown as horizontal, because some terminals map a horizontal trackpad swipe onto a shifted vertical wheel rather than emitting native horizontal-wheel events. A surface that matches MouseEventKind itself will silently drop the Shift+vertical case and diverge from every other surface; route the event through the shared helper and dispatch on the returned axis instead. Surfaces whose horizontal step differs from the default (the host console panels step one column) pass their own step via mouse_scroll_delta_with_step so they share the axis/modifier rule without inheriting the default magnitude.

Topmost modal captures wheel and touchpad scroll

When a modal or child prompt is open, wheel/touchpad scroll belongs to the topmost scrollable or list-like modal under the pointer. The event must not leak into the screen behind it, even when the modal selection is already at the first or last row.

List-like modals move their selected row with saturating, non-wrapping scroll semantics. Keyboard Up/Down may keep the modal's normal wrapping behavior, but wheel gestures behave like scrolling: first row stays first, last row stays last. Dialog-body scrolling routes through DialogScroll; selectable lists route through the shared list-selection scroll helper. A new picker or File Browser host context that lets the background panel scroll while the modal is open violates this rule.

Full bottom-of-screen stack: spacer → hints → spacer → status/chip row

The bottom three layers of every screen must be laid out in this exact order (top to bottom):

  1. One blank spacer row above the hints — separates body from hints.
  2. Hint bar — keyboard shortcuts for the current focus.
  3. One blank spacer row below the hints — separates hints from the status row.
  4. Status / debug-chip row (debug mode only; absent when --debug is off).

Enforcement (Defect 14 — spacer above hints): footer_height() in jackin-console returns hint_height + 1 (spacer + hints). render_footer() renders hints in the bottom hint_height rows and leaves the top row blank.

Enforcement (Defect 39 — spacer below hints, between hints and chip): split_debug_area() in jackin-console allocates 2 rows for the debug section when in debug mode (1 spacer + 1 chip row). Only the bottom row is rendered with StatusFooter; the top row is naturally blank. The main area shrinks by 2 rows (not 1) in debug mode, so the hint bar and the chip row are always separated by one blank row.

A hint row flush against the body content with no blank row above is a design-decision violation. A chip row flush against the hint row with no blank row between them is equally a violation.

and are equivalent for entering tab content

On every tabbed screen, while the tab bar owns focus, both (Tab) and transfer focus into the tab content block — same end state, same transition. Both keys must route through the same FocusEditorContent / FocusSettingsContent dispatch so they cannot diverge. This is the W3C Tabs pattern's "enter content" step; neither key alone is sufficient.

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 root-console modal widget such as src/console/tui/op_picker/ 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.
  • A full-screen overlay that hides the footer (e.g. the build-log viewer) still renders its hint as the bottom row of the screen, outside its box — not as an internal line of the box.

Hints use one shared span vocabulary and one renderer per surface

Hint rows are not hand-built. The vocabulary is the shared HintSpan enum in TermRock (Key, Text, Sep, GroupSep), and the styling rule is identical everywhere: Key is white + bold, Text is phosphor green (rendered with a leading space), Sep is a gray " · ", GroupSep is three spaces, and the row is centered. Ratatui surfaces render this through the shared HintBar component in TermRock source; the in-container multiplexer also consumes the shared hint renderer in its bottom chrome and wraps long hint sets inside its reserved footer rows instead of dropping active key groups. Dialog hint vocabulary remains owned by crates/jackin-capsule/src/tui/components/dialog.rs. Define a screen's hint as a &[HintSpan] constant and pass it to the renderer; do not assemble styled Spans, raw separator strings, or per-surface colour choices inline. A new hand-rolled hint row (its own Span::styled key/label pairs, its own " · " literal, or its own dim/green choice) is a design-decision violation: route it through the shared span list + renderer instead. This is what lets the cockpit picker, the build-log overlay, and every capsule dialog footer read identically.

Hint spans derive from the keymap that owns dispatch

The span vocabulary is shared, but the keys a hint bar advertises are not free text — they derive from the Keymap<A> that owns dispatch for that surface, never from hand-written glyphs. Keymap<A> in TermRock source is the single source of truth: Keymap::dispatch(chord) and Keymap::hint_spans() / Keymap::glyph_for(action) read from the one &'static [KeyBinding<A>] table, so the advertised key cannot drift from the handled key for any Shown or HiddenAlias binding. Every surface keymap follows this — the shared widgets (CONFIRM_KEYMAP, TEXT_INPUT_KEYMAP, SELECT_LIST_KEYMAP, ERROR_POPUP_KEYMAP, SAVE_DISCARD_KEYMAP), the console tables in crates/jackin-console/src/tui/keymap.rs, the launch tables in crates/jackin-launch/src/tui/keymap.rs, and the capsule tables (CAPSULE_GLOBAL_KEYMAP, PREFIX_COMMAND_KEYMAP, RESIZE_PANE_KEYMAP, and the dialog keymaps) in crates/jackin-capsule/src/tui/keymap.rs. Multi-byte input the byte-level Keymap<u8> cannot represent is bridged by raw_bytes_to_chord (e.g. KeyChord::alt_shift for the Alt+Shift+Arrow CSI sequence that drives RESIZE_PANE_KEYMAP).

A HintSpan::Key(…) literal may appear in a hint builder only when it represents a genuinely unregisterable input: a multi-byte CSI escape sequence, a mouse event, a combined multi-key display group (e.g. ↑↓/j/k), a dynamic runtime value, or an inline-handled surface that has no backing keymap. Every such literal must carry an // UNREGISTERABLE(<reason>) comment naming the specific reason. A bare HintSpan::Key(…) with neither a keymap entry nor an // UNREGISTERABLE annotation is a design-decision violation and a merge blocker. The Debug-info dialog hint bar has exactly one builder — debug_info_hint_spans in TermRock source — and the console modal footer (container_info_footer_items) delegates to it, so the console, launch cockpit, and any future surface render byte-identical hints, matching the one-shared-dialog rule in Chrome & Surfaces.

Read-only pane selection

Pane terminal content is read-only. Mouse selection in a capsule pane has one action: select text and copy it to the outer clipboard. It must not add editing semantics such as cut, delete, replace, paste, or in-place modification.

Selection coordinates are content coordinates, not screen coordinates. Store anchor/focus against the pane's retained scrollback content so a completed selection can survive redraws and remain highlighted when the visible viewport moves. Releasing the mouse after a non-empty drag copies the selected text and keeps the highlight visible; the release itself is not a deselect action.

Copied feedback uses the shared Toast overlay, not the hint bar. The hint bar remains reserved for available actions in the focused area; successful copy feedback is state feedback. The toast appears near the top-right of the visible surface, stays briefly on a deterministic timer, and expires without taking focus, hiding the retained selection, or changing the footer hints.

The persisted selection clears only on explicit deselect: clicking unrelated content/chrome, typing input before forwarding it to the pane, or starting a new selection. Wheel scrolling after a completed selection keeps the selected content highlighted wherever that content remains visible.

Dragging beyond the top or bottom of a scrollable pane auto-scrolls in that direction and extends the selection. The drag owns that pointer event; it must not also be forwarded to the PTY as mouse input. Auto-scroll is bounded by the same scrollback limits as ordinary wheel/key scroll.

Debug output never reaches a rich full-screen TUI

When a rich alternate-screen surface owns the terminal — the launch/loading cockpit, the workspace console, the in-container multiplexer, or the PR-verify surface — --debug must not print over it. Operator notices are deferred until terminal ownership ends, while governed telemetry exports directly to the configured backend. No local telemetry file is created.

The mechanism is the rich_surface_active flag plus the active diagnostics run: emit_debug_line / active_debug (crates/jackin/src/console/adapter.rs, crates/jackin-diagnostics/src/run.rs) route to the run file, and the command runner (crates/jackin-docker/src/shell_runner.rs) suppresses live streaming whenever --debug is on or a rich surface is active. Streaming child output straight to stdout/stderr while a rich surface is up is a bug; route it through the diagnostics run instead.

The launch UI may show the current cli.invocation.id as a copyable backend correlation handle. It never shows a reveal action for a nonexistent diagnostics path. Keep detailed evidence out of the alternate screen: add governed telemetry for registered semantic data or use the explicit operator-output port, then defer that output while a rich surface owns the terminal.


Focusability

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

When a passive detail 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 passive block that is not scrollable in any direction has nothing to communicate with a scroll-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.

This does not apply to active interaction containers such as dialogs, pickers, forms, tab panels, and button groups. Those containers are focusable because they own keyboard interaction; they use the bright green focus-visible border even when their content fits.

Why this matters

For passive detail blocks, 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 passive 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

TermRock Viewport (console scroll_block adapter) paints PanelEmphasis from the caller's focused flag with no overflow gate. Caller focused means interaction ownership — forms, tab content, and other containers that accept keys stay green even when content fits. The passive-scroll focusability rule is enforced in state/input: list-detail surfaces clear list_scroll_focus when content no longer overflows (list_pre_render_focus_plan / focused_block_still_scrollable), so a passive detail block does not remain the focus owner without overflow.

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. Pre-render focus planning (list_pre_render_focus_plan / focused_block_still_scrollable) clears passive list-detail focus when content no longer overflows, so the green border stays honest without the paint path second-guessing focused. 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 TermRock Viewport / console scroll_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.

Derive content extents from the rendered lines, never from a parallel column-sum. A block's content_width must use TermRock's rendered-line width helpers over the exact Vec<Line> the block renders, and its content_height must be that line count. Build the lines once in a pure function and feed the same function to rendering and scroll clamping. A parallel column sum drifts from Unicode display width and trailing padding.

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 TermRock Viewport / console scroll_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 TermRock Viewport / console scroll_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.

Horizontal scrollbar renders whenever content overflows horizontally

Any block whose content is wider than its viewport renders the shared horizontal scrollbar thumb on its bottom border, mirroring the vertical thumb on the right border. When content fits horizontally, no scrollbar is shown.

Enforcement: TermRock Viewport paints the vertical overflow bar; surfaces that own independent horizontal scroll must paint the bottom track through TermRock render_scrollbar + ScrollbarSpec (or an equivalent TermRock helper). Any surface that tracks scroll_x independently must use the same overflow math as the painted content.

The H/L scroll hint must only appear in the footer when horizontal scrolling is actually possible (content overflows the viewport). Showing H/L scroll when content fits is a false affordance.

Scroll hints advertise only the axes that actually overflow

Every scroll hint must reflect the body's real per-axis overflow. A surface may show ↑↓ scroll only when its content overflows vertically, ←→ scroll only when it overflows horizontally, ↑↓←→ scroll only when both overflow, and no scroll hint at all when the content fits. Showing both arrows when only one axis can move — or any scroll hint on a body that fits — is a false affordance, exactly like the H/L scroll rule above: the operator presses a key, nothing moves, and the convention loses trust.

The hint must be derived from the same is_scrollable gate the scrollbar uses, so a hint advertises an axis if and only if that axis's scrollbar is drawn — the two never disagree. This is not optional polish: a static ↑↓←→ scroll constant baked into a dialog is the violation this rule exists to catch (the Debug-info dialog regressed exactly this way — it advertised vertical scroll on a body that only overflowed horizontally).

Enforcement — one shared primitive, no per-surface glyph choices:

  • scroll_hint_spans(ScrollAxes { vertical, horizontal }) in TermRock source is the single source for the scroll-key HintSpans. It returns ↑↓←→/↑↓/←→/empty. Every surface composes its full hint from this primitive plus its own dismiss/copy keys; no surface hand-writes ↑↓←→ scroll.
  • dialog_scroll_axes(content_width, content_height, block_area) derives the ScrollAxes from content vs viewport using the scrollbar's is_scrollable test. Dialogs measure content_width/content_height the same way render_scrollable_dialog_body measures them (unpadded line width, line count), so the axes match the rendered overflow.
  • The Debug-info dialog (debug_info_hint_spans, which the host console's modal footer container_info_footer_items delegates to), the capsule's read-only info dialogs (info_dialog_hint, fed by DialogRatatuiSnapshot::scroll_axes), and the launch build-log overlay (build_log_hint) all route through these two functions. A new scrollable dialog that prints its own fixed scroll glyphs instead of calling scroll_hint_spans is a design-decision violation.

Scrollbar glyphs read identically across both axes

A screen's vertical and horizontal scrollbars must look like the same control at two angles, never two different controls. The weight is governed by ScrollbarStyle (TermRock source), and both axes of one style render at the same weight — only the orientation glyph rotates:

  • Horizontal thumb — always the heavy line (U+2501). There is no horizontal style variant (SCROLLBAR_HORIZONTAL_THUMB); a full block reads poorly as a horizontal bar, so render_horizontal_scrollbar has no _with_style form.
  • Vertical thumb — chosen by ScrollbarStyle (the enum applies to the vertical axis only):
    • ScrollbarStyle::Line (default everywhere): heavy rule (U+2503), matching the horizontal weight so a screen's bottom-border and right-border bars look like one control at two angles.
    • ScrollbarStyle::Block: full block (U+2588), the opt-in thick vertical bar, selected via render_vertical_scrollbar_with_style / render_vertical_scrollbar_in_area_with_style.

The track is always the dim · (SCROLLBAR_TRACK) regardless of style or axis. Default to Line for vertical bars unless a surface deliberately wants the thick block; the shared render_picker_list scroll thumb follows the Line glyph so modal-picker scrollbars match the rest of the TUI.

Content height formula for each block type

These are the exact formulas that match what each render function passes to TermRock Viewport / console scroll_block:

Blockcontent_height
Workspace mounts1 + max(data_rows, 1) where data_rows = sum of 1-or-2 per mount (1 if src==dst, 2 otherwise)
Global mounts / Role global mounts1 + data_rows where data_rows = sum of 1-or-2 per mount (1 if src==dst, 2 otherwise); empty sections render one placeholder row
Roles2 + agent_count (1 "Default …" row + 1 blank row + agent rows)
Left sidebar (workspace names)visible selectable row count inside the sidebar block; the renderer derives the vertical offset with cursor_follow_offset so the selected row stays visible after selection, expansion, refresh, and resize changes

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 TermRock Viewport / console scroll_block (defined in TermRock source, re-exported through view.rs for manager-internal callers). Never hand-roll block creation, Paragraph scroll, or scrollbar rendering inline.

TermRock Viewport (console call sites go through scroll_block::render_scrollable_block_at) guarantees:

  • Border role BorderFocused (phosphor green) when focused is true (interaction owner); otherwise Border (neutral gray).
  • Vertical scrollbar rendered only when content overflows vertically.
  • Horizontal overflow is scrolled through DialogScroll offsets; paint horizontal tracks with TermRock render_scrollbar when a surface needs an explicit bottom bar.
  • Scroll offsets are clamped to valid range every frame (eliminates stale overshoot).
  • Scrollbar thumbs are proportional to visible content, 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.

Input hit-testing, focusability, cursor-follow scrolling, selected-list windowing, scrollbar dragging, wheel/key max-offset math, content width measurement, and scrollbar rendering must flow through termrock::scroll and canonical widget state. Those modules are the source of truth for clamping, top-relative offsets, tail-relative offsets, full-cell thumb conversion, and scrollbar metrics. Do not reimplement viewport, thumb, track, or offset formulas in product input handlers or renderers.

The shared model does not make every scrollbar look the same. jackin owns product composition: capsule panes paint only thumb cells into the right border column via render_scrollbar + ScrollbarSpec, while console detail panels use Viewport chrome. Do not invent a second neutral scrollbar renderer in product code.

Console call shape (adapter around TermRock Viewport):

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

Pass None for title to suppress the block title. Pass a bare trimmed title (no padding); TermRock Viewport / Panel normalize title spacing.


Focus Persistence

Focus sustainability rule (extended): Focus on a container must persist as long as the operator is interacting within that container. This includes all inline sub-pickers — when an inline agent picker, role picker, or provider picker opens WITHIN a focused sidebar, the sidebar border must remain green because the picker is an inline extension of the sidebar's current action, not a separate container that steals focus. The picker receives keyboard input through the sidebar; it does not claim its own focus indicator.

Inline picker focus rule: An inline picker rendered inside a parent container must use the parent's focus state for its panel border. Use focused = parent.list_names_focused (or equivalent), never hardcoded PanelEmphasis::Normal when the parent owns interaction. The cursor inside an inline picker follows the same gating: only visible when the parent container is focused. Focus on a container must persist as long as the operator is interacting within that container. Navigation keys (///) executed inside a focused container must never transfer or clear focus — they navigate within the current owner. Only an explicit user action (mouse click on a different area, Tab to another area, Esc to a parent screen) may move focus.

list_names_focused (left sidebar) is cleared only when:

  1. The user clicks in the right pane — update_scroll_focus sends SetListNamesFocused(false).

It must NOT be cleared by: reset_list_scroll, workspace selection changes, terminal resize, or content fitting within the viewport. The sidebar is the primary navigation container; its focus persists for the entire session until the operator explicitly moves focus elsewhere. Clearing it on resize or content-fit is a focus-sustainability violation — the green border must not disappear while the operator is navigating workspace entries.

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 and ScrollUp/ScrollDown 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.

Every wheel path must resolve the target from the pointer position immediately before applying the delta. Stale focus state must never decide where a wheel event goes: when independently scrollable blocks share a screen, a wheel over one block must not scroll another block or no-op because a prior click or focus belonged elsewhere.

The left pane scroll uses the shared termrock::scroll clamping path after each wheel/key mutation, then the renderer clamps defensively again on every frame.


On this page

Navigation conventionsKey rolesFocus layersW3C Tabs pattern (required for all tabbed interfaces)Other area-boundary rulesNavigation hints (exhaustive — no hidden keys)Long-running work must be visible while it is happeningClickable targets must look clickable (hover + hand pointer — non-negotiable)Space vs Enter — W3C semantic distinctionPreview rows stay visible and inertGrouped settings edit through dialogsValue-row footers name the primary action firstTab strip and content are mutually exclusive focus owners and are equivalent for entering tab contentSelection highlight is always full row width cursor appears on the selected row only when the panel has focusMouse-wheel axis classification is centralizedTopmost modal captures wheel and touchpad scrollFull bottom-of-screen stack: spacer → hints → spacer → status/chip row and are equivalent for entering tab contentHints: footer only, never inside dialogsHints use one shared span vocabulary and one renderer per surfaceHint spans derive from the keymap that owns dispatchRead-only pane selectionDebug output never reaches a rich full-screen TUIFocusabilityWhy this mattersImplementationClick hit-test area must cover the full rendered blockHorizontal scrollbar renders whenever content overflows horizontallyScroll hints advertise only the axes that actually overflowScrollbar glyphs read identically across both axesContent height formula for each block typeScrollable Blocks — Shared ComponentFocus PersistenceMouse Scroll (Hover-Scroll)