Files
CodeWhale Bot 65b1e73429 feat(tui): Signal Cut whale for the empty state
The idle mark drew a flat gold slab with its fluke floating six columns
clear of the body and a four-cell rule leaving the flank at eye height —
it read as a bar with a shape drifting past it, not an animal.

Redraw it from the Whale Teams / Signal Cut roster (CWC 2026-08-15):
head-left with a blunt nose, a swept dorsal on an arched back, a tail
stock that stays body mass and rises into the attached crown fluke, and
one cyan current cut along the belly. The fluke's notch now sits directly
above the rising stock tip, so the tail is part of the whale.

The glyph vocabulary is the one whales::art already uses for the six role
portraits, so the empty-state hero and the /fleet roster are one animal at
two scales. The spout and belly cut resolve through
whales::WhaleInk::from_theme(..).current, which lifts brand cyan #48D7FF
to the secondary-chrome contrast floor per theme; previously the spout
took each theme's accent_secondary and the belly was body gold.

Pure render: no prompt, tool catalog, or session-context change, so the
KV-cache prefix is untouched.

- classic block width 24 -> 17 columns, uwu 21 -> 16
- docs/design/WHALE_TEAMS_TUI_ART.md records the translation decisions
- scripts/preview-whale-art.py renders before/after in true colour, plain
  text, and a terminal-cell PNG, reading the art out of underwater.rs
2026-08-16 16:50:24 -07:00

235 lines
8.8 KiB
Python
Executable File

