feat(ui): single-row centered startup logo with ANSI Shadow wordmark (#2053)

* feat(ui): single-row centered startup logo with ANSI Shadow wordmark

Render OPEN and CLAUDE side by side as one centered 6-line block on the
pre-Ink startup screen, redrawn in ANSI Shadow letterforms (consistent
shadow corners, D-shaped D, clean N). Terminals narrower than the 94-col
row fall back to two stacked blocks, each centered as a unit so rows stay
aligned. The tagline, provider box, and version line are centered to
match.

The Ink welcome panel wordmark (constants/brand.ts) becomes a matching
single row: letter-spaced caps flanked by shade-gradient accents,
keeping the shimmer/brand two-tone split.

Adds layout unit tests (one-row vs stacked switchover, block centering,
box centering) and brand wordmark invariant tests; updates the D-shape
glyph regression for the new font.

Co-Authored-By: OpenClaude <openclaude@gitlawb.com>

* test(ui): address CodeRabbit review — wordmark render test, row centering asserts

Extract the LogoV2 wordmark row into a WordmarkRow component and add a
focused render test asserting segment order and the shimmer/brand color
split (left accent + OPEN in brandShimmer, CLAUDE + right accent in
brand), via renderToAnsiString with chalk pinned to truecolor.

Extend the startup-screen layout test to assert the tagline and version
rows are centered, alongside the existing provider-box check.

Co-Authored-By: OpenClaude <openclaude@gitlawb.com>

---------

Co-authored-by: OpenClaude <openclaude@gitlawb.com>
This commit is contained in:
Kevin Codex
2026-07-28 13:51:08 +08:00
committed by GitHub
co-authored by OpenClaude
parent 9d5b77db89
commit 12994f2c97
7 changed files with 300 additions and 47 deletions
+3 -2
View File
@@ -7,8 +7,9 @@ import { stringWidth } from '../../ink/stringWidth.js';
import { getLayoutMode, calculateLayoutDimensions, calculateOptimalLeftWidth, formatWelcomeMessage, truncatePath, getRecentActivitySync, getRecentReleaseNotesSync, getLogoDisplayData } from '../../utils/logoV2Utils.js';
import { truncate } from '../../utils/format.js';
import { getDisplayPath } from '../../utils/file.js';
import { BRAND_TAGLINE, WORDMARK_CLAUDE, WORDMARK_OPEN } from '../../constants/brand.js';
import { BRAND_TAGLINE } from '../../constants/brand.js';
import { Clawd } from './Clawd.js';
import { WordmarkRow } from './WordmarkRow.js';
import { FeedColumn } from './FeedColumn.js';
import { createRecentActivityFeed, createWhatsNewFeed, createProjectOnboardingFeed, createGuestPassesFeed } from './feedConfigs.js';
import { getGlobalConfig, saveGlobalConfig } from 'src/utils/config.js';
@@ -374,7 +375,7 @@ export function LogoV2() {
const t17 = 1;
let t18;
if ($[46] !== welcomeMessage_0) {
t18 = <Box marginTop={1} flexDirection="column" alignItems="center"><Text><Text color="brandShimmer">{WORDMARK_OPEN[0]}</Text><Text color="brand"> {WORDMARK_CLAUDE[0]}</Text></Text><Text><Text color="brandShimmer">{WORDMARK_OPEN[1]}</Text><Text color="brand"> {WORDMARK_CLAUDE[1]}</Text></Text><Text dimColor={true}>{BRAND_TAGLINE}</Text><Text color="inactive"></Text><Text bold={true}>{welcomeMessage_0}</Text></Box>;
t18 = <Box marginTop={1} flexDirection="column" alignItems="center"><WordmarkRow /><Text dimColor={true}>{BRAND_TAGLINE}</Text><Text color="inactive"></Text><Text bold={true}>{welcomeMessage_0}</Text></Box>;
$[46] = welcomeMessage_0;
$[47] = t18;
} else {
@@ -0,0 +1,55 @@
import { afterAll, describe, expect, test } from 'bun:test'
import { stripVTControlCharacters as stripAnsi } from 'node:util'
import chalk from 'chalk'
import * as React from 'react'
import {
WORDMARK_ACCENT_LEFT,
WORDMARK_ACCENT_RIGHT,
WORDMARK_CLAUDE,
WORDMARK_OPEN,
} from '../../constants/brand.js'
import { renderToAnsiString, renderToString } from '../../utils/staticRender.js'
import { WordmarkRow } from './WordmarkRow.js'
// The color-split check reads SGR codes; pin chalk to truecolor so they are
// emitted even though test stdout is not a TTY (precedent: CompanionSprite).
const originalChalkLevel = chalk.level
chalk.level = 3
afterAll(() => {
chalk.level = originalChalkLevel
})
const FG_COLOR_ESCAPE = /\x1b\[38;2;\d+;\d+;\d+m/g
/** The last truecolor foreground escape emitted before `segment` appears. */
function colorBefore(ansiOutput: string, segment: string): string {
const idx = ansiOutput.indexOf(segment)
expect(idx).toBeGreaterThan(-1)
const escapes = ansiOutput.slice(0, idx).match(FG_COLOR_ESCAPE)
expect(escapes).not.toBeNull()
return escapes![escapes!.length - 1]!
}
describe('WordmarkRow', () => {
test('renders all segments in order on a single row', async () => {
const out = await renderToString(<WordmarkRow />, 80)
const plain = stripAnsi(out).trimEnd()
expect(plain).toBe(
`${WORDMARK_ACCENT_LEFT} ${WORDMARK_OPEN} ${WORDMARK_CLAUDE} ${WORDMARK_ACCENT_RIGHT}`,
)
expect(plain).not.toContain('\n')
})
test('left accent + OPEN use the shimmer accent, CLAUDE + right accent the brand accent', async () => {
const out = (await renderToAnsiString(<WordmarkRow />, 80)).trimEnd()
const leftAccentColor = colorBefore(out, WORDMARK_ACCENT_LEFT)
const openColor = colorBefore(out, WORDMARK_OPEN)
const claudeColor = colorBefore(out, WORDMARK_CLAUDE)
const rightAccentColor = colorBefore(out, WORDMARK_ACCENT_RIGHT)
expect(openColor).toBe(leftAccentColor)
expect(rightAccentColor).toBe(claudeColor)
expect(claudeColor).not.toBe(leftAccentColor)
})
})
+29
View File
@@ -0,0 +1,29 @@
import * as React from 'react'
import { Text } from '../../ink.js'
import {
WORDMARK_ACCENT_LEFT,
WORDMARK_ACCENT_RIGHT,
WORDMARK_CLAUDE,
WORDMARK_OPEN,
} from '../../constants/brand.js'
/**
* The single-row brand wordmark: shade-gradient accents flanking letter-spaced
* caps, with the OPEN half (and left accent) in the shimmer accent and the
* CLAUDE half (and right accent) in the brand accent.
*/
export function WordmarkRow(): React.ReactElement {
return (
<Text>
<Text color="brandShimmer">{WORDMARK_ACCENT_LEFT} </Text>
<Text bold={true} color="brandShimmer">
{WORDMARK_OPEN}
</Text>
<Text bold={true} color="brand">
{' '}
{WORDMARK_CLAUDE}
</Text>
<Text color="brand"> {WORDMARK_ACCENT_RIGHT}</Text>
</Text>
)
}
+102 -4
View File
@@ -63,6 +63,7 @@ const originalEnv: Record<string, string | undefined> = {}
const originalMacro = (globalThis as Record<string, unknown>).MACRO
const originalIsTTY = process.stdout.isTTY
const originalWrite = process.stdout.write
const originalColumns = process.stdout.columns
// `model` is a legacy loose key not declared on GlobalConfig.
const originalModel = (getGlobalConfig() as GlobalConfig & Record<string, unknown>).model
@@ -89,6 +90,10 @@ afterEach(() => {
configurable: true,
value: originalIsTTY,
})
Object.defineProperty(process.stdout, 'columns', {
configurable: true,
value: originalColumns,
})
process.stdout.write = originalWrite
for (const key of ENV_KEYS) {
if (originalEnv[key] === undefined) {
@@ -123,10 +128,103 @@ describe('printStartupScreen logo', () => {
printStartupScreen()
const plainOutput = stripAnsi(output)
expect(plainOutput).toContain('█████████████╗')
expect(plainOutput).toContain('██╔═══██ ██╔═════╝')
expect(plainOutput).toContain('██████╔╝ ███████╗')
expect(plainOutput).not.toContain('████████║ ████████╗')
expect(plainOutput).toContain('██████╗ ███████╗')
expect(plainOutput).toContain('████ █████╗')
expect(plainOutput).toContain('██████╔╝ ███████╗')
expect(plainOutput).not.toContain('████║ █████╗')
})
})
// --- Logo layout: one centered row on wide terminals, stacked fallback ---
function renderStartupScreen(columns: number): string {
;(globalThis as Record<string, unknown>).MACRO = { VERSION: 'test-version' }
Object.defineProperty(process.stdout, 'isTTY', {
configurable: true,
value: true,
})
Object.defineProperty(process.stdout, 'columns', {
configurable: true,
value: columns,
})
let output = ''
process.stdout.write = ((chunk: string | Uint8Array) => {
output += chunk.toString()
return true
}) as typeof process.stdout.write
printStartupScreen()
process.stdout.write = originalWrite
return stripAnsi(output)
}
function logoLines(plainOutput: string): string[] {
// Letter rows contain █; the bottom shadow row only ╚═╝ glyphs. Neither
// pattern occurs in the tagline, info box, or version line.
return plainOutput.split('\n').filter(line => line.includes('█') || line.includes('╚═╝'))
}
// Visible widths of the current art: OPEN 38 + gap 2 + CLAUDE 54.
const ONE_ROW_WIDTH = 94
const BOX_WIDTH = 62
describe('printStartupScreen layout', () => {
test('wide terminal renders OPEN and CLAUDE side by side as one 6-line block', () => {
const out = renderStartupScreen(120)
const logo = logoLines(out)
expect(logo).toHaveLength(6)
// End of N (OPEN) and start of C (CLAUDE) share a line
expect(logo[0]).toContain('███╗ ██╗ ██████╗ ██╗')
})
test('one-row logo is centered and rows are aligned as a block', () => {
const out = renderStartupScreen(120)
const logo = logoLines(out)
const pad = ' '.repeat(Math.floor((120 - ONE_ROW_WIDTH) / 2))
expect(logo[1]!.startsWith(`${pad}██╔═══██╗`)).toBe(true)
expect(logo[4]!.startsWith(`${pad}╚██████╔╝`)).toBe(true)
for (const line of out.split('\n')) {
expect(line.length).toBeLessThanOrEqual(120)
}
})
test('narrow terminal falls back to two stacked centered blocks', () => {
const out = renderStartupScreen(80)
expect(logoLines(out)).toHaveLength(12)
expect(out).not.toContain('███╗ ██╗ ██████╗')
for (const line of out.split('\n')) {
expect(line.length).toBeLessThanOrEqual(80)
}
})
test('layout switches exactly at the one-row width', () => {
expect(logoLines(renderStartupScreen(ONE_ROW_WIDTH))).toHaveLength(6)
expect(logoLines(renderStartupScreen(ONE_ROW_WIDTH - 1))).toHaveLength(12)
})
test('provider box, tagline, and version line are centered', () => {
const out = renderStartupScreen(120)
const lines = out.split('\n')
// The logo letterforms also contain ╔ — the box's top border is the line
// that starts with it.
const boxTop = lines.find(line => line.trimStart().startsWith('╔'))
expect(boxTop).toBeDefined()
expect(boxTop!.indexOf('╔')).toBe(Math.floor((120 - BOX_WIDTH) / 2))
const tagline = lines.find(line => line.includes('✦'))
expect(tagline).toBeDefined()
expect(tagline!.indexOf('✦')).toBe(
Math.floor((120 - tagline!.trim().length) / 2),
)
const version = lines.find(line => line.includes('openclaude v'))
expect(version).toBeDefined()
expect(version!.indexOf('openclaude')).toBe(
Math.floor((120 - version!.trim().length) / 2),
)
})
})
+49 -26
View File
@@ -56,22 +56,23 @@ export function paintLine(text: string, stops: readonly RGB[], lineT: number): s
// ─── Filled Block Text Logo ───────────────────────────────────────────────────
// ANSI Shadow figlet letterforms, one space between letters.
const LOGO_OPEN = [
` \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2557`,
` \u2588\u2588\u2554\u2550\u2550\u2550\u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u2550\u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u2550\u2550\u2550\u255d \u2588\u2588\u2588\u2557 \u2588\u2588\u2551`,
` \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2551`,
` \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u2550\u2550\u2550\u255d \u2588\u2588\u2554\u2550\u2550\u2550\u255d \u2588\u2588\u2554\u2588\u2588\u2588\u2588\u2551`,
` \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551 \u255a\u2588\u2588\u2588\u2551`,
` \u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d \u255a\u2550\u255d \u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d \u255a\u2550\u255d \u255a\u2550\u2550\u255d`,
' \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2557 \u2588\u2588\u2557',
'\u2588\u2588\u2554\u2550\u2550\u2550\u2588\u2588\u2557 \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557 \u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255d \u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551',
'\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255d \u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2554\u2588\u2588\u2557 \u2588\u2588\u2551',
'\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u2550\u255d \u2588\u2588\u2554\u2550\u2550\u255d \u2588\u2588\u2551\u255a\u2588\u2588\u2557\u2588\u2588\u2551',
'\u255a\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255d \u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551 \u255a\u2588\u2588\u2588\u2588\u2551',
' \u255a\u2550\u2550\u2550\u2550\u2550\u255d \u255a\u2550\u255d \u255a\u2550\u2550\u2550\u2550\u2550\u2550\u255d \u255a\u2550\u255d \u255a\u2550\u2550\u2550\u255d',
]
const LOGO_CLAUDE = [
` \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557`,
` \u2588\u2588\u2554\u2550\u2550\u2550\u2550\u2550\u255d \u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u2550\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u2550\u2588\u2588\u2557 \u2588\u2588\u2554\u2550\u2550\u2550\u2550\u2550\u255d`,
` \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 `,
` \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u2550\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u2550\u255d `,
` \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2551 \u2588\u2588\u2551 \u255a\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255d \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255d \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557`,
` \u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d \u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d\u255a\u2550\u255d \u255a\u2550\u255d \u255a\u2550\u2550\u2550\u2550\u2550\u255d \u255a\u2550\u2550\u2550\u2550\u2550\u2550\u255d \u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d`,
' \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557',
'\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255d \u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557 \u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255d',
'\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2557 ',
'\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u255d ',
'\u255a\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551 \u2588\u2588\u2551 \u255a\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255d \u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255d \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557',
' \u255a\u2550\u2550\u2550\u2550\u2550\u255d \u255a\u2550\u2550\u2550\u2550\u2550\u2550\u255d \u255a\u2550\u255d \u255a\u2550\u255d \u255a\u2550\u2550\u2550\u2550\u2550\u255d \u255a\u2550\u2550\u2550\u2550\u2550\u255d \u255a\u2550\u2550\u2550\u2550\u2550\u2550\u255d',
]
// ─── Provider detection ───────────────────────────────────────────────────────
@@ -191,30 +192,51 @@ export function printStartupScreen(modelOverride?: string): void {
const p = detectProvider(modelOverride)
const W = 62
const columns = process.stdout.columns || 80
const centerPad = (visibleLen: number): string =>
' '.repeat(Math.max(0, Math.floor((columns - visibleLen) / 2)))
const out: string[] = []
out.push('')
// Gradient logo
const allLogo = [...LOGO_OPEN, '', ...LOGO_CLAUDE]
const total = allLogo.length
// Gradient logo \u2014 OPEN and CLAUDE side by side on one centered row; falls
// back to the stacked two-block layout when the terminal is too narrow.
// Each block is centered as a unit (one pad per block) so rows stay aligned.
const open = LOGO_OPEN
const claude = LOGO_CLAUDE
const openWidth = Math.max(...open.map(row => row.length))
const claudeWidth = Math.max(...claude.map(row => row.length))
const oneRowWidth = openWidth + 2 + claudeWidth
const fitsOneRow = columns >= oneRowWidth
const logoLines = fitsOneRow
? open.map((row, i) => ({
pad: centerPad(oneRowWidth),
text: `${row.padEnd(openWidth)} ${claude[i]}`.trimEnd(),
}))
: [
...open.map(row => ({ pad: centerPad(openWidth), text: row })),
{ pad: '', text: '' },
...claude.map(row => ({ pad: centerPad(claudeWidth), text: row.trimEnd() })),
]
const total = logoLines.length
for (let i = 0; i < total; i++) {
const t = total > 1 ? i / (total - 1) : 0
if (allLogo[i] === '') {
if (logoLines[i].text === '') {
out.push('')
} else {
out.push(paintLine(allLogo[i], GRAD, t))
out.push(logoLines[i].pad + paintLine(logoLines[i].text, GRAD, t))
}
}
out.push('')
// Tagline
out.push(` ${ansiRgb(...ACCENT)}\u2726${RESET} ${ansiRgb(...CREAM)}${BRAND_TAGLINE}${RESET} ${ansiRgb(...ACCENT)}\u2726${RESET}`)
out.push(`${centerPad(BRAND_TAGLINE.length + 4)}${ansiRgb(...ACCENT)}\u2726${RESET} ${ansiRgb(...CREAM)}${BRAND_TAGLINE}${RESET} ${ansiRgb(...ACCENT)}\u2726${RESET}`)
out.push('')
// Provider info box
out.push(`${ansiRgb(...BORDER)}\u2554${'\u2550'.repeat(W - 2)}\u2557${RESET}`)
const boxPad = centerPad(W)
out.push(`${boxPad}${ansiRgb(...BORDER)}\u2554${'\u2550'.repeat(W - 2)}\u2557${RESET}`)
const lbl = (k: string, v: string, c: RGB = CREAM): [string, number] => {
const padK = k.padEnd(9)
@@ -223,23 +245,24 @@ export function printStartupScreen(modelOverride?: string): void {
const provC: RGB = p.isLocal ? [130, 175, 130] : ACCENT
let [r, l] = lbl('Provider', p.name, provC)
out.push(boxRow(r, W, l, BORDER))
out.push(boxPad + boxRow(r, W, l, BORDER))
;[r, l] = lbl('Model', p.model)
out.push(boxRow(r, W, l, BORDER))
out.push(boxPad + boxRow(r, W, l, BORDER))
const ep = p.baseUrl.length > 38 ? p.baseUrl.slice(0, 35) + '...' : p.baseUrl
;[r, l] = lbl('Endpoint', ep)
out.push(boxRow(r, W, l, BORDER))
out.push(boxPad + boxRow(r, W, l, BORDER))
out.push(`${ansiRgb(...BORDER)}\u2560${'\u2550'.repeat(W - 2)}\u2563${RESET}`)
out.push(`${boxPad}${ansiRgb(...BORDER)}\u2560${'\u2550'.repeat(W - 2)}\u2563${RESET}`)
const sC: RGB = p.isLocal ? [130, 175, 130] : ACCENT
const sL = p.isLocal ? 'local' : 'cloud'
const sRow = ` ${ansiRgb(...sC)}\u25cf${RESET} ${DIM}${ansiRgb(...DIMCOL)}${sL}${RESET} ${DIM}${ansiRgb(...DIMCOL)}Ready \u2014 type ${RESET}${ansiRgb(...ACCENT)}/help${RESET}${DIM}${ansiRgb(...DIMCOL)} to begin${RESET}`
const sLen = ` \u25cf ${sL} Ready \u2014 type /help to begin`.length
out.push(boxRow(sRow, W, sLen, BORDER))
out.push(boxPad + boxRow(sRow, W, sLen, BORDER))
out.push(`${ansiRgb(...BORDER)}\u255a${'\u2550'.repeat(W - 2)}\u255d${RESET}`)
out.push(` ${DIM}${ansiRgb(...DIMCOL)}openclaude ${RESET}${ansiRgb(...ACCENT)}v${MACRO.DISPLAY_VERSION ?? MACRO.VERSION}${RESET}`)
out.push(`${boxPad}${ansiRgb(...BORDER)}\u255a${'\u2550'.repeat(W - 2)}\u255d${RESET}`)
const versionText = `openclaude v${MACRO.DISPLAY_VERSION ?? MACRO.VERSION}`
out.push(`${centerPad(versionText.length)}${DIM}${ansiRgb(...DIMCOL)}openclaude ${RESET}${ansiRgb(...ACCENT)}v${MACRO.DISPLAY_VERSION ?? MACRO.VERSION}${RESET}`)
out.push('')
process.stdout.write(out.join('\n') + '\n')
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, test } from 'bun:test'
import {
BRAND_ACCENT_RGB,
WORDMARK_ACCENT_LEFT,
WORDMARK_ACCENT_RIGHT,
WORDMARK_CLAUDE,
WORDMARK_OPEN,
WORDMARK_WIDTH,
} from './brand.js'
describe('wordmark', () => {
test('every segment is a single row', () => {
for (const segment of [
WORDMARK_ACCENT_LEFT,
WORDMARK_OPEN,
WORDMARK_CLAUDE,
WORDMARK_ACCENT_RIGHT,
]) {
expect(segment).not.toContain('\n')
expect(segment.length).toBeGreaterThan(0)
}
})
test('WORDMARK_WIDTH matches the rendered row', () => {
const row = `${WORDMARK_ACCENT_LEFT} ${WORDMARK_OPEN} ${WORDMARK_CLAUDE} ${WORDMARK_ACCENT_RIGHT}`
expect(row.length).toBe(WORDMARK_WIDTH)
})
test('gradient accents are mirror images of each other', () => {
expect([...WORDMARK_ACCENT_RIGHT].reverse().join('')).toBe(WORDMARK_ACCENT_LEFT)
})
test('row fits the narrowest full-logo panel', () => {
// The full welcome panel renders at LEFT_PANEL_MAX_WIDTH-ish widths; keep
// the wordmark comfortably under 40 columns so it never wraps there.
expect(WORDMARK_WIDTH).toBeLessThanOrEqual(40)
})
})
describe('brand accent', () => {
test('stays in rgb() form required by parseRGB consumers', () => {
expect(BRAND_ACCENT_RGB).toMatch(/^rgb\(\d{1,3},\d{1,3},\d{1,3}\)$/)
})
})
+18 -15
View File
@@ -15,23 +15,26 @@ export const BRAND_TAGLINE = 'Open terminal for any LLM'
export const BRAND_ACCENT_RGB = 'rgb(255,122,26)'
/**
* Two-row Unicode half-block wordmark, split so the two halves can be
* rendered in different accent shades. Block characters (█ ▀ ▄) render
* correctly in Apple Terminal. Rendered side by side with a 1-col gap:
* Single-row wordmark, split so the two halves can be rendered in different
* accent shades. Letter-spaced caps flanked by shade-gradient accents
* (░ ▒ ▓ █ render correctly in Apple Terminal). Rendered as one centered row:
*
* █▀█ █▀█ █▀▀ █▄ █ █▀▀ █ ▄▀█ █ █ █▀▄ █▀▀
* █▄█ █▀▀ ██▄ █ ▀█ █▄▄ █▄▄ █▀█ █▄█ █▄▀ ██▄
* ░▒▓█ O P E N C L A U D E █▓▒░
*/
export const WORDMARK_OPEN = [
'█▀█ █▀█ █▀▀ █▄ █',
'█▄█ █▀▀ ██▄ █ ▀█',
] as const
export const WORDMARK_ACCENT_LEFT = '░▒▓█'
export const WORDMARK_CLAUDE = [
'█▀▀ █ ▄▀█ █ █ █▀▄ █▀▀',
'█▄▄ █▄▄ █▀█ █▄█ █▄▀ ██▄',
] as const
export const WORDMARK_OPEN = 'O P E N'
/** Rendered width of the full wordmark: open half + 1-col gap + claude half. */
export const WORDMARK_CLAUDE = 'C L A U D E'
export const WORDMARK_ACCENT_RIGHT = '█▓▒░'
/** Rendered width of the full wordmark row, including accents and gaps. */
export const WORDMARK_WIDTH =
WORDMARK_OPEN[0].length + 1 + WORDMARK_CLAUDE[0].length
WORDMARK_ACCENT_LEFT.length +
1 +
WORDMARK_OPEN.length +
1 +
WORDMARK_CLAUDE.length +
1 +
WORDMARK_ACCENT_RIGHT.length