# Visual Design (https://jackin.tailrocks.com/reference/tui/visual-design/)



## PHOSPHOR Color Palette [#phosphor-color-palette]

The canonical RGB values live in the `jackin-tui` crate palette (<RepoFile path="crates/jackin-tui/src/lib.rs">crates/jackin-tui/src/lib.rs</RepoFile>) so the host TUI and the in-container multiplexer cannot drift; <RepoFile path="crates/jackin-tui/src/theme.rs">crates/jackin-tui/src/theme.rs</RepoFile> adapts them into ratatui `Color` constants that the rest of the host TUI imports. Never define local color constants.

**All colors come from named theme tokens; no inline `Color::Rgb(...)` literals outside `theme.rs`.** Every new color must be added as a named constant in `lib.rs` and re-exported from `theme.rs` as a ratatui `Color`. Inline RGB literals are a design-decision violation.

| 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                 |
| `WARNING_YELLOW`       | `Rgb(255, 216, 94)`  | Warning notes in confirmation dialogs  |
| `PREVIEW_CARD`         | `Rgb(28, 28, 28)`    | Lookbook preview canvas background     |
| `CAPSULE_PANE_FOCUSED` | `Rgb(180, 180, 180)` | Capsule pane focused border            |

Every lookbook story preview card and lookbook background must use the `PREVIEW_CARD` token, never an inline `Color::Rgb(28, 28, 28)` literal. The general rule (no inline `Color::Rgb(...)` outside `theme.rs`) already covers this, but `PREVIEW_CARD` is the token to reach for whenever a dark card background is needed.

### Background fills use the terminal default, never forced black [#background-fills-use-the-terminal-default-never-forced-black]

**Modal backdrops and dialog surfaces paint the terminal's *default* background, not a fixed black.** The Ratatui tokens `theme::DIALOG_BACKDROP` and `theme::DIALOG_SURFACE` are `Color::Reset`; the raw-ANSI token `ansi::BG_DARK` is `\x1b[49m` (the default-background SGR). A dialog backdrop or surface must never set `Color::Black`, `Color::Rgb(0, 0, 0)`, or a `48;2;0;0;0m` fill. Forcing pure black makes overlays stand out as a dark rectangle against a themed (non-black) terminal background; emitting the default background lets every overlay match the operator's terminal theme. Occlusion still holds — a `Color::Reset` cell with a space overwrites the chrome behind it, just on the operator's background instead of black. This applies to every surface (console, launch cockpit, capsule) because all three consume the same shared tokens. Black as a **foreground** (e.g. black text on a light status chip) is unaffected by this rule; it governs background fills only.

## Panel Title Spacing [#panel-title-spacing]

Every titled block passes its title through `Panel::title()`, which normalizes the string to `" {trimmed_title} "` (one space, trimmed title, one space) internally. Callers must **not** pre-pad the title string with spaces. The resulting display is `┌ Title ┐` with exactly one space on each side of the title text.

A title passed as `"  Title  "` (extra spaces) or `"Title"` (no spaces) is a design-decision violation — pass the bare trimmed title and let `Panel::title()` apply the canonical padding.

***

## Panel Body Inset [#panel-body-inset]

Panel body content must be inset from the border by exactly 1 cell horizontally. Use `panel_body_area(block_area, inner_area)` from `jackin_tui::components::panel` instead of rendering content directly into `block.inner(area)`. The 1-cell inset prevents text from touching the left and right border characters and is a visual requirement across all titled panels.

`render_scrollable_block` applies this inset automatically. Direct callers that render text content into a titled panel via the ratatui `Block` API must call `panel_body_area` to obtain the inset rect and render into that, not into the raw `inner` rect.

***

## Action Rows (`+ Add …` / `+ Override …`) [#action-rows--add----override-]

Every row that begins with `+` is an add/create action and must render through the one shared `action_row_style(selected)` function in `jackin-console`. Rules:

* Same `ACTION_ACCENT` foreground on every `+ …` row app-wide, on every screen and tab.
* Bold when selected, normal weight when not.
* No trailing parenthetical description (no `(all roles overridden)`, no `(0 remaining)`, no any-other-parenthetical). The action label alone is sufficient.
* One shared style function for all `+ …` rows; no surface hand-rolls its own style.

***

## Block Border Colors [#block-border-colors]

