Files
nanoclaw/setup/lib/theme.ts
exe.dev user ab2d509671 feat(setup): paint card and log bodies in brand cyan
Adds a `brandBody` helper in setup/lib/theme.ts that wraps prose in
brand cyan (#2BB7CE), with the same TTY/NO_COLOR/truecolor gating used
by `brand`/`brandBold`/`brandChip`. The helper splits multi-line input
and colors each line independently so the SGR sequence doesn't bleed
across clack's gutter prefix.

Routing:
  - `note()` (the un-dim card wrapper from #2095) now passes
    `brandBody` as its `format` callback, so card bodies render
    cyan line-by-line.
  - Every prose `p.log.{message,info,success,step,warn}` call in the
    setup flow wraps its body argument in `brandBody`. Calls whose
    body is explicitly `k.dim(...)` (failure transcript tails, log
    paths, claude-assist response previews) are left alone — those
    are the "preview/debug" cases the dim-policy comment in
    theme.ts already carves out.
  - Spinner-finish lines in windowed-runner / claude-assist color
    only the message portion; the `(5s)` elapsed suffix stays dim.

Brand cyan accents (chips, wordmark, inline emphasis) are unchanged.
This PR only adds the body color.

A follow-up will add OSC 11 dark/light detection so light-mode
terminals get a brand blue (#2b6fdc) variant — opt-in upgrade with
no regression for the dark-mode default.
2026-04-29 11:43:30 +00:00

156 lines
5.2 KiB
TypeScript

/**
* NanoClaw brand palette for the terminal.
*
* Colors pulled from assets/nanoclaw-logo.png:
* brand cyan ≈ #2BB7CE — the "Claw" wordmark + mascot body
* brand navy ≈ #171B3B — the dark logo background + outlines
*
* Rendering gates:
* - No TTY (piped / redirected) → plain text, no ANSI
* - NO_COLOR set → plain text, no ANSI
* - COLORTERM truecolor/24bit → 24-bit ANSI (exact brand cyan)
* - Otherwise → kleur's 16-color cyan (closest fallback)
*/
import * as p from '@clack/prompts';
import k from 'kleur';
const USE_ANSI = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
const TRUECOLOR =
USE_ANSI &&
(process.env.COLORTERM === 'truecolor' || process.env.COLORTERM === '24bit');
export function brand(s: string): string {
if (!USE_ANSI) return s;
if (TRUECOLOR) return `\x1b[38;2;43;183;206m${s}\x1b[0m`;
return k.cyan(s);
}
export function brandBold(s: string): string {
if (!USE_ANSI) return s;
if (TRUECOLOR) return `\x1b[1;38;2;43;183;206m${s}\x1b[0m`;
return k.bold(k.cyan(s));
}
export function brandChip(s: string): string {
if (!USE_ANSI) return s;
if (TRUECOLOR) {
return `\x1b[48;2;43;183;206m\x1b[38;2;23;27;59m\x1b[1m${s}\x1b[0m`;
}
return k.bgCyan(k.black(k.bold(s)));
}
/**
* Brand body color for setup-flow prose. Used for card bodies (via the
* `note()` formatter) and `p.log.*` body arguments — anywhere the
* previous "dim" treatment was making prose hard to read or washing
* out embedded brand emphasis.
*
* Multi-line input is colored line-by-line so embedded line breaks
* don't bleed the SGR sequence across clack's gutter prefix.
*/
export function brandBody(s: string): string {
if (!USE_ANSI) return s;
if (TRUECOLOR) {
return s
.split('\n')
.map((line) => (line.length > 0 ? `\x1b[38;2;43;183;206m${line}\x1b[39m` : line))
.join('\n');
}
return s
.split('\n')
.map((line) => (line.length > 0 ? k.cyan(line) : line))
.join('\n');
}
/**
* Wrap text so it fits inside clack's gutter without the terminal's soft
* wrap breaking the `│ …` bar on long lines. Works on a single string with
* embedded `\n`s; each logical line is wrapped independently.
*
* The `gutter` argument is the total horizontal overhead clack adds for
* the component the text lives in (e.g. 4 for `p.log.*`'s `│ ` prefix;
* 6-ish for `p.note`'s box). Caller picks it; we just subtract from
* `process.stdout.columns` and hard-wrap at word boundaries.
*/
export function wrapForGutter(text: string, gutter: number): string {
const cols = process.stdout.columns ?? 80;
const width = Math.max(30, cols - gutter);
return text
.split('\n')
.map((line) => wrapLine(line, width))
.join('\n');
}
/**
* Wrap multi-line explanatory prose to the clack gutter. Previously
* dimmed its output (hence the name) — that made body copy hard to read
* against dark terminals. Dim is now reserved for preview/debug blocks
* (failure transcript tails, claude-assist streams); prose renders at
* the terminal's regular weight.
*/
export function dimWrap(text: string, gutter: number): string {
return wrapForGutter(text, gutter);
}
/**
* Wrap clack's `p.note` so card bodies render in the brand body color
* (#2b6fdc) instead of clack's default dim. Clack runs the formatter
* on each line individually, so `brandBody` colors each line cleanly
* without bleeding across the gutter prefix.
*/
export function note(message: string, title?: string): void {
p.note(message, title, { format: brandBody });
}
const ANSI_RE = /\x1b\[[0-9;]*m/g;
function visibleLength(s: string): number {
return s.replace(ANSI_RE, '').length;
}
/**
* Truncate a label so the final line — base + reserved suffix — fits in
* the terminal width. Use on spinner labels that get an elapsed counter
* appended: if the total exceeds terminal width, clack's cursor-up
* redraw math breaks and each tick stacks a copy of the line instead
* of replacing it.
*
* `suffix` is the reserved space for what we'll append after `fit()`
* returns (e.g. ` (999s)` or a tool-use breadcrumb). We don't include
* it in the output — caller appends it.
*/
export function fitToWidth(base: string, suffix: string): string {
const cols = process.stdout.columns ?? 80;
// Overhead we reserve before sizing the label:
// spinner icon (1) + 2 padding spaces = 3
// clack's animated ellipsis after the label = up to 3 (". " -> "...")
// 1-char safety margin so wide-char glyphs don't tip over the edge
// Total reserved budget = 7 cols plus the caller's suffix.
const budget = Math.max(20, cols - 7 - visibleLength(suffix));
return base.length > budget ? base.slice(0, budget - 1) + '…' : base;
}
function wrapLine(line: string, width: number): string {
if (visibleLength(line) <= width) return line;
const words = line.split(' ');
const rows: string[] = [];
let cur = '';
let curLen = 0;
for (const word of words) {
const wLen = visibleLength(word);
if (curLen === 0) {
cur = word;
curLen = wLen;
} else if (curLen + 1 + wLen <= width) {
cur += ' ' + word;
curLen += 1 + wLen;
} else {
rows.push(cur);
cur = word;
curLen = wLen;
}
}
if (cur) rows.push(cur);
return rows.join('\n');
}