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.
This commit is contained in:
exe.dev user
2026-04-29 11:43:30 +00:00
parent e0f813603e
commit ab2d509671
8 changed files with 78 additions and 46 deletions

View File

@@ -24,7 +24,7 @@ import * as p from '@clack/prompts';
import k from 'kleur';
import { ensureAnswer } from './runner.js';
import { fitToWidth, note } from './theme.js';
import { brandBody, fitToWidth, note } from './theme.js';
export interface AssistContext {
stepName: string;
@@ -106,7 +106,7 @@ export async function offerClaudeAssist(
const parsed = parseResponse(response);
if (!parsed) {
p.log.warn("Claude responded but I couldn't parse a command out of it.");
p.log.warn(brandBody("Claude responded but I couldn't parse a command out of it."));
p.log.message(k.dim(response.trim().slice(0, 500)));
return false;
}
@@ -268,7 +268,7 @@ async function queryClaudeUnderSpinner(
const elapsed = Math.round((Date.now() - start) / 1000);
const suffix = ` (${elapsed}s)`;
if (kind === 'ok') {
p.log.success(`${fitToWidth('Claude replied.', suffix)}${k.dim(suffix)}`);
p.log.success(`${brandBody(fitToWidth('Claude replied.', suffix))}${k.dim(suffix)}`);
resolve(payload);
} else {
p.log.error(

View File

@@ -27,7 +27,7 @@ import { execSync, spawn } from 'child_process';
import * as p from '@clack/prompts';
import k from 'kleur';
import { note } from './theme.js';
import { brandBody, note } from './theme.js';
export interface HandoffContext {
/** Channel this handoff is happening in (e.g., 'teams'). */
@@ -64,7 +64,7 @@ export interface HandoffContext {
export async function offerClaudeHandoff(ctx: HandoffContext): Promise<boolean> {
if (!isClaudeUsable()) {
p.log.warn(
"Claude isn't installed yet — can't hand you off here. Finish setup first, then retry.",
brandBody("Claude isn't installed yet — can't hand you off here. Finish setup first, then retry."),
);
return false;
}
@@ -93,7 +93,7 @@ export async function offerClaudeHandoff(ctx: HandoffContext): Promise<boolean>
{ stdio: 'inherit' },
);
child.on('close', () => {
p.log.success("Back from Claude. Let's continue.");
p.log.success(brandBody("Back from Claude. Let's continue."));
resolve(true);
});
child.on('error', () => {

View File

@@ -20,7 +20,7 @@ import k from 'kleur';
import * as setupLog from '../logs.js';
import { offerClaudeAssist } from './claude-assist.js';
import { emit as phEmit } from './diagnostics.js';
import { fitToWidth } from './theme.js';
import { brandBody, fitToWidth } from './theme.js';
export type Fields = Record<string, string>;
export type Block = { type: string; fields: Fields };
@@ -390,7 +390,7 @@ export async function fail(
const skipList = [
...new Set([...existingSkip, ...setupLog.completedStepNames()]),
].join(',');
p.log.step(`Retrying from ${stepName}`);
p.log.step(brandBody(`Retrying from ${stepName}`));
const result = spawnSync('pnpm', ['--silent', 'run', 'setup:auto'], {
stdio: 'inherit',
env: { ...process.env, NANOCLAW_SKIP: skipList },

View File

@@ -39,6 +39,29 @@ export function brandChip(s: string): string {
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
@@ -70,16 +93,13 @@ export function dimWrap(text: string, gutter: number): string {
}
/**
* Wrap clack's `p.note` with the dim formatter disabled. By default
* clack renders note bodies through `styleText("dim", …)`, which the
* project's prose-readability stance (see `dimWrap` above) explicitly
* rejects. Pass-through formatter keeps body text at the terminal's
* regular weight; pre-styled segments (chips, bold, brand color) come
* through unfaded.
* 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.
*/
const passthroughFormat = (s: string): string => s;
export function note(message: string, title?: string): void {
p.note(message, title, { format: passthroughFormat });
p.note(message, title, { format: brandBody });
}
const ANSI_RE = /\x1b\[[0-9;]*m/g;

View File

@@ -23,7 +23,7 @@ import { emit as phEmit } from './diagnostics.js';
import type { StepResult, SpinnerLabels } from './runner.js';
import { dumpTranscriptOnFailure, spawnStep, writeStepEntry } from './runner.js';
import * as setupLog from '../logs.js';
import { fitToWidth } from './theme.js';
import { brandBody, fitToWidth } from './theme.js';
const WINDOW_SIZE = 3;
const SPINNER_FRAMES = ['◒', '◐', '◓', '◑'];
@@ -169,7 +169,7 @@ async function runUnderWindow(
if (result.ok) {
const isSkipped = result.terminal?.fields.STATUS === 'skipped';
const msg = isSkipped && labels.skipped ? labels.skipped : labels.done;
p.log.success(`${fitToWidth(msg, suffix)}${k.dim(suffix)}`);
p.log.success(`${brandBody(fitToWidth(msg, suffix))}${k.dim(suffix)}`);
} else {
const failMsg = labels.failed ?? labels.running.replace(/…$/, ' failed');
p.log.error(`${fitToWidth(failMsg, suffix)}${k.dim(suffix)}`);
@@ -185,7 +185,7 @@ async function handleStall(
): Promise<void> {
render.pauseRender();
p.log.warn(
`This looks stuck — no output from the ${stepName} step for the last 60 seconds.`,
brandBody(`This looks stuck — no output from the ${stepName} step for the last 60 seconds.`),
);
phEmit('step_stalled', { step: stepName });