Visual Design
PHOSPHOR color palette, border color semantics, left sidebar conventions, and tab bar specifications.
PHOSPHOR Color Palette
The canonical RGB values live in the jackin-tui crate palette (crates/jackin-tui/src/lib.rs) so the host TUI and the in-container multiplexer cannot drift; crates/jackin-tui/src/theme.rs 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
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
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 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 …)
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_ACCENTforeground 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
- Active interaction container:
PHOSPHOR_GREENborder — 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_GREENborder — bothfocused = trueand content overflows in at least one axis. - Passive scroll block, focused but not scrollable:
PHOSPHOR_DARKborder —focused = trueis ignored when content fits; no false scroll affordance. - Unfocused / background container:
PHOSPHOR_DARKborder 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)
- 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 throughjackin_tui::fixed_prefix_scroll_segments, not by Rustcharor byte count. - H/L keys and
ScrollLeft/ScrollRightboth updatestate.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
W3C ARIA Tabs pattern (see AGENTS.md → TUI navigation conventions for full spec). Summary:
-
Tab bar has its own focus area (
tab_bar_focused: boolin 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 thejackin'pill), both with WHITE text and the active tab bold. These backgrounds and the Ratatui renderer live in the sharedjackin-tuicrate (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 inWHITE— 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/Escreturns focus to the tab bar from content. -
Tab hover lifts the cell under the pointer (
TAB_BG_INACTIVE_HOVER/TAB_BG_ACTIVE_HOVERin the shared palette), matching the in-container multiplexer's pointer feedback. The console enables any-event motion tracking (?1003halongside?1000h/?1002h/?1015h/?1006h) so aMouseEventKind::Movedevent 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 sharedjackin_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
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)), Clears 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.rspreview_inner): fills withPREVIEW_CARD, appliesMargin { horizontal: 1, vertical: 1 }. - SVG export (
svg.rsrender_story_to_buffer): samePREVIEW_CARDfill, sameSTORY_PAD = 1padding 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
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
The capsule's ANSI renderer uses two flavors of the color-encoding helpers in crates/jackin-tui/src/lib.rs:
-
rgb_fg(color)/rgb_bg(color)—const fn, compile-time only. These return a&'static strfrom 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 forconst 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;bmfor anyRgbvalue 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 withDANGER_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
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_BLUEis 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_HOVERwhile 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
hrefcarry the full value.
These rules are enforced by the shared component (crates/jackin-tui/src/components/container_info.rs): 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.