* **Active interaction container**: `PHOSPHOR_GREEN` border — dialogs, pickers, forms, tab panels, and the currently active list/sidebar use the bright border whenever they own keyboard or scroll interaction.
* **Passive scroll block, focused and scrollable**: `PHOSPHOR_GREEN` border — both `focused = true` and content overflows in at least one axis.
* **Passive scroll block, focused but not scrollable**: `PHOSPHOR_DARK` border — `focused = true` is ignored when content fits; no false scroll affordance.
* **Unfocused / background container**: `PHOSPHOR_DARK` border regardless of scrollability.

`render_scrollable_block` enforces the passive-scroll logic internally. Callers only supply `focused: bool` from state; the renderer decides whether to use green. Active interaction containers that are not passive scroll blocks must use the same token rule directly or through their shared component: bright border for the active container, dark border for inactive/background containers.

***

## Left Sidebar (Workspace Name List) [#left-sidebar-workspace-name-list]

* Horizontal scroll only — no vertical scroll.
* Uses the shared fixed-prefix line renderer (`render_line_with_fixed_prefix_scroll`) so the disclosure/cursor prefix stays visible while only the workspace label scrolls. The renderer must slice by display columns through `jackin_tui::fixed_prefix_scroll_segments`, not by Rust `char` or byte count.
* 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 persists until the user explicitly clicks the right pane. Horizontal scroll is clamped on resize, but focus state is not cleared (focus-sustainability rule).
* Selected and hovered rows must paint their background across the full visible row after horizontal scroll, even when the scrolled label is shorter than the viewport. Do not let generic line scrolling clip the highlighted prefix or leave unpainted cells inside the row.

***

## Tab Bar [#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 chrome is shared with the in-container multiplexer status bar** (`jackin-capsule`): inactive tabs render on a dark-grey background (`TAB_BG_INACTIVE`), the active tab lifts to graphite (`TAB_BG_ACTIVE` — never the brand green, so it stays distinct from the `jackin'` pill), both with WHITE text and the active tab bold. These backgrounds and the Ratatui renderer live in the shared `jackin-tui` crate (`TAB_BG_*`, `components::TabStrip`) and are consumed by the console tab strips (workspace editor, settings) and the capsule Ratatui status bar.

* **Tab bar focused**: the active tab shows a `PHOSPHOR_GREEN` `━` underline bar — the keyboard-focus cue. GREEN = "tab bar owns focus."

* **Content focused** (tab bar NOT focused): the active tab still shows a `━` underline bar, but in `WHITE` — a dim context indicator. This tells the operator which tab is selected without implying the tab bar is the focus owner. The GREEN border on the content block below is the real focus signal.

* **Neither**: no underline bar rendered (edge case only; one of the two areas always owns focus in normal operation).

* **Content focused** (tab bar not focused): same graphite active tab, 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.

* **Tab hover lifts the cell under the pointer** (`TAB_BG_INACTIVE_HOVER` / `TAB_BG_ACTIVE_HOVER` in the shared palette), matching the in-container multiplexer's pointer feedback. The console enables any-event motion tracking (`?1003h` alongside `?1000h`/`?1002h`/`?1015h`/`?1006h`) so a `MouseEventKind::Moved` event repaints the hovered tab; moving off the strip clears it. The motion-flood concern that deferred this earlier is moot host-side — events are local (no pty) and the manager coalesces renders at 20Hz, so a stream of motion events costs one repaint per frame. The hit-test that maps a column to a tab index is the shared `jackin_tui::lay_out_tabs` + `jackin_tui::tab_at_column`, the same geometry the multiplexer uses, so click-selection and hover stay in lock-step with the rendered cells.

***

## Lookbook SVG Exports — Padded Preview Canvas [#lookbook-svg-exports--padded-preview-canvas]

**Rule:** lookbook SVG exports use the same `PREVIEW_CARD` padded canvas as the interactive preview. The SVG export path and the interactive preview must render identically — each story floats on a uniform charcoal card, never edge-to-edge against a black background.

Concretely, `render_story_to_buffer` allocates a buffer of `(story.width + 2·STORY_PAD) × (story.height + 2·STORY_PAD)`, fills the whole area with `PREVIEW_CARD` (`Rgb(28, 28, 28)`), `Clear`s the inset story rect, and renders the story into that inset. The resulting SVG has a `#1c1c1c` ring around every component.

Two paths that must always agree:

