feat(tui): restore approved current startup mark

(cherry picked from commit 2001b8f2c8)
This commit is contained in:
CodeWhale Bot committed 2026-08-30 17:32:25 -07:00
1 parent 1117692168
commit a2af80480d
13 files changed
+208 -186

No files matched your search

+2 -2
View File
@@ -25,8 +25,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
entry for it — membership (`codewhale login`) is the only door.
- Add the Tideline component family from the ratatui translation spec
(#5698's screens, riding the #5699 work-strip layout): hero startup
surface with quick actions and option strip, composer restyle with the
fluke cap, notifications inbox, merged footer band, pod ledger, receipt
surface with quick actions and option strip, notifications inbox, merged
footer band, pod ledger, receipt
stream, theme list with motion toggles, live preview, settings rail,
and the left rail — each a standalone render module pinned by 28 new
byte-exact golden buffers. Frame wiring follows the Tideline acceptance
@@ -1,10 +1,10 @@
▚▄▄▖ ▗▄▄▟
▝▜███▙▟███▛▘
▝▀▜██▛▀▘
▜█
██▖
▝▀▜█▙▄▖
▄▄▄▄██▌
▜████▀▘
▟██▄▄
▟█████▖
▐█▟▀█████
▐█▜██████
What are we working on?
welcome back · 4 saved sessions in this workspace
@@ -1,11 +1,11 @@
▚▄▄▖ ▗▄▄▟
▝▜███▙▟███▛▘
▝▀▜██▛▀▘
▜█
██▖
▝▀▜█▙▄▖
▄▄▄▄██▌
▜████▀▘
▟██▄▄
▟█████▖
▐█▟▀█████
▐█▜██████
What are we working on?
welcome back · 4 saved sessions in this workspace
@@ -1,12 +1,12 @@
▚▄▄▖ ▗▄▄▟
▝▜███▙▟███▛▘
▝▀▜██▛▀▘
▜█
██▖
▝▀▜█▙▄▖
▄▄▄▄██▌
▜████▀▘
▟██▄▄
▟█████▖
▐█▟▀█████
▐█▜██████
What are we working on?
welcome back · 4 saved sessions in this workspace
+6 -6
View File
@@ -1,9 +1,9 @@
▚▄▄▖ ▗▄▄▟
▝▜███▙▟███▛▘
▝▀▜██▛▀▘
▜█
██▖
▝▀▜█▙▄▖
▄▄▄▄██▌
▜████▀▘
▟██▄▄
▟█████▖
▐█▟▀█████
▐█▜██████
What are we working on?
welcome back · 4 saved sessions in this workspace
+49 -49
View File
@@ -2892,34 +2892,39 @@ use ratatui::layout::{Constraint, Layout};
use crate::palette::UiTheme;
/// The founder's fluke mark — the generated 12x6 cell rendition from the
/// brand master path (`designs/brand/20260829-fluke-founder/TUI_GLYPHS.md`,
/// produced by `build-tui-glyph.py`; never hand-drawn). The hand-projected
/// three-cell crown was deleted by founder decree; this block is its
/// replacement everywhere in the startup path. The ASCII-safe projection maps each
/// quadrant block through its declared `glyphs::ascii_fallback` (`#`, `.`,
/// `\`) — a legible silhouette, not a smear.
const FLUKE_BLOCK: [&str; 6] = [
"▚▄▄▖ ▗▄▄▟",
"▝▜███▙▟███▛▘",
" ▝▀▜██▛▀▘",
" ▜█",
" ██▖",
" ▝▀▜█▙▄▖",
/// The current Codewhale diving-whale mark — the generated 10x6 cell
/// rendition from `designs/brand/20260829-codewhale-mark/TUI_GLYPHS.md`,
/// produced by `build-tui-glyph.py`; never hand-drawn. Its wave is the `>`
/// shell-prompt motif. The retired fluke-ring proposal is deliberately absent
/// from this startup path. The ASCII-safe projection maps each quadrant block
/// through its declared `glyphs::ascii_fallback` — a legible silhouette, not a
/// smear.
const CURRENT_MARK_BLOCK: [&str; 6] = [
"▄▄▄▄██▌",
"▜████▀▘",
" ▟██▄▄",
" ▟█████▖",
" ▐█▟▀█████",
" ▐█▜██████",
];
/// Rows of fluke-mark ink, one `String` per terminal row. `ascii_safe`
/// The generated glyph has a seven-cell first row, but all rows share this
/// ten-cell canvas. Use the canvas for centering and hitboxes so later rows do
/// not shift or escape their recorded area.
const CURRENT_MARK_WIDTH: u16 = 10;
/// Rows of current-mark ink, one `String` per terminal row. `ascii_safe`
/// projects each cell through the declared fallbacks (spec §2: every
/// authored glyph has one).
fn fluke_rows(ascii_safe: bool) -> Vec<String> {
FLUKE_BLOCK
fn current_mark_rows(ascii_safe: bool) -> Vec<String> {
CURRENT_MARK_BLOCK
.iter()
.map(|row| fluke_row(row, ascii_safe))
.map(|row| current_mark_row(row, ascii_safe))
.collect()
}
/// Single-row projection: the unicode row verbatim, or each block through
/// its declared ASCII fallback.
fn fluke_row(row: &str, ascii_safe: bool) -> String {
fn current_mark_row(row: &str, ascii_safe: bool) -> String {
if !ascii_safe {
return row.to_string();
}
@@ -3121,8 +3126,8 @@ impl<'a> TidelineStartup<'a> {
.collect()
}
fn fluke(&self) -> Vec<String> {
fluke_rows(self.ascii_safe)
fn current_mark(&self) -> Vec<String> {
current_mark_rows(self.ascii_safe)
}
}
@@ -3164,8 +3169,9 @@ struct StartupLayout {
quick_rows_start: u16,
/// Row within `hero` where the centered hero block starts.
hero_top: u16,
/// Whether the 12x6 fluke mark fits (`FLUKE_BLOCK` + heading + subtitle).
fluke_shown: bool,
/// Whether the 10x6 current mark fits (the fixed canvas, heading, and
/// subtitle).
current_mark_shown: bool,
/// Option-strip column count: 4 tiles need ~14 cells each to name
/// themselves whole, so below 56 stage columns the strip sheds to 2
/// (§5b shed ⑩) rather than truncate every label mid-word.
@@ -3194,10 +3200,11 @@ fn startup_layout(stage: Rect) -> StartupLayout {
height: dock_h,
..tail
};
let fluke_h = FLUKE_BLOCK.len() as u16;
let fluke_shown = hero.height >= fluke_h.saturating_add(2);
let block_h = if fluke_shown {
fluke_h + 2
let current_mark_h = CURRENT_MARK_BLOCK.len() as u16;
let current_mark_shown =
hero.width >= CURRENT_MARK_WIDTH && hero.height >= current_mark_h.saturating_add(2);
let block_h = if current_mark_shown {
current_mark_h + 2
} else {
u16::min(hero.height, 2)
};
@@ -3210,7 +3217,7 @@ fn startup_layout(stage: Rect) -> StartupLayout {
dock,
quick_rows_start: quick.height.saturating_sub(3),
hero_top: hero.height.saturating_sub(block_h) / 2,
fluke_shown,
current_mark_shown,
strip_columns: if strip.width < 56 { 2 } else { 4 },
}
}
@@ -3224,16 +3231,18 @@ pub fn render_tideline_startup(stage: Rect, buf: &mut Buffer, startup: &Tideline
let theme = startup.theme;
let layout = startup_layout(stage);
// Hero: the generated fluke mark (when the vertical budget admits it),
// the heading, and one dim subtitle (first-run vs returning) as one
// vertically centered block.
// Hero: the generated current mark (when the cell budget admits it), the
// heading, and one dim subtitle (first-run vs returning) as one vertically
// centered block. Each mark row is left-aligned on the same fixed-width
// canvas, rather than individually centered by its visible ink width.
let mut hero_row = layout.hero_top;
if layout.fluke_shown {
for row in startup.fluke() {
centered(
if layout.current_mark_shown {
let mark_x = layout.hero.x + layout.hero.width.saturating_sub(CURRENT_MARK_WIDTH) / 2;
for row in startup.current_mark() {
set_span(
buf,
layout.hero,
hero_row,
mark_x,
layout.hero.y + hero_row,
&Span::styled(row, chrome(theme, ChromeInk::Attention)),
);
hero_row = hero_row.saturating_add(1);
@@ -3423,12 +3432,12 @@ pub fn render_tideline_startup(stage: Rect, buf: &mut Buffer, startup: &Tideline
}
}
/// Recorded hitboxes for the startup stage (spec §6): the hero fluke, each
/// quick action row, and each option-strip tile. Same shapes as the painted
/// cells.
/// Recorded interactive hitboxes for the startup stage (spec §6): each quick
/// action row and option-strip tile, plus the docked composer's focus and send
/// targets. The hero brand mark is deliberately decorative; it has no action
/// or hitbox until the product defines one with keyboard parity.
#[derive(Debug, Clone, Default)]
pub struct TidelineStartupHitboxes {
pub fluke: Rect,
pub actions: Vec<Rect>,
pub options: Vec<Rect>,
/// The docked composer's input row (click focuses, exactly like Tab).
@@ -3448,15 +3457,6 @@ pub fn tideline_startup_hitboxes(stage: Rect) -> TidelineStartupHitboxes {
}
let layout = startup_layout(stage);
if layout.fluke_shown {
let fluke_w = FLUKE_BLOCK[0].width() as u16;
out.fluke = Rect {
x: layout.hero.x + (layout.hero.width.saturating_sub(fluke_w)) / 2,
y: layout.hero.y + layout.hero_top,
width: fluke_w,
height: FLUKE_BLOCK.len() as u16,
};
}
out.actions = (0..3)
.map(|index| Rect {
x: layout.quick.x + 2,
+36 -26
View File
@@ -8,8 +8,8 @@ use ratatui::layout::Rect;
use unicode_width::UnicodeWidthChar;
use super::{
FLUKE_BLOCK, LaunchAction, TidelineStartup, handle_launch_key, render_tideline_startup,
tideline_startup_hitboxes,
CURRENT_MARK_BLOCK, CURRENT_MARK_WIDTH, LaunchAction, TidelineStartup, handle_launch_key,
render_tideline_startup, tideline_startup_hitboxes,
};
use crate::palette::UI_THEME;
use crate::tui::golden_harness::{BLOCKER_SIZES, assert_matches_golden, render_golden_text};
@@ -87,7 +87,7 @@ fn startup_matches_golden_at_the_40x12_terminal_floor() {
// §5b shed order proven at the floor. A 40x12 terminal leaves the stage
// 10 rows after the topbar and merged footer: the QUICK ACTIONS label
// row and the wave rules collapse, the hero keeps heading + subtitle
// (the 12x6 fluke needs its 8-row budget), and the strip sheds to 2
// (the 10x6 current mark needs its 8-row budget), and the strip sheds to 2
// columns so tile labels stay whole.
let fixture = returning();
let startup = fixture.widget(&UI_THEME);
@@ -123,26 +123,29 @@ fn startup_hero_states_first_run_vs_returning() {
}
#[test]
fn startup_hero_paints_the_generated_fluke_block_and_sheds_it_at_short_stages() {
// The generated 12x6 mark (never a hand-drawn crown): centered, one row
// per FLUKE_BLOCK line, above the heading. It sheds below its 8-row
// budget instead of being clipped mid-mark.
fn startup_hero_paints_the_generated_current_mark_on_its_fixed_canvas_and_sheds_it_at_short_stages()
{
// The generated 10x6 current mark: each row starts on the same ten-cell
// canvas, above the heading. It sheds below its 8-row budget instead of
// being clipped mid-mark.
let startup = TidelineStartup::new(&UI_THEME, 4, true);
let wide = draw(80, 24, &startup);
for row in FLUKE_BLOCK {
let row_w = unicode_width::UnicodeWidthStr::width(row);
let expected = format!("{}{row}", " ".repeat((80 - row_w) / 2));
for row in CURRENT_MARK_BLOCK {
let expected = format!(
"{}{row}",
" ".repeat((80 - usize::from(CURRENT_MARK_WIDTH)) / 2)
);
assert!(
wide.lines().any(|line| line.starts_with(&expected)),
"fluke row {row:?} must paint centered at 80x24:\n{wide}"
"current mark row {row:?} must paint on its fixed canvas at 80x24:\n{wide}"
);
}
// 60x16: stage 14 rows -> hero 5 < 8, mark sheds, heading stays.
let short = draw(60, 16, &startup);
assert!(short.contains("What are we working on?"), "{short}");
assert!(
!short.contains(FLUKE_BLOCK[0]),
"the fluke must not clip mid-mark at short stages:\n{short}"
!short.contains(CURRENT_MARK_BLOCK[0]),
"the current mark must not clip mid-mark at short stages:\n{short}"
);
}
@@ -285,13 +288,26 @@ fn startup_option_strip_sheds_to_two_columns_when_narrow() {
fn startup_ascii_safe_has_no_wide_or_unsupported_glyphs() {
let startup = TidelineStartup::new(&UI_THEME, 4, true).ascii_safe(true);
let text = draw(100, 30, &startup);
// The generated fluke projects through the declared quadrant-block
// fallbacks (`#`, `.`, `\`) — a legible silhouette, not a smear.
assert!(
text.lines()
.any(|line| line.contains("\\###") || line.contains("###.")),
"ascii fluke block must paint:\n{text}"
);
// The generated current mark projects through the declared quadrant-block
// fallbacks — a legible silhouette, not a hand-made ASCII substitute.
for row in CURRENT_MARK_BLOCK {
let projected: String = row
.chars()
.map(|ch| {
if ch == ' ' {
ch.to_string()
} else {
crate::tui::glyphs::ascii_fallback(&ch.to_string())
.map(str::to_string)
.unwrap_or_else(|| ch.to_string())
}
})
.collect();
assert!(
text.lines().any(|line| line.contains(projected.trim_end())),
"ascii current-mark row {projected:?} must paint:\n{text}"
);
}
assert!(text.contains(". ~~~ ."), "wave rule projects to ASCII");
assert!(text.contains("Enter >"), "chevron projects to >");
for ch in text.chars() {
@@ -311,7 +327,6 @@ fn startup_hitboxes_match_painted_cells() {
let (w, h) = (100, 30);
let area = Rect::new(0, 0, w, h);
let hitboxes = tideline_startup_hitboxes(area);
assert!(hitboxes.fluke.width > 0, "fluke is a hitbox at 100x30");
assert_eq!(hitboxes.actions.len(), 3, "one rect per quick action row");
assert_eq!(hitboxes.options.len(), 4, "one rect per option tile");
let mut buf = Buffer::empty(area);
@@ -329,11 +344,6 @@ fn startup_hitboxes_match_painted_cells() {
assert!(rect.x + rect.width <= w);
assert!(rect.y + rect.height <= h);
}
let fluke_cells = painted(hitboxes.fluke);
assert!(
!fluke_cells.trim().is_empty(),
"fluke hitbox covers the mark"
);
}
#[test]
+11 -10
View File
@@ -17,11 +17,12 @@ prose > the recovered motion sketch (motion language) > `tideline-redesign.html`
Cell-inventory read of the references (startup, work+pod, settings/appearance):
- **Topbar (all three).** One row: fluke + `CODEWHALE` wordmark; contextual
- **Topbar (all three).** One row: `CODEWHALE` wordmark; contextual
segments (`run …`, `pod …`, `3/4 whales`, `model …`, `theme …`,
`Settings / Appearance`, `folder …`); pinned right = `context NN% ▰▰▱▱▱` +
full clock. Segment set varies per screen; brand/meter/clock never move.
- **Startup.** Centered hero: fluke mark, "What are we working on?", one dim
- **Startup.** Centered hero: the current diving-whale mark (whose wave forms
a `>` prompt), "What are we working on?", one dim
subtitle; `QUICK ACTIONS` band with 3 rows (icon · label · description ·
command + `›`); a 4-column option strip (New worktree / Chat only / Theme /
Help); whale-outline composer; footer with route · cost · keys.
@@ -41,7 +42,7 @@ Cell-inventory read of the references (startup, work+pod, settings/appearance):
| In the reference | Why it cannot ship | Decision |
|---|---|---|
| Composer drawn as a stroked whale outline | No bezier strokes; only box-drawing glyphs | **Rounded border + fluke cap.** `╭─╮│╰╯` border (dim at rest, Info on focus), fluke glyph (`▚△▞`, ASCII `<.>`) set into the top-right corner cell as the cap. The hull taper silhouette is **dropped** — it is sub-cell vector work, and the fluke cap carries the identity at 1/50th the cells. The send `↑` becomes a 3-cell hitbox `[↑]` right-aligned inside the border. |
| Composer drawn as a stroked whale outline | No bezier strokes; only box-drawing glyphs | **Rounded border.** `╭─╮│╰╯` border (dim at rest, Info on focus); the old fluke cap is retired and no brand glyph is hand-drawn into the composer. The send `↑` becomes a 3-cell hitbox `[↑]` right-aligned inside the border. The approved mark appears only where its full generated terminal projection fits. |
| Translucent whale silhouettes behind text (Deepsea preview) | No alpha; painting over text destroys it | **Empty-cells-only compositing**, the `ambient_life.rs` rule verbatim: write only cells that are open water (`is_open_water` + `TEXT_CLEARANCE_ROWS = 1` clearance from `occupied_text_bounds`). Eviction order when water is scarce: bubbles first, then fish school, then jellyfish; the whale cameo is evicted last (highest identity value). Caustic-style tinting stays bg-only on `cell.symbol() == " "`. Deepsea ambient runs only under `MotionMode::Full`. |
| SVG icons (plug, clock, folder, palette, chat) | Not renderable | One glyph per action, added to `glyphs.rs` with declared ASCII fallbacks via `ascii_fallback`: plug `⌁`→`+`, resume `↺`→`<`, folder `▤`→`=`, palette `◐`→`*`, chat `◌`→`o`, worktree `⑂`→`y`, help `?`→`?` (identity). Each is 1 cell, no wide glyphs. |
| Ledger cells wrapping to two lines | Table columns are exact integer cells | Fixed column widths + per-column truncation: WHALE 10 (never truncates — names are short by contract), ASSIGNMENT = remainder (truncate with `…`, never wrap), STATE 12 (glyph + word), ELAPSED 8, RECEIPTS 8, LAST UPDATE 8 (`HH:MM:SS`). **At 80 columns** the rail is hidden and ledger sheds to `WHALE │ ASSIGNMENT │ STATE` — ELAPSED, RECEIPTS, LAST UPDATE drop in that order before ASSIGNMENT loses cells. |
@@ -65,7 +66,7 @@ constraints ~:928). The references collapse the bottom into one footer:
| 4 background-work chip | **Deleted as a band**; the fact moves to the topbar `pod n/m` segment and the rail WORK group (one surface owns each fact). |
| 5 session boot receipt | **Deleted as a band**; boot lines become ordinary transcript receipts. |
| 6 activity band | **Merged into the footer** (left half: phase chip + echolocation + cost). |
| 7 composer | **Extends** — rounded border + fluke cap + `[↑]` hitbox; composer authority logic untouched. |
| 7 composer | **Extends** — rounded border + `[↑]` hitbox; composer authority logic untouched. |
| 8 identity band | **Merged into the footer** (right half: depth line + key legend). `phase_strip::render_identity` is the merge target; `render_footer` delegates today already. |
Orphaned facts, each with exactly one home: cost/token ledger → footer;
@@ -109,7 +110,7 @@ where the `Rect` is stored for `mouse_ui` (existing pattern:
| Component | What it does | States | Data source | Replaces | Owning file | Keys | Mouse hitbox | Golden name |
|---|---|---|---|---|---|---|---|---|
| Topbar | One-row status surface | per-screen segment set; hover; shed | `effective_route_identity_display()`, run/pod summaries, `context_budget` pct, injected clock | `underwater::render_header` | `tui/topbar.rs` ✅ | Tab⇄, Enter activate | brand/menu + per-segment rects → `viewport.last_topbar_hitboxes` | `topbar_{startup,work,settings}_{w}x{h}` ✅ |
| Hero (startup) | Centered fluke + prompt | first-run vs returning | `LaunchState`, `workspace_session_count` | `render_launch_screen` | `tui/underwater.rs` | — | fluke = open menu (`launch.row_areas`) | `startup_{w}x{h}` |
| Hero (startup) | Centered current mark + prompt | first-run vs returning | `LaunchState`, `workspace_session_count` | `render_launch_screen` | `tui/underwater.rs` | — | decorative identity; no action until keyboard parity exists | `startup_{w}x{h}` |
| Quick actions | 3 command rows | selected/hover/disabled (no model) | `LaunchAction`, provider state | launch menu rows | `tui/underwater.rs` + `mouse_ui.rs:441` | ↑/↓, Enter, Esc | row rects (exists) | `startup_*` |
| Option strip | 4 columns (worktree/chat/theme/help) | hover/selected | `LaunchState` | launch options row | same | Tab, Enter | 4 col rects | `startup_*` |
| Rail | Left column, 5 groups + collapse | expanded/collapsed/focused | `WorkSurfaceState`, `subagent_cache`, run list, git status | work strip + `sidebar` remnants | `tui/work_surface/` (#5699 territory) | Tab, ↑/↓, Enter, `«` | `WorkHitbox{WorkRowId,row_y}` (exists) | `work_{w}x{h}` |
@@ -118,7 +119,7 @@ where the `Rect` is stored for `mouse_ui` (existing pattern:
| Theme list | 13 themes + motion toggles | selected/preview/applying | `ThemeId`, `ocean_treatment`, `low_motion`, `fancy_animations` | `theme_picker.rs` | `tui/theme_picker.rs`, `views/` | ↑/↓, Enter preview/apply | row rects | `settings_{w}x{h}` |
| Live preview | Projection of a real screen in chosen theme | mirrors screen state; never a second store | same render fns, `TestBackend`-style projection into the pane | settings preview | `tui/views/` settings | — | none (passive) | `settings_*` |
| Settings rail | 8 categories + meta rows | selected | `ConfigView` | `ConfigView` nav | `tui/views/mod.rs` | ↑/↓, Tab | category rects | `settings_*` |
| Composer | Input with fluke cap + send hitbox | focus, pending crumb, approval-replaced | `ComposerState`, pending preview | composer_ui/chrome (extends) | existing composer files | Enter, ⇧Enter, Esc | `[↑]` submit rect; border focus click | `composer_{w}x{h}` |
| Composer | Input with rounded border + send hitbox | focus, pending crumb, approval-replaced | `ComposerState`, pending preview | composer_ui/chrome (extends) | existing composer files | Enter, ⇧Enter, Esc | `[↑]` submit rect; border focus click | `composer_{w}x{h}` |
| Footer | One band: phase·cost (left), depth line·keys (right) | per-phase ink; 80% warn | `SessionState` cost, phase, `context_budget` | slots 6+8 merged | `tui/phase_strip.rs` | — | depth segment → context inspector | `footer_{w}x{h}` |
| Notifications inbox | Attention rows (gold ◆) | unread/read; per-kind | `status_toasts`/`sticky_status` → typed records | toast soup | `tui/notifications.rs` | Enter, `r`, Esc | row rects | `notifications_{w}x{h}` |
@@ -141,7 +142,7 @@ Startup stage:
```rust
let [hero, rule_a, quick, rule_b, strip, spacer] = Layout::vertical([
Constraint::Percentage(38), // hero: fluke + heading + subtitle
Constraint::Percentage(38), // hero: current mark + heading + subtitle
Constraint::Length(1), // wave rule `⋯ ∼∼∼ ⋯` (dim, static)
Constraint::Length(3 + 2), // QUICK ACTIONS: label row + 3 rows + margins
Constraint::Length(1), // wave rule
@@ -220,7 +221,7 @@ families obey `STATUS_BAR_COLOR_GRAMMAR.md`:
| Element | ChromeInk | Family |
|---|---|---|
| Fluke + wordmark | `Attention` (gold) | Cognition — the sanctioned whale-mark gold |
| Current mark + wordmark | `Attention` (gold) | Cognition — the sanctioned whale-mark gold |
| Segment labels / separators / clock | `Metadata` / `MetadataDim` / `MetadataHint` | Metadata |
| Route · model · run | `Identity` | Identity |
| Pod (live) / `3/4` / context meter / theme name | `Active` / `Info` | Active / Identity |
@@ -281,8 +282,8 @@ authored, everything else staying calm:
**spout** — the one payoff moment; suppressed if work resumes instantly.
*Failure:* flat `✗` + the model segment says what to do next (microcopy:
"no route yet — /connect"), never "Error". *Exit into work:* composer
keeps focus — no modal re-orientation. Surprise budget: spout, the fluke
cap waking on composer focus, hero breath.
keeps focus — no modal re-orientation. Surprise budget: spout and hero
breath; composer focus changes only its border and send affordance.
- **Work + Pod.** *Orient:* the pod-formation tree draws its `├──└──` rows in
one ≤600 ms top-down reveal, then is forever still (continuity: the tree is
the same object in the ledger below). *Waiting:* receipt rows carry typed
+18 -17
View File
@@ -5,6 +5,12 @@ gives every agent role a species-led whale and every whale one of six runtime
states. This document is the contract for how that identity appears in the
Codewhale terminal UI. The implementation is `crates/tui/src/tui/whales.rs`.
> **Status — runtime badges current; portrait reference retired.** The current
> Codewhale product mark is the generated diving whale whose wave forms a
> `>` prompt, sourced from `designs/brand/20260829-codewhale-mark/`. The old
> hand-drawn crown/fluke treatment described in the historical portrait record
> below is not a current product mark and does not render in `underwater.rs`.
The source artwork (six species × six states × four colorways of 384×192
rasters, plus concept boards and the visual brief) lives in the CWC repository.
No CWC file is copied here: the terminal whales are authored fresh as glyph
@@ -92,29 +98,24 @@ accents only and must never be used for status, mode, or permission.
## Art
The drawing decisions — including the empty-state hero mark in
`underwater.rs`, which shares this glyph vocabulary — live in
[`WHALE_TEAMS_TUI_ART.md`](WHALE_TEAMS_TUI_ART.md).
The current startup mark is intentionally separate from Whale Teams artwork:
it is the approved generated diving-whale terminal projection in
`underwater.rs`. [`WHALE_TEAMS_TUI_ART.md`](WHALE_TEAMS_TUI_ART.md), its text
preview, and its generator are retained as an **archived portrait reference**
for removed artwork; they must not be used as a product-mark source.
Portraits are 3 rows × 14 columns: a two-column state-cue lane on the left,
the head at the left, the crown fluke `▚△▞` on the right (the same vocabulary
as the idle Codewhale mark in `underwater.rs`), and a wake lane under the tail.
Badges are two cells: a species feature glyph in the role accent plus a body
cell.
Every glyph has a `glyphs::ascii_fallback` entry, and `portrait_ascii` /
`badge_ascii` expose the narrowed silhouettes. With `CODEWHALE_ASCII_SAFE=1`
the seven badges stay distinct: `<#` `#]` `#\` `:#` `#-` `*#` `.#`.
The live runtime uses two-cell badges: a species feature glyph in the role
accent plus a body cell. With `CODEWHALE_ASCII_SAFE=1` the seven badges stay
distinct: `<#` `#]` `#\` `:#` `#-` `*#` `.#`.
## Surfaces
- `/fleet` roster: species badge on every member row; the detail pane opens
with the portrait (≥ 60 columns) and the `badge Name · species · job` line
— no caption labels.
- `/fleet` roster: species badge on every member row, with the
`badge Name · species · job` line — no retired portrait is rendered.
- `/fleet` workers (`SubAgentsView`): badge on every worker row, plus a
second line with the badge and the state cue and word — nothing else.
- `whales::portrait` / `whales::badge` are public for other surfaces (the
Fleet setup role pane is the intended next consumer).
- `whales::badge` is public for other surfaces (the Fleet setup role pane is
the intended next consumer).
Not represented: the deep-current, reef-shift, and night-signal colorways; the
legacy WhalePet atlas and Rive companion contracts (which have no artwork in
+16 -9
View File
@@ -1,9 +1,16 @@
# Whale Teams TUI — art translation
# Whale Teams TUI — archived portrait-art reference
How the Signal Cut mascots become terminal glyph art. The runtime contract
(role → species, state → evidence, inks) is [`WHALE_TEAMS_TUI.md`](WHALE_TEAMS_TUI.md);
this file is the *drawing* decision record for the empty-state hero and the
roster art it must match.
> **Status — retired reference, not current brand authority.** This record
> preserves the removed hand-drawn portrait/crown treatment for historical
> comparison. Do not use its `▚△▞` treatment as a Codewhale mark or infer that
> `underwater.rs` renders it. The current approved terminal mark is the
> generated diving whale from `designs/brand/20260829-codewhale-mark/`, whose
> wave forms a `>` prompt.
How the former Signal Cut mascots became terminal glyph art. The remaining
live runtime contract (role → species, state → evidence, inks) is
[`WHALE_TEAMS_TUI.md`](WHALE_TEAMS_TUI.md); this file is historical drawing
evidence only, not a specification for the startup hero or roster.
Source of truth (CWC repo, read-only from here):
`docs/design/whale-teams/VISUAL-BRIEF.md`, the Signal Cut concept boards and
@@ -24,11 +31,11 @@ Read off the matrix and the working keyframes:
- **Working** adds bounded cyan wake dots *behind* the tail — never a change
of pose, expression, or scale.
## (a) The empty-state hero
## (a) Historical empty-state portrait
`crates/tui/src/tui/underwater.rs` — `IDLE_WHALE_SPOUT_ROW` / `IDLE_WHALE_ROWS`.
Four rows, 17 columns (uwu: 16), so it stays quiet inside the 60-column empty
state.
Former `crates/tui/src/tui/underwater.rs` idle-art constants. The current
startup hero no longer uses this artwork; it renders the approved generated
diving-whale mark instead.
```
˚
+22 -12
View File
@@ -208,7 +208,7 @@
/* Startup: a quiet first choice, not a blank terminal. */
.startup { min-height: 650px; display: grid; grid-template-rows: 1fr auto; padding: 0 10%; }
.startup-center { display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 72px 0 38px; }
.startup-mark { min-height: 50px; color: var(--gold); border: 0; background: transparent; cursor: pointer; font: inherit; font-size: 37px; letter-spacing: .18em; }
.startup-mark { min-width: 10ch; min-height: 6lh; color: var(--gold); font: 700 13px/1 var(--mono); letter-spacing: 0; white-space: pre; }
.startup h2 { margin: 20px 0 7px; font-size: 23px; letter-spacing: -.025em; font-weight: 650; }
.startup p { margin: 0; color: var(--t-dim); }
.quick-actions { border-top: 1px solid var(--t-faint); padding: 13px 0 0; }
@@ -276,7 +276,7 @@
.doc-section { margin-top: 70px; }.doc-section h2 { margin: 0 0 7px; font-size: 24px; letter-spacing: -.025em; }.doc-section > p { max-width: 870px; margin: 0; color: var(--page-dim); }.rule-grid { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 12px; margin-top: 17px; }.rule { padding: 14px; border: 1px solid var(--card-edge); border-radius: 10px; background: var(--card); }.rule b { color: var(--accent); }.rule p { margin: 6px 0 0; color: var(--page-dim); font-size: 13px; }
.spec-table { width: 100%; margin-top: 17px; border-collapse: collapse; font-size: 12px; }.spec-table th, .spec-table td { padding: 9px 10px; border-bottom: 1px solid var(--card-edge); text-align: left; vertical-align: top; }.spec-table th { color: var(--page-dim); font-size: 10px; letter-spacing: .08em; text-transform: uppercase; }.spec-table code { color: var(--accent); font-size: 11px; }.status-existing { color: var(--ok); }.status-proposed { color: var(--gold); }.status-guard { color: var(--warn); }
.manifest-note { margin-top: 13px; padding: 12px 14px; border-left: 2px solid var(--accent2); background: color-mix(in srgb, var(--accent) 5%, var(--card)); color: var(--page-dim); font-size: 13px; }.manifest-note b { color: var(--page-fg); }
.glyph-grid { display: grid; grid-template-columns: repeat(5,minmax(0,1fr)); gap: 10px; margin-top: 17px; }.glyph-card { padding: 12px; border: 1px solid var(--card-edge); background: var(--card); }.glyph-sample { color: var(--accent2); font: 700 20px/1 var(--mono); }.glyph-card:nth-child(2) .glyph-sample { color: var(--gold); }.glyph-card:nth-child(3) .glyph-sample { color: var(--accent); }.glyph-card:nth-child(4) .glyph-sample { color: var(--reason); }.glyph-card:nth-child(5) .glyph-sample { color: var(--ok); }.glyph-card b { display: block; margin-top: 8px; }.glyph-card p { margin: 3px 0 0; color: var(--page-dim); font-size: 12px; }
.glyph-grid { display: grid; grid-template-columns: repeat(5,minmax(0,1fr)); gap: 10px; margin-top: 17px; }.glyph-card { padding: 12px; border: 1px solid var(--card-edge); background: var(--card); }.glyph-sample { color: var(--accent2); font: 700 20px/1 var(--mono); }.glyph-sample.terminal-mark { font-size: 11px; line-height: 1; white-space: pre; }.glyph-card:nth-child(2) .glyph-sample { color: var(--gold); }.glyph-card:nth-child(3) .glyph-sample { color: var(--accent); }.glyph-card:nth-child(4) .glyph-sample { color: var(--reason); }.glyph-card:nth-child(5) .glyph-sample { color: var(--ok); }.glyph-card b { display: block; margin-top: 8px; }.glyph-card p { margin: 3px 0 0; color: var(--page-dim); font-size: 12px; }
.acceptance { margin-top: 15px; columns: 2; column-gap: 32px; }.acceptance label { display: block; break-inside: avoid; margin: 0 0 9px; color: var(--page-dim); font-size: 13px; }.acceptance input { margin-right: 8px; accent-color: var(--accent); }
.hidden { display: none !important; }
@@ -332,7 +332,7 @@
<div class="terminal" id="terminal" data-background="Color::Reset">
<div class="topbar">
<button class="brand-lockup" type="button" data-target="brand.primary" title="Codewhale identity"><span class="brand-cell" aria-hidden="true">▚△▞</span>CODEWHALE</button>
<button class="brand-lockup" type="button" data-target="brand.primary" title="Codewhale identity">CODEWHALE</button>
<span class="divider"></span>
<button type="button" data-target="header.workspace"><span class="label">folder</span> <span class="path">~/codewhale</span></button>
<span class="divider collapse-on-small"></span>
@@ -375,7 +375,7 @@
<div class="turn-copy">Make release 0.9.12 ready.</div>
</article>
<article class="turn assistant">
<span class="turn-marker" aria-hidden="true">▚△▞</span>
<span class="turn-marker" aria-hidden="true">◌</span>
<div class="turn-head"><strong>CODEWHALE</strong><time>14:41:02</time></div>
<div class="turn-copy">I’ll prepare release 0.9.12. Spawning a pod to run checks in parallel.<br><span class="dim">Plan: fix CI, audit contributor ancestry, draft release notes.</span></div>
@@ -420,7 +420,12 @@
<div class="screen" id="screen-startup">
<div class="startup">
<div class="startup-center">
<button type="button" class="startup-mark" data-target="brand.startup" aria-label="Codewhale fluke asset slot">▚△▞</button>
<div class="startup-mark" role="img" aria-label="Codewhale diving-whale mark">▄▄▄▄██▌
▜████▀▘
▟██▄▄
▟█████▖
▐█▟▀█████
▐█▜██████</div>
<h2>What are we working on?</h2>
<p>Folder and tool access are asked when needed.</p>
</div>
@@ -537,7 +542,7 @@
<section class="doc-section" id="principles">
<h2>What makes it Codewhale</h2>
<p>It should feel as legible as Codex, Claude Code, or a sober native workbench before it feels branded. The brand arrives through timing, language, a small current of cyan, and the fluke at moments of authorship or return.</p>
<p>It should feel as legible as Codex, Claude Code, or a sober native workbench before it feels branded. The brand arrives through timing, language, a small current of cyan, and the approved diving-whale mark at moments of authorship or return.</p>
<div class="rule-grid">
<article class="rule"><b>Host first</b><p>Match Terminal, Dark, and Light use <code>Color::Reset</code> for the background. Their foreground palette adapts; the user’s terminal remains the surface.</p></article>
<article class="rule"><b>Conversation first</b><p>The transcript is the default lens. The rail and ledger can fold; receipts are quiet until a person asks for evidence.</p></article>
@@ -550,9 +555,14 @@
<section class="doc-section" id="glyphs">
<h2>Small whale grammar</h2>
<p>The fluke is the new mark. It appears at the wordmark, startup, a Codewhale turn, and a settled resurface—not in every button or table row. The rest of the glyphs are working notation. All retain an ASCII fallback in the terminal projection.</p>
<p>The current diving-whale mark, whose wave forms a <code>&gt;</code> prompt, appears at the startup hero and other sparse identity moments—not in every button or table row. The rest of the glyphs are working notation. All retain an ASCII fallback in the terminal projection.</p>
<div class="glyph-grid">
<article class="glyph-card"><div class="glyph-sample">▚△▞</div><b>Fluke</b><p>Identity and a successful resurface. Source is an approved asset; the terminal gets a cell projection.</p></article>
<article class="glyph-card"><div class="glyph-sample terminal-mark">▄▄▄▄██▌
▜████▀▘
▟██▄▄
▟█████▖
▐█▟▀█████
▐█▜██████</div><b>Current mark</b><p>Identity at startup and other sparse moments. This is the approved generated terminal projection, not a hand-made substitute.</p></article>
<article class="glyph-card"><div class="glyph-sample">⌁</div><b>Pod formation</b><p>Parallel work beginning. Never a generic button icon.</p></article>
<article class="glyph-card"><div class="glyph-sample">◌</div><b>Wake / live work</b><p>Active receipt or a running worker. Static in low-motion and ANSI.</p></article>
<article class="glyph-card"><div class="glyph-sample">◆</div><b>Spout / attention</b><p>A real user decision is needed. Gold and words; not an error.</p></article>
@@ -580,7 +590,7 @@
<tr><td>Prompt</td><td><code>crates/tui/src/prompts/text.rs</code></td><td><code>BASE_PROMPT</code> untouched</td><td class="status-guard">out of scope</td></tr>
</tbody>
</table>
<div class="manifest-note"><b>CWC parity:</b> the web app shares the information architecture, terms, state grammar, typed notification facts, provider truth, and fluke asset manifest. It does not imitate a terminal. Web controls render as normal app controls; the TUI renders the same contracts as cells.</div>
<div class="manifest-note"><b>CWC parity:</b> the web app shares the information architecture, terms, state grammar, typed notification facts, provider truth, and current-mark asset manifest. It does not imitate a terminal. Web controls render as normal app controls; the TUI renders the same contracts as cells.</div>
</section>
<section class="doc-section" id="acceptance">
@@ -596,7 +606,7 @@
<label><input type="checkbox"> Settings show current Z.ai / GLM-5.3 separately from saved startup defaults.</label>
<label><input type="checkbox"> Banner, in-app history, and audio settings do not masquerade as one switch.</label>
<label><input type="checkbox"> low_motion and fancy_animations both settle immediately and preserve the final state.</label>
<label><input type="checkbox"> Fluke reads as an attached whale tail at all approved sizes; no speculative SVG ships.</label>
<label><input type="checkbox"> The generated current diving-whale terminal projection matches the approved source at all approved sizes; no hand-drawn substitute ships.</label>
</div>
</section>
</main>
@@ -611,8 +621,8 @@
"deepsea": "explicit OceanTreatment::Deepsea surface"
},
"targets": {
"brand.primary": {"title":"Primary fluke mark","owner":"glyphs.rs + underwater.rs","state":"BrandMark::Primary","action":"open product menu","hitbox":"header brand Rect","notes":"Approved asset manifest; terminal uses cell projection."},
"brand.startup": {"title":"Startup fluke","owner":"underwater.rs + onboarding/mod.rs","state":"OnboardingState","action":"open identity/help","hitbox":"startup mark Rect","notes":"One of the sparse identity placements."},
"brand.primary": {"title":"Primary Codewhale wordmark","owner":"glyphs.rs + underwater.rs","state":"BrandMark::Primary","action":"open product menu","hitbox":"header brand Rect","notes":"One-row header preserves readable wordmark; the full approved mark belongs where its cell projection fits."},
"brand.startup": {"title":"Startup diving-whale mark","owner":"underwater.rs","state":"OnboardingState","action":"decorative identity","hitbox":"none","notes":"The approved generated 10x6 terminal projection, used at a sparse identity placement. It becomes interactive only with a defined keyboard-parity action."},
"header.workspace": {"title":"Workspace identity","owner":"underwater.rs","state":"GitStatusSnapshot","action":"open workspace details","hitbox":"header workspace Rect","notes":"Metadata family; truncate before wrapping."},
"header.route": {"title":"Route","owner":"underwater.rs","state":"route identity","action":"open provider/route inspector","hitbox":"header route Rect","notes":"Identity-blue, not a status light."},
"header.model": {"title":"Effective model","owner":"underwater.rs + App","state":"effective provider/model route","action":"open provider inspector","hitbox":"header model Rect","notes":"Must come from current App route, not persisted config."},
+5 -2
View File
@@ -1,4 +1,8 @@
Codewhale empty-state whale mark — CURRENT (main) vs NEW (Signal Cut)
ARCHIVED REFERENCE — removed portrait artwork, not the current Codewhale mark.
For the approved startup identity, use `designs/brand/20260829-codewhale-mark/`:
the generated diving whale whose wave forms a `>` prompt.
Former Codewhale empty-state whale portrait — CURRENT (main) vs NEW (Signal Cut)
== classic ==
@@ -43,4 +47,3 @@ Codewhale empty-state whale mark — CURRENT (main) vs NEW (Signal Cut)
.#######.
block width: 21 -> 16 columns
+25 -35
View File
@@ -1,9 +1,11 @@
#!/usr/bin/env python3
"""Preview the Codewhale empty-state whale mark: before vs after.
"""Preview archived Codewhale empty-state portrait art: before vs after.
Reads the live art constants out of `crates/tui/src/tui/underwater.rs` so the
preview cannot drift from what the TUI actually renders, and prints them beside
the art this branch replaced.
This script is historical comparison evidence only. It is not a preview of the
current Codewhale mark: that mark is the generated diving whale from
`designs/brand/20260829-codewhale-mark/`, whose wave forms a `>` prompt.
The historical rows are held below as a fixed record because the live
`underwater.rs` constants were intentionally deleted with the old artwork.
python3 scripts/preview-whale-art.py # ANSI true colour
python3 scripts/preview-whale-art.py --plain # no escape codes
@@ -16,12 +18,6 @@ Colours are the real palette tokens: Signal Gold #F6C453 body, Signal Current
from __future__ import annotations
import argparse
import pathlib
import re
import sys
ROOT = pathlib.Path(__file__).resolve().parents[1]
UNDERWATER = ROOT / "crates" / "tui" / "src" / "tui" / "underwater.rs"
GOLD = (246, 196, 83) # WHALE_HUMAN_RGB — Signal Gold
CYAN = (72, 215, 255) # WHALE_CYAN_RGB — Signal Current
@@ -44,6 +40,22 @@ BEFORE = {
]),
}
# The former Signal Cut revision, kept as a static record. It must not be
# inferred from live source: the runtime portrait constants were deliberately
# removed when the product mark changed.
AFTER = {
"classic": (" ˚", [
" ▗▄▄▟▄▄▄▄▄▖ ▚△▞",
" ▐█·████████▙▄▄▞",
" ▝▀▀▀▀▀▀▀▀▘",
]),
"uwu": (" ˚✦", [
" ▗▄▄▟▄▄▄▄▖ ▚△▞",
" ▐█░·░█████▙▄▄▞",
" ▝▀▀▀▀▀▀▀▘",
]),
}
# Same table the TUI narrows through (`glyphs::ascii_fallback`).
ASCII_FALLBACK = {
"━": "-", "▐": "|", "█": "#", "▄": "#", "▟": "#",
@@ -55,28 +67,6 @@ ASCII_FALLBACK = {
CURRENT_ROW = 2 # IDLE_WHALE_CURRENT_ROW
def parse_art() -> dict[str, tuple[str, list[str]]]:
"""Pull the four art constants out of underwater.rs."""
src = UNDERWATER.read_text(encoding="utf-8")
def one(name: str) -> str:
match = re.search(rf'const {name}: &str = "(.*?)";', src)
if not match:
sys.exit(f"could not find {name} in {UNDERWATER}")
return match.group(1)
def rows(name: str) -> list[str]:
match = re.search(rf"const {name}: \[&str; 3\] = \[(.*?)\];", src, re.S)
if not match:
sys.exit(f"could not find {name} in {UNDERWATER}")
return re.findall(r'"(.*?)"', match.group(1))
return {
"classic": (one("IDLE_WHALE_SPOUT_ROW"), rows("IDLE_WHALE_ROWS")),
"uwu": (one("UWU_IDLE_WHALE_SPOUT_ROW"), rows("UWU_IDLE_WHALE_ROWS")),
}
def ink(variant: str, row: int, ch: str, old: bool = False) -> tuple[int, int, int]:
"""The colour the TUI resolves for one glyph, without the caustic sweep."""
if ch in "·░✦△":
@@ -156,7 +146,7 @@ def render_png(after: dict[str, tuple[str, list[str]]], path: str) -> None:
cell_w, cell_h, pad = 18, 34, 30
panels = [
("BEFORE · main", BEFORE["classic"], True),
("AFTER · Signal Cut", after["classic"], False),
("ARCHIVED AFTER · Signal Cut", after["classic"], False),
]
cols = max(len(row) for _, (spout, body), _ in panels for row in [spout] + body)
width = pad * 2 + cols * cell_w
@@ -210,8 +200,8 @@ def main() -> None:
args = parser.parse_args()
colour = not args.plain and sys.stdout.isatty()
after = parse_art()
print("Codewhale empty-state whale mark — CURRENT (main) vs NEW (Signal Cut)\n")
after = AFTER
print("ARCHIVED: former Codewhale empty-state portrait — main vs Signal Cut\n")
for variant in ("classic", "uwu"):
label = "classic" if variant == "classic" else "uwu"
print(f"== {label} ==\n")