mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 10:14:19 -05:00
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:
co-authored by
OpenClaude
parent
9d5b77db89
commit
12994f2c97
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user