<!-- STATUS: COMPLETE -->

# Phase 16: TUI — Layout, Widgets, and Command Routing

## Goal

Build the foundational Textual application: the persistent interactive session layout, custom widgets, command input/routing system, theme architecture, and Film Noir theme. After this phase, the app launches with a real layout, accepts commands, and renders content — but screen-specific flows (first launch, mission view, etc.) are deferred to Phase 17.

## Dependencies

- **Phase 13** — Campaign orchestration API (`create_campaign`, `resume_campaign`, `complete_mission`, `unlock_mission`, `start_mission`, `get_campaign_progress`)
- **Phase 15** — Pipeline produces usable missions with working chain links
- All campaign data is already in SQLite; the TUI reads from it

## Deferred Items Addressed

- **Static testing mission data for UI iteration** — Will create a fixture script that seeds a test database with realistic campaign data. The TUI can be developed and tested against this without hitting the LLM.
- **Campaign overwrite confirmation in TUI** — Noted but not implemented this phase (TUI doesn't create campaigns yet, that's the CLI's job). Will be addressed when the TUI can trigger campaign creation.

## Research Notes

### Textual 8.1.1 (installed)

- **App**: `compose()` yields widgets, `CSS_PATH` or `DEFAULT_CSS` for styling
- **Screen**: `push_screen()` / `pop_screen()` for navigation, `ModalScreen` for dialogs
- **Widgets**: `Header`, `Footer`, `Input`, `RichLog`, `Static`, `Label`, `ProgressBar`, `DataTable`
- **Containers**: `VerticalScroll`, `Horizontal`, `Vertical`, `Container`
- **CSS**: TCSS files with `$variable` theme references, `:focus`, `:dark`/`:light` pseudo-selectors
- **Theming**: `Theme` class with named color variables (`primary`, `secondary`, `accent`, etc.)
- **Actions**: `BINDINGS` list maps keys to `action_*` methods
- **Events**: `on_input_submitted` for command input, `on_key` for raw keys
- **Workers**: `run_worker()` for async operations (not needed this phase — all data is local SQLite)

### Layout Architecture (from requirements §7)

Four regions:
1. **Header** — campaign name, theme, session stats
2. **Command reference** — visible list of available commands
3. **Main content area** — scrollable, renders narrative/missions/briefings
4. **Command input** — text input at bottom

### Command System (from requirements §7)

Canonical + alias system. 10 commands total for v1:

| Canonical | Noir Alias | Action |
|-----------|------------|--------|
| `next` | `investigate` | Show available missions, pick one |
| `complete` | `file report` | Complete current mission |
| `status` | `case board` | Campaign overview |
| `briefing` | `case file` | Re-read codebase briefing |
| `mission` | `lead` | Show current active mission |
| `notebook` | `case notes` | *(v1.1)* Print notebook path |
| `unlock` | `break in` | Force unlock a locked mission |
| `help` | `call in a marker` | Show all commands |
| `quit` | `bury the day` | End session |
| `end campaign` | `close the case` | End campaign with final report |

### Theme Architecture (from requirements §7)

A theme is a configuration package with:
- TCSS stylesheet (colors, borders, text styles)
- Command aliases (themed names for canonical commands)
- Vocabulary (what to call missions, chains, etc.)
- Narrative tone (modifier for LLM text — already generated, just display)

### Presentation Separation (from requirements §8)

Requirements mandate **strict separation between data/logic and presentation**. Every screen is a data query rendered by whichever mode is active. This enables `--plain` mode in v1.1 without refactoring.

**Implementation approach**: Create a `ViewData` pattern where each "view" is a dataclass (pure data), and the TUI renders it. The data layer queries the DB and produces ViewData; the widget layer consumes ViewData. No DB calls inside widgets.

---

## Tasks

### Task 0: Create test fixture script for TUI development

**Files:** `_planning/fixtures/seed_test_campaign.py`

**Action:** Create a standalone script that:
1. Creates a campaign directory in a temp location
2. Opens a campaign DB
3. Seeds it with realistic data: 1 campaign, 1 briefing, ~20 missions across 4 categories, 4 chains with linked missions, file refs, prerequisites
4. Missions have varied statuses: some AVAILABLE, some LOCKED, some COMPLETED, some IN_PROGRESS
5. Prints the campaign_dir path so the TUI can be pointed at it

**Verify:** `python3 _planning/fixtures/seed_test_campaign.py` runs without errors and prints a path. Open the DB with sqlite3 and verify data exists.

**Done when:** Script creates a realistic test campaign in a temp directory.

---

### Task 1: Theme data model — vocabulary, aliases, stylesheet path

**Files:** `src/unfamiliar/tui/themes/__init__.py`, `src/unfamiliar/tui/themes/film_noir/__init__.py`, `src/unfamiliar/tui/themes/film_noir/theme.py`, `src/unfamiliar/tui/themes/film_noir/noir.tcss`

**Action:**
1. In `themes/__init__.py`: Define `ThemeConfig` dataclass with fields:
   - `name: str` — theme identifier
   - `display_name: str` — human-readable name
   - `css_path: Path` — path to TCSS file
   - `aliases: dict[str, str]` — canonical command → themed alias
   - `vocabulary: dict[str, str]` — canonical term → themed term (e.g., "mission" → "lead", "chain" → "case thread")
   - `get_theme(name: str) -> ThemeConfig` — factory function
2. In `film_noir/theme.py`: Define `FILM_NOIR_THEME` constant with all Film Noir aliases and vocabulary from requirements
3. In `film_noir/noir.tcss`: Create the Film Noir TCSS stylesheet:
   - Dark background, muted golds/ambers for accents
   - High contrast text (cream on dark)
   - Styled borders, panel backgrounds
   - Widget-specific styles for Header, CommandReference, ContentArea, CommandInput

**Verify:** `python3 -c "from unfamiliar.tui.themes import get_theme; t = get_theme('film_noir'); print(t.aliases)"` prints the alias mapping.

**Done when:** Theme data model exists with Film Noir as the first (and only) implementation. TCSS file has complete styling for all planned widgets.

---

### Task 2: View data models — presentation-separated data layer

**Files:** `src/unfamiliar/tui/views.py`

**Action:** Create dataclasses that represent pure data for each view. No DB imports, no Textual imports — just data:

```python
@dataclass
class HeaderData:
    campaign_name: str
    repo_name: str
    theme_name: str
    completed: int
    total: int
    available: int

@dataclass
class CommandInfo:
    canonical: str
    alias: str
    description: str

@dataclass
class MissionSummary:
    id: int
    title: str
    title_plain: str
    category: str
    difficulty: str
    time_minutes: int
    status: str
    security_tag: bool

@dataclass
class MissionDetail:
    id: int
    title: str
    title_plain: str
    description: str
    category: str
    difficulty: str
    time_minutes: int
    status: str
    completion_condition: str
    security_tag: bool
    narrative_intro: str
    narrative_outro: str
    file_refs: list[FileRefData]
    prerequisites: list[str]  # titles of prerequisite missions

@dataclass
class FileRefData:
    file_path: str
    line_start: int | None
    line_end: int | None
    description: str

@dataclass
class CategoryProgressData:
    name: str
    display_name: str
    completed: int
    total: int
    available: int
    locked: int
    percent: int

@dataclass
class StatusData:
    campaign_name: str
    overall_percent: int
    completed: int
    total: int
    available: int
    locked: int
    in_progress: int
    security_total: int
    security_completed: int
    categories: list[CategoryProgressData]
    chains: list[ChainSummary]

@dataclass
class ChainSummary:
    name: str
    name_plain: str
    category: str
    mission_count: int
    completed_count: int

@dataclass
class BriefingData:
    summary: str
    tech_stack: list[str]
    security_posture: str

@dataclass
class CompletionData:
    mission_title: str
    narrative_outro: str
    unlocked_titles: list[str]
    new_available: int
    total_completed: int
    total_missions: int
```

**Verify:** `python3 -c "from unfamiliar.tui.views import HeaderData, StatusData; print('OK')"` imports cleanly.

**Done when:** All view dataclasses defined. No dependencies on Textual or DB — pure data structures.

---

### Task 3: Data queries — bridge from DB to view data

**Files:** `src/unfamiliar/tui/queries.py`

**Action:** Create functions that query the DB and return view dataclasses:

- `load_header_data(db, campaign) -> HeaderData`
- `load_status_data(db, campaign) -> StatusData`
- `load_available_missions(db, campaign_id) -> list[MissionSummary]`
- `load_mission_detail(db, mission_id) -> MissionDetail`
- `load_briefing_data(db, campaign_id) -> BriefingData`
- `load_chain_summaries(db, campaign_id) -> list[ChainSummary]`

Each function:
1. Calls the appropriate repository methods
2. Transforms DB models into view dataclasses
3. Returns pure data (no Textual types)

**Verify:** Import and call each function against the test fixture DB. Data returns correctly.

**Done when:** All query functions implemented and return correct view data from the fixture DB.

---

### Task 4: Custom widgets — InvestigationHeader

**Files:** `src/unfamiliar/tui/widgets/__init__.py`, `src/unfamiliar/tui/widgets/header.py`

**Action:** Create `InvestigationHeader` widget (extends `Static`):
- Displays: campaign name, repo name, theme, progress stats
- Accepts `HeaderData` to update display
- Uses Rich markup for styling
- Docked to top, 3 lines tall
- Film Noir styled: dark background, amber text, box border

**Verify:** Widget renders correctly in isolation test.

**Done when:** Header widget displays campaign info and progress stats with Film Noir styling.

---

### Task 5: Custom widgets — CommandReference

**Files:** `src/unfamiliar/tui/widgets/command_ref.py`

**Action:** Create `CommandReference` widget (extends `Static`):
- Displays all available commands in a compact format
- Shows both canonical name and themed alias
- Uses `list[CommandInfo]` to populate
- Docked to right side, fixed width (~30 chars)
- Scrollable if terminal is too short

**Verify:** Widget renders all 10 commands with aliases.

**Done when:** Command reference panel displays correctly with Film Noir styling.

---

### Task 6: Custom widgets — ContentArea

**Files:** `src/unfamiliar/tui/widgets/content.py`

**Action:** Create `ContentArea` widget (extends `RichLog` or `VerticalScroll` with `Static` children):
- Main scrollable content region
- Methods to render different content types:
  - `show_text(markup: str)` — render Rich markup text
  - `show_missions(missions: list[MissionSummary])` — render mission list
  - `show_mission_detail(detail: MissionDetail)` — render full mission view
  - `show_status(data: StatusData)` — render case board
  - `show_briefing(data: BriefingData)` — render briefing
  - `show_completion(data: CompletionData)` — render completion message
  - `clear()` — clear content
- Each render method formats the data using Rich markup
- Auto-scrolls to top on new content

**Verify:** Widget renders each content type correctly.

**Done when:** Content area can display all major content types with formatted output.

---

### Task 7: Custom widgets — CommandInput

**Files:** `src/unfamiliar/tui/widgets/command_input.py`

**Action:** Create `CommandInput` widget (extends `Input`):
- Text input at the bottom of the screen
- Placeholder text: themed prompt (e.g., "What's your next move, detective?")
- On submit (Enter): posts a `CommandSubmitted` message with the input text
- Clears input after submit
- Docked to bottom

Define `CommandSubmitted` message class:
```python
class CommandSubmitted(Message):
    def __init__(self, command: str) -> None:
        super().__init__()
        self.command = command
```

**Verify:** Input accepts text, posts message on Enter, clears itself.

**Done when:** Command input widget works as the primary interaction point.

---

### Task 8: Command router — parse and dispatch commands

**Files:** `src/unfamiliar/tui/commands.py`

**Action:** Create the command routing system:

1. `CommandRouter` class:
   - Initialized with a `ThemeConfig` (for alias resolution)
   - `parse(raw_input: str) -> tuple[str, list[str]]` — returns (canonical_command, args)
     - Strips whitespace, lowercases
     - Checks themed aliases first (including multi-word like "file report", "case board")
     - Falls back to canonical names
     - Returns `("unknown", [raw_input])` for unrecognized input
   - `get_commands() -> list[CommandInfo]` — returns all commands with aliases and descriptions

2. Command definitions — a list of `CommandDef` with:
   - canonical name
   - description
   - accepts_args (bool)

**Verify:** `router.parse("investigate")` returns `("next", [])`. `router.parse("file report")` returns `("complete", [])`. `router.parse("break in 42")` returns `("unlock", ["42"])`.

**Done when:** Command router correctly resolves all canonical names and themed aliases, with argument parsing.

---

### Task 9: Main app — UnfamiliarApp with layout composition

**Files:** `src/unfamiliar/tui/app.py`

**Action:** Create `UnfamiliarApp(App)`:

1. **Composition** (`compose()`):
   - `InvestigationHeader`
   - `Horizontal` container with:
     - `ContentArea` (flex: grows to fill)
     - `CommandReference` (fixed width)
   - `CommandInput`

2. **Initialization** (`on_mount()`):
   - Accept `campaign_dir: Path` and `campaign_id: int` as constructor params
   - Open the campaign DB
   - Load `HeaderData` and populate header
   - Load `StatusData` and show welcome/resume message in content area
   - Populate command reference with themed commands

3. **Command handling** (`on_command_submitted()`):
   - Pass raw input to `CommandRouter.parse()`
   - Dispatch to handler methods:
     - `_handle_next()` — show available missions
     - `_handle_complete()` — complete current mission
     - `_handle_status()` — show case board
     - `_handle_briefing()` — show briefing
     - `_handle_mission()` — show current mission detail
     - `_handle_unlock(args)` — force unlock a mission
     - `_handle_help()` — show command list in content area
     - `_handle_quit()` — exit app
     - `_handle_end_campaign()` — close campaign
     - `_handle_unknown(raw)` — show "unknown command" message

4. **Key bindings**:
   - `q` — quit (with confirmation)
   - `?` — help
   - `escape` — clear/cancel

5. **CSS**: `CSS_PATH` pointing to Film Noir TCSS

6. **State tracking**:
   - `current_mission_id: int | None` — the mission currently being viewed/worked on
   - `campaign_id: int`
   - `db: CampaignDB`

**Verify:** App launches, displays layout with all four regions, accepts commands.

**Done when:** App launches with correct layout, accepts and routes commands, displays data in content area.

---

### Task 10: CLI integration — launch TUI from Typer commands

**Files:** `src/unfamiliar/cli.py`

**Action:**
1. Update `investigate` command: after campaign creation, launch the TUI instead of printing stats
2. Update `resume` command: after finding campaign, launch the TUI
3. Create `_launch_tui(campaign_dir: Path, campaign_id: int)` helper that:
   - Imports and instantiates `UnfamiliarApp`
   - Calls `app.run()`
4. Remove the "TUI coming in Phase 14" placeholder messages

**Verify:** `unfamiliar investigate <repo>` creates campaign then launches TUI. `unfamiliar resume` finds campaign and launches TUI.

**Done when:** CLI commands launch the TUI after campaign operations complete.

---

## Interface Contracts

### ThemeConfig (themes/__init__.py)

```python
@dataclass
class ThemeConfig:
    name: str                          # "film_noir"
    display_name: str                  # "Film Noir"
    css_path: Path                     # Path to .tcss file
    aliases: dict[str, str]            # {"next": "investigate", "complete": "file report", ...}
    vocabulary: dict[str, str]         # {"mission": "lead", "chain": "case thread", ...}

def get_theme(name: str) -> ThemeConfig
```

- **Purpose**: Load a theme configuration by name
- **Invariants**: `name` must match a registered theme. v1 only has "film_noir"
- **Valid inputs**: `name="film_noir"`
- **Error conditions**: `ValueError` if theme name not found
- **Edge cases**: Unknown theme name → `ValueError("Unknown theme: 'foo'. Available: film_noir")`

### CommandRouter (commands.py)

```python
class CommandRouter:
    def __init__(self, theme: ThemeConfig) -> None: ...
    def parse(self, raw_input: str) -> tuple[str, list[str]]: ...
    def get_commands(self) -> list[CommandInfo]: ...
```

- **Purpose**: Parse user input into canonical command + arguments
- **Invariants**: Always returns a 2-tuple. Unknown input returns `("unknown", [raw_input])`
- **Valid inputs**: Any string (including empty)
- **Error conditions**: Never raises — always returns a result
- **Edge cases**:
  - Empty string → `("unknown", [""])`
  - Multi-word alias "file report" → `("complete", [])`
  - "break in 42" → `("unlock", ["42"])`
  - Extra whitespace "  status  " → `("status", [])`
  - Case insensitive: "STATUS" → `("status", [])`

### View Data Query Functions (queries.py)

```python
def load_header_data(db: CampaignDB, campaign: Campaign) -> HeaderData
def load_status_data(db: CampaignDB, campaign: Campaign) -> StatusData
def load_available_missions(db: CampaignDB, campaign_id: int) -> list[MissionSummary]
def load_mission_detail(db: CampaignDB, mission_id: int) -> MissionDetail
def load_briefing_data(db: CampaignDB, campaign_id: int) -> BriefingData
def load_chain_summaries(db: CampaignDB, campaign_id: int) -> list[ChainSummary]
```

- **Purpose**: Query DB and return presentation-ready data
- **Invariants**: Never returns None — returns empty lists or zero-count data if no records
- **Valid inputs**: Valid `CampaignDB` with open connection, valid IDs
- **Error conditions**:
  - `load_mission_detail` with non-existent ID → `ValueError("Mission 999 not found")`
  - `load_briefing_data` with no briefing → returns `BriefingData(summary="No briefing generated yet.", tech_stack=[], security_posture="")`
- **Edge cases**:
  - Campaign with 0 missions → `StatusData` with all zeros, empty category list
  - Mission with no file refs → `MissionDetail.file_refs` is empty list
  - Mission with no prerequisites → `MissionDetail.prerequisites` is empty list

### UnfamiliarApp (app.py)

```python
class UnfamiliarApp(App):
    def __init__(self, campaign_dir: Path, campaign_id: int) -> None: ...
```

- **Purpose**: Main TUI application entry point
- **Invariants**: `campaign_dir` must exist with a valid campaign.db. `campaign_id` must exist in that DB.
- **Error conditions**: App shows error message in content area if DB operations fail (doesn't crash)
- **Edge cases**:
  - Campaign with 0 missions → shows "No missions generated" in content
  - All missions completed → shows congratulations message

### ContentArea (widgets/content.py)

```python
class ContentArea(VerticalScroll):
    def show_text(self, markup: str) -> None: ...
    def show_missions(self, missions: list[MissionSummary]) -> None: ...
    def show_mission_detail(self, detail: MissionDetail) -> None: ...
    def show_status(self, data: StatusData) -> None: ...
    def show_briefing(self, data: BriefingData) -> None: ...
    def show_completion(self, data: CompletionData) -> None: ...
    def clear(self) -> None: ...
```

- **Purpose**: Render different content types in the main scrollable area
- **Invariants**: Each `show_*` call clears previous content first. Content is Rich markup.
- **Edge cases**:
  - Empty mission list → shows "No missions available" message
  - Mission with very long description → wraps naturally in scroll area
  - Empty briefing → shows placeholder text

### CommandInput (widgets/command_input.py)

```python
class CommandSubmitted(Message):
    command: str

class CommandInput(Input):
    # Posts CommandSubmitted on Enter, clears input
```

- **Purpose**: Accept user text input and post as message
- **Invariants**: Always clears after submit. Always posts message (even for empty input).
- **Edge cases**: Empty submit → posts `CommandSubmitted(command="")`. Very long input → accepted (no max length).

---

## Contract Updates from UA Testing

The following contract changes were discovered during UA testing and applied to the codebase. These supersede the original contracts above.

---

### Contract Update 1: Widget CSS Architecture — No DEFAULT_CSS with Theme Variables

**Rule:** Do not define `DEFAULT_CSS` on custom widgets that reference theme variables (`$noir-*`). All widget styling must live in the theme's TCSS file.

**Reason:** Textual parses `DEFAULT_CSS` before theme TCSS files are loaded. CSS variables like `$noir-bg-darker` only exist in `noir.tcss`, causing `reference to undefined variable` errors at widget parse time.

**Correct pattern:**
```python
class InvestigationHeader(Widget):
    """Note: Styling is defined in noir.tcss, not DEFAULT_CSS, to use theme variables."""
    # No DEFAULT_CSS defined — all styling in noir.tcss
```

**Applies to:** All custom widgets (InvestigationHeader, CommandReference, ContentArea, CommandInput).

---

### Contract Update 2: Widget Lifecycle — Deferred Child Access

**Rule:** In `on_mount()` and `watch_<reactive>()`, always use `self.call_after_refresh(self._update_children)` instead of calling `_update_children()` directly.

**Reason:** In Textual's widget lifecycle, the parent's `on_mount()` fires *before* child widgets are mounted. Calling `query_one("#child-id")` in `on_mount()` fails with `NoMatches`.

**Correct pattern:**
```python
def on_mount(self) -> None:
    self.call_after_refresh(self._update_children)

def watch_data(self, old_data, new_data) -> None:
    self.call_after_refresh(self._update_children)
```

---

### Contract Update 3: Rich Style String Syntax

**Rule:** In `Text.append()` style strings, use bare hex colors (`#c9a227`), not `color('#c9a227')`.

**Reason:** The `color()` wrapper is only valid in Rich *markup* (e.g., `Text.from_markup("[color('#c9a227')]text[/]")`), not in style strings passed to `append()`.

**Correct formats:**
- `"bold #c9a227"` — bold with gold color
- `"italic #8a8678"` — italic with muted color
- `"dim #5a574d"` — dim with dim color

**Incorrect:** `"bold color('#c9a227')"` → `MissingStyle` error

---

### Contract Update 4: Widget Method Naming — Avoid Textual Internals

**Rule:** Never name a custom widget method `_render()`, `render()`, `render_line()`, or `_compose()`. These collide with Textual internals.

**Reason:** Textual's `Widget._render()` returns a `Visual` object. Overriding it to return `None` breaks rendering with `AttributeError: 'NoneType' object has no attribute 'render_strips'`.

**Safe naming:** `_update_children()`, `_refresh_display()`, `_sync_from_data()`

---

### Contract Update 5: App CSS_PATH Must Be Set Before super().__init__()

**Rule:** Set `CSS_PATH` in `__init__()` *before* calling `super().__init__()`. Never set it in `on_mount()`.

**Reason:** Textual loads CSS during app initialization, before `compose()` runs. Setting `CSS_PATH` in `on_mount()` is too late — the layout is already computed without any CSS rules.

**Correct pattern:**
```python
class UnfamiliarApp(App):
    def __init__(self, ...):
        self.theme_config = get_theme(theme_name)
        self.CSS_PATH = self.theme_config.css_path
        self.router = CommandRouter(self.theme_config)
        super().__init__()
```

**Guidance:** Only database connections and data loading belong in `on_mount()`.

---

### Contract Update 6: Layout Containers Require Explicit Flex Sizing

**Rule:** All layout containers (`Container`, `Horizontal`, `VerticalScroll`) must have explicit `height: 1fr` and/or `width: 1fr` in TCSS to participate in flexible layout.

**Reason:** Without explicit flex sizing, flexible content areas collapse or overflow. Fixed-size elements (header, sidebar, input) render correctly but flexible elements do not.

**Required CSS pattern for horizontal split (sidebar + content):**
```css
#main-container {
    height: 1fr;
    overflow: hidden;
}

#content-row {
    height: 1fr;
    overflow: hidden;
}

#main-content {
    width: 1fr;
    height: 1fr;
}
```

**Key rule:** `overflow: hidden` on containers prevents flexible content from expanding beyond allocated space and pushing fixed-height elements (like CommandInput) off screen.

---

### Contract Update 7: CSS align Property Requires Two Values

**Rule:** Textual's `align` property requires both horizontal and vertical values.

**Correct:** `align: center middle;`
**Incorrect:** `align: center;` → `Invalid value for align property`

---

### Contract Update 8: Input Widget CSS — Height, Padding, and Border Box Model

**Rule:** When styling an `Input` subclass, account for Textual's border-box model. Input's `DEFAULT_CSS` includes `border: tall` (1 row top + 1 row bottom). You must explicitly clear it with `border: none` before setting your own border, and keep vertical padding at 0.

**Reason:** With `height: 3` (border-box), every row of border and padding reduces content space. Input's default `border: tall` (2 rows) + `padding: 1 2` (2 rows) = 4 rows consumed, leaving -1 → 0 for content. The input text is invisible and unfocusable.

**Correct CSS:**
```css
CommandInput {
    background: $noir-bg-input;
    color: $noir-text;
    border: none;              /* Clear Input's default border: tall */
    border-top: solid $border; /* Add only the border you want */
    padding: 0 2;              /* Horizontal padding only */
    height: 3;                 /* 3 - 1 border = 2 rows content */
}

CommandInput:focus {
    border: none;
    border-top: solid $accent;
}
```

**General rule for Input subclass CSS:** Always start with `border: none` to clear defaults, then add only what you need. Calculate: `content rows = height - vertical_border - vertical_padding`. Must be >= 1.