* **Interactive preview** (`main.rs` `preview_inner`): fills with `PREVIEW_CARD`, applies `Margin { horizontal: 1, vertical: 1 }`.
* **SVG export** (`svg.rs` `render_story_to_buffer`): same `PREVIEW_CARD` fill, same `STORY_PAD = 1` padding ring.

If one path is updated (different padding, different background), the other must be updated in the same PR. DRY miss here = every screenshot on the docs site drifts from the interactive browser.

***

## Panel and Dialog Title Capitalization [#panel-and-dialog-title-capitalization]

Every panel border title, dialog title, and box border title must start with an uppercase letter. The first character of the title string (after `Panel::title()` normalizes spacing) must be uppercase.

**Rule:** pass titles with an uppercase first character at the call site. `Panel::title()` does not normalize letter case — it only normalizes spacing. A title `"workspace"` produces `┌ workspace ┐` (wrong); `"Workspace"` produces `┌ Workspace ┐` (correct).

Data-derived titles (workspace names, role names, story group labels) must be capitalized at their **source**, not with a post-hoc `to_uppercase()` call. If a data source can produce a lowercase first character that ends up as a panel border title, capitalize it at the point where the string is constructed.

Examples of correctly capitalized titles: `"General"`, `"Environments"`, `"Debug info"`, `"Stories"`, `"About"`, `"Preview"`. A lowercase first letter in any title visible to the operator is a violation of this rule.

***

## Capsule ANSI color encoders: static allowlist vs dynamic runtime [#capsule-ansi-color-encoders-static-allowlist-vs-dynamic-runtime]

The capsule's ANSI renderer uses two flavors of the color-encoding helpers in <RepoFile path="crates/jackin-tui/src/lib.rs">crates/jackin-tui/src/lib.rs</RepoFile>:

* **`rgb_fg(color)` / `rgb_bg(color)` — `const fn`, compile-time only.** These return a `&'static str` from a fixed allowlist of named palette colors. An unlisted color is a **compile error** (not a runtime panic), which is the desired behavior for constant tables: if you add a named color to the palette and forget to add it to the allowlist, the build fails loudly. Use these for `const COLOR_NAME: &str = rgb_fg(PHOSPHOR_GREEN)` table entries.

* **`rgb_fg_dyn(Rgb) -> String` / `rgb_bg_dyn(Rgb) -> String` — dynamic, runtime.** These emit the standard truecolor SGR sequence `\x1b[38;2;r;g;bm` / `\x1b[48;2;r;g;bm` for **any** `Rgb` value and never panic. Use these whenever the color value is not a compile-time constant — any place a color is chosen at runtime (e.g. the debug chip which is conditionally rendered with `DANGER_RED`).

**Rule:** capsule render code must never call `rgb_fg` or `rgb_bg` with a value that is not a `const` palette constant. Any non-const color use must route through `rgb_fg_dyn` / `rgb_bg_dyn`. A `const` call with an unlisted color is a build error (correct!); a non-`const` call with an unlisted color is an exit-101 panic (bug!).

***

## Copyable values — link style, hover, click-to-copy [#copyable-values--link-style-hover-click-to-copy]

Any value the operator can click to copy (a file path, run id, container id, URL) must read as a link and behave identically on every surface, following the W3C native-link convention:

* **Colour:** `LINK_FG` (cyan) on a dark dialog surface — never blue, never the same as plain or emphasised text. `LINK_BLUE` is reserved for clickable text on the *white* status bar, where cyan lacks contrast; do not use it on dark dialog surfaces.
* **Underline:** always underlined, with or without an OSC 8 hyperlink. Underline is the resting affordance that marks the value as a link; underlining only the hyperlinked rows is a violation.
* **Hover:** the value brightens to `LINK_FG_HOVER` while the pointer is over it, and the pointer switches to the clickable shape where supported. A copyable value with no hover colour change is a violation — the hover feedback is what tells the operator it is interactive.
* **Click:** copies the full value to the clipboard (OSC 52) and shows the transient "Copied!" badge on that row. The visible text may be abbreviated; the copied payload and the OSC 8 `href` carry the full value.

These rules are enforced by the shared component (<RepoFile path="crates/jackin-tui/src/components/container_info.rs">crates/jackin-tui/src/components/container\_info.rs</RepoFile>): every copyable row renders through the same styling, so the affordance never drifts between the console, launch cockpit, and capsule. New copyable surfaces reuse that component rather than re-implementing the colour/underline/hover rules.