#!/usr/bin/env python3
"""Preview the Codewhale empty-state whale mark: 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.
python3 scripts/preview-whale-art.py # ANSI true colour
python3 scripts/preview-whale-art.py --plain # no escape codes
python3 scripts/preview-whale-art.py --png out.png # if Pillow exists
Colours are the real palette tokens: Signal Gold #F6C453 body, Signal Current
#48D7FF spout and belly cut, ivory eye, on the whale theme's #03070D surface.
"""
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
IVORY = (233, 238, 245) # text_body
SAKURA = (247, 168, 200) # uwu accent_primary
SEAFOAM = (79, 209, 197) # whale theme accent_secondary (the old spout)
SURFACE = (3, 7, 13) # whale theme surface_bg
# The art this branch replaced, kept verbatim so the PR is reviewable.
BEFORE = {
"classic": (" ˚", [
" ▗▄▄▄▄▄▄▄▄▄▄▄▖ ▚△▞",
" ▐██·███████████▙━━━━▞",
" ▝▀▀▀▀▀▀▀▀▀▀▀▀▘",
]),
"uwu": (" ˚✦", [
" ▗▄▄▄▄▄▄▄▄▄▄▄▖ ▚△▞",
"▐█░·░█████████▙▄▄▞",
" ▝▀▀▀▀▀▀▀▀▀▀▀▘",
]),
}
# Same table the TUI narrows through (`glyphs::ascii_fallback`).
ASCII_FALLBACK = {
"━": "-", "▐": "|", "█": "#", "▄": "#", "▟": "#",
"▙": "#", "▀": "#", "▗": ".", "▖": ".", "▝": ".",
"▘": ".", "▚": "\\", "▞": "/", "░": ":", "△": "^",
"·": ".", "˚": "o", "✦": "*",
}
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 "·░✦△":
return SAKURA if variant == "uwu" else IVORY
if old:
# main: spout took the theme's secondary accent, the belly was body gold.
return SEAFOAM if row < 0 else GOLD
if row < 0:
return CYAN # spout row
if row == CURRENT_ROW:
return CYAN # belly cut
return GOLD
def paint(variant: str, spout: str, body: list[str], colour: bool, old: bool = False) -> list[str]:
out = []
for row, text in enumerate([spout] + body, start=-1):
if not colour:
out.append(text)
continue
line = f"\x1b[48;2;{SURFACE[0]};{SURFACE[1]};{SURFACE[2]}m"
for ch in text:
r, g, b = ink(variant, row, ch, old)
line += f"\x1b[38;2;{r};{g};{b}m{ch}"
out.append(line + "\x1b[0m")
return out
def ascii_rows(spout: str, body: list[str]) -> list[str]:
return ["".join(ASCII_FALLBACK.get(ch, ch) for ch in row) for row in [spout] + body]
def block(title: str, rows: list[str]) -> None:
print(f" {title}")
for row in rows:
print(f" {row}")
print()
# Block-element geometry as (x0, y0, x1, y1) fractions of one terminal cell.
# Terminals scale these to fill the cell exactly; font rasterisers do not, so
# the PNG draws them itself and keeps the font only for the small marks.
CELL_SHAPES: dict[str, tuple[tuple[float, float, float, float], ...]] = {
"█": ((0, 0, 1, 1),),
"▀": ((0, 0, 1, 0.5),),
"▄": ((0, 0.5, 1, 1),),
"▌": ((0, 0, 0.5, 1),),
"▐": ((0.5, 0, 1, 1),),
"▘": ((0, 0, 0.5, 0.5),),
"▝": ((0.5, 0, 1, 0.5),),
"▖": ((0, 0.5, 0.5, 1),),
"▗": ((0.5, 0.5, 1, 1),),
"▙": ((0, 0, 0.5, 1), (0.5, 0.5, 1, 1)),
"▟": ((0.5, 0, 1, 0.5), (0, 0.5, 1, 1)),
"▚": ((0, 0, 0.5, 0.5), (0.5, 0.5, 1, 1)),
"▞": ((0.5, 0, 1, 0.5), (0, 0.5, 0.5, 1)),
"━": ((0, 0.42, 1, 0.6),),
"·": ((0.34, 0.42, 0.66, 0.6),),
"˚": ((0.3, 0.16, 0.7, 0.42),),
"░": ((0, 0, 1, 1),),
}
# Marks the cell grammar draws as outlines or points rather than fills.
RING = {"˚": (0.3, 0.16, 0.7, 0.44)}
TRIANGLE = {"△": (0.22, 0.2, 0.78, 0.76)}
STAR = {"✦": (0.5, 0.35, 0.26)}
SHADED = {"░"}
def render_png(after: dict[str, tuple[str, list[str]]], path: str) -> None:
"""A terminal-cell mock of BEFORE vs AFTER, drawn as geometry."""
try:
from PIL import Image, ImageDraw # noqa: PLC0415
except ImportError:
print(f"! Pillow is not installed; skipping {path}", file=sys.stderr)
return
cell_w, cell_h, pad = 18, 34, 30
panels = [
("BEFORE · main", BEFORE["classic"], True),
("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
height = pad + len(panels) * (cell_h * 6 + pad)
img = Image.new("RGB", (width, height), SURFACE)
draw = ImageDraw.Draw(img, "RGBA")
y = pad
for title, (spout, body), old in panels:
draw.text((pad, y + cell_h // 3), title, fill=(120, 138, 158))
y += cell_h * 2
for row, text in enumerate([spout] + body, start=-1):
top = y + (row + 1) * cell_h
for col, ch in enumerate(text):
if ch == " ":
continue
left = pad + col * cell_w
colour = ink("classic", row, ch, old)
cell = lambda x0, y0, x1, y1: ( # noqa: E731
left + x0 * cell_w,
top + y0 * cell_h,
left + x1 * cell_w - 1,
top + y1 * cell_h - 1,
)
if ch in RING:
draw.ellipse(cell(*RING[ch]), outline=colour, width=2)
elif ch in TRIANGLE:
x0, y0, x1, y1 = cell(*TRIANGLE[ch])
draw.polygon([((x0 + x1) / 2, y0), (x1, y1), (x0, y1)], outline=colour, width=2)
elif ch in STAR:
cx, cy, r = STAR[ch]
x, y0 = left + cx * cell_w, top + cy * cell_h
draw.polygon(
[(x, y0 - r * cell_h), (x + r * cell_w, y0),
(x, y0 + r * cell_h), (x - r * cell_w, y0)],
fill=colour,
)
else:
alpha = 90 if ch in SHADED else 255
for shape in CELL_SHAPES.get(ch, ()):
draw.rectangle(cell(*shape), fill=(*colour, alpha))
y += cell_h * 4 + pad
img.save(path)
print(f"wrote {path}")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--plain", action="store_true", help="no ANSI colour")
parser.add_argument("--png", metavar="PATH", help="also write a PNG (needs Pillow)")
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")
for variant in ("classic", "uwu"):
label = "classic" if variant == "classic" else "uwu"
print(f"== {label} ==\n")
before_spout, before_body = BEFORE[variant]
after_spout, after_body = after[variant]
block("BEFORE", paint(variant, before_spout, before_body, colour, old=True))
block("AFTER", paint(variant, after_spout, after_body, colour))
block("AFTER, CODEWHALE_ASCII_SAFE=1", ascii_rows(after_spout, after_body))
widths = (
max(len(r) for r in [before_spout] + before_body),
max(len(r) for r in [after_spout] + after_body),
)
print(f" block width: {widths[0]} -> {widths[1]} columns\n")
if args.png:
render_png(after, args.png)
if __name__ == "__main__":
main()