Skip to content

Commit 0b24f09

Browse files
ui: paint the whole frame from the brand palette, so a light terminal theme cannot bleed through (#93)
The palette was only half hardcoded. We paint our own near-black canvas, but most text reached ink with NO color prop (and often a bare dimColor), which leaves it on the TERMINAL default foreground: under a light theme that is near-black, on our near-black canvas. The transcript body is the worst of it, since styleFor leaves textColor off for the assistant, tool, thinking, system and notice kinds, which is most of a session. Three fixes: every Text now carries a brand hex, and secondary copy takes theme.muted instead of dimColor (\x1b[2m is dropped by a fair number of terminals once a 24-bit fg is set, the same reason the live pulse already swapped colors rather than dimming); the surfaces are quantized before they reach ink, because chalk sends any hex whose channels differ to the 256-color cube, whose darkest step is rgb(95,95,95), so all three warm brand surfaces collapsed onto one mid gray on a 256-color terminal; and markdown tables lose chalk.red heads and cli-table3 border: [gray], the last two colors that resolved through the user palette. Co-authored-by: ellipsis-dev[bot] <ellipsis-dev[bot]@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent cb0507a commit 0b24f09

8 files changed

Lines changed: 310 additions & 45 deletions

File tree

src/lib/markdown.ts

1.08 KB
Binary file not shown.

src/lib/theme.ts

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import chalk from 'chalk'
2+
13
// The Ellipsis brand palette, dark mode — the CLI's one source of color.
24
//
35
// These hexes are COPIES of brand/tokens.json in the ellipsis monorepo (the
@@ -11,20 +13,76 @@
1113
// accent in dark mode is BONE, not brand blue. Brand ink #175173 scores
1214
// 1.79:1 on the panel — unreadable as terminal text. So "you are here" is
1315
// carried by brightness (bone against stone), not by hue.
16+
//
17+
// Because the CLI paints its own canvas, the palette only holds if it is used
18+
// for EVERY cell of the frame. Two rules keep it whole on a terminal whose own
19+
// theme is light:
20+
//
21+
// 1. Every glyph takes a color from this file. Ink leaves a `<Text>` with no
22+
// `color` prop on the terminal's DEFAULT foreground, which under a light
23+
// theme is near-black — the same near-black we just painted the canvas
24+
// with, so the text vanishes. `dimColor` on its own is that bug plus an
25+
// \x1b[2m: secondary copy takes `muted`, never a bare `dimColor`. (dim is
26+
// fine ON TOP of an explicit color, where it only shades a known hue.)
27+
// 2. Surfaces reach ink already quantized for the terminal's color depth —
28+
// see `surfaceFor`, which is why the three surface entries below are
29+
// computed rather than literal.
30+
31+
// The surfaces as authored. Call sites never read these: they take the
32+
// `theme.*` entries, which are these run through `surfaceFor`.
33+
const BRAND_SURFACES = {
34+
canvas: '#1c1b1a',
35+
panel: '#262523',
36+
panelActive: '#343330',
37+
} as const
38+
39+
// A surface hex ink can paint at `level` without losing the step between one
40+
// surface and the next.
41+
//
42+
// chalk resolves a hex onto the 256-color palette two different ways: to the
43+
// 24-rung GREYSCALE RAMP (indexes 232-255, ~10 units apart) when r, g and b
44+
// are equal, and otherwise to the 6x6x6 COLOR CUBE, whose darkest step above
45+
// black is rgb(95,95,95). The brand surfaces are WARM greys — their channels
46+
// differ by a point or two — so on a terminal that does 256 colors but not
47+
// truecolor (Terminal.app, tmux without RGB, mosh, plain conhost) all three
48+
// land on cube index 59 simultaneously: the near-black canvas paints as a mid
49+
// grey slab, and the panel and active steps disappear along with every "you
50+
// are here" highlight that was carried by them.
51+
//
52+
// Averaging the channels is invisible at this brightness (a warm near-black
53+
// and a neutral near-black are the same wall of dark) and puts each surface
54+
// back on its own rung: 234, 235, 236. Truecolor terminals get the authored
55+
// warmth untouched; a 16-color terminal renders both spellings as its palette
56+
// black, so the substitution costs nothing there either.
57+
export function surfaceFor(hex: string, level: number): string {
58+
if (level >= 3) return hex
59+
const value = hex.replace('#', '')
60+
const channels = [0, 2, 4].map((i) => Number.parseInt(value.slice(i, i + 2), 16))
61+
if (channels.some(Number.isNaN)) return hex
62+
const mean = Math.round((channels[0] + channels[1] + channels[2]) / 3)
63+
return `#${mean.toString(16).padStart(2, '0').repeat(3)}`
64+
}
65+
66+
// `chalk.level` is read once, at import: ink colorizes through this very chalk
67+
// instance (it is a hoisted single copy), so what it can render is what we
68+
// quantize for.
69+
const COLOR_LEVEL: number = chalk.level
1470

1571
export const theme = {
1672
// The app canvas and the lifted panel an input sits on. ~1.1:1 apart: barely
1773
// a lift, which is the point — a panel should separate, not stripe.
18-
canvas: '#1c1b1a',
19-
panel: '#262523',
74+
canvas: surfaceFor(BRAND_SURFACES.canvas, COLOR_LEVEL),
75+
panel: surfaceFor(BRAND_SURFACES.panel, COLOR_LEVEL),
2076
// One step lighter than `panel`: the brand border hairline, doing duty as
2177
// the "you are here" surface (highlighted message, focused composer,
2278
// selected nav row). Selection is a brightness step between surfaces —
2379
// never the full inverse flash, which reads bone-white and far too loud.
24-
panelActive: '#343330',
80+
panelActive: surfaceFor(BRAND_SURFACES.panelActive, COLOR_LEVEL),
2581

2682
// Type. `foreground` is body copy and doubles as the accent (see above);
27-
// `muted` is every secondary string (meta, hints, timestamps).
83+
// `muted` is every secondary string (meta, hints, timestamps) — and, since
84+
// rule 1 above rules out a bare `dimColor`, it is also how a quiet line
85+
// reads quiet. 7.4:1 on the canvas, so quiet still means legible.
2886
foreground: '#f0efe9',
2987
muted: '#a8a59c',
3088

@@ -40,7 +98,7 @@ export const theme = {
4098
// use for code blocks, so a snippet reads the same in the CLI as in the docs.
4199
syntaxLiteral: '#d9bd8d',
42100
syntaxString: '#c8c6bc',
43-
} as const
101+
}
44102

45103
// The elevated surface an input area sits on. Named separately from
46104
// `theme.panel` because call sites mean "this is an input", not "this is

src/ui/ConnectApp.tsx

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import {
5555
rowViewport,
5656
snapAnchorForEntry,
5757
spacerRow,
58+
spanColor,
5859
type RowSpan,
5960
type ScrollAnchor,
6061
type TranscriptRow,
@@ -1287,7 +1288,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
12871288
inside the budget so they never push a row out. */}
12881289
<Box flexDirection="column" flexGrow={1} flexShrink={1} overflow="hidden">
12891290
{view.showAbove && (
1290-
<Text dimColor wrap="truncate">
1291+
<Text color={theme.muted} wrap="truncate">
12911292
{` ↑ ${view.hiddenAbove} more line${view.hiddenAbove === 1 ? '' : 's'} above`}
12921293
</Text>
12931294
)}
@@ -1313,7 +1314,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
13131314
/>
13141315
))}
13151316
{view.showBelow && (
1316-
<Text dimColor wrap="truncate">
1317+
<Text color={theme.muted} wrap="truncate">
13171318
{` ↓ ${view.hiddenBelow} more line${view.hiddenBelow === 1 ? '' : 's'} below`}
13181319
</Text>
13191320
)}
@@ -1327,7 +1328,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
13271328
detail) can't grow the frame past the pane. */}
13281329
{notice && noticeRows > 0 && (
13291330
<Box height={noticeRows} flexShrink={0} overflow="hidden">
1330-
<Text dimColor>· {notice}</Text>
1331+
<Text color={theme.muted}>· {notice}</Text>
13311332
</Box>
13321333
)}
13331334
{/* The composer: the input area on the elevated surface — one step
@@ -1368,8 +1369,16 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
13681369
{/* The prompt is the selection glyph while the composer is where
13691370
you are (focused, no transcript highlight) — the same cyan
13701371
marker as everywhere else — and dim when it isn't. */}
1371-
<Text key={`${composer.text}:${composer.cursor}:${focused && navKey === null}`}>
1372-
<Text color={theme.foreground} dimColor={!(focused && navKey === null)}>
1372+
{/* The explicit colour on the parent is what the bare text
1373+
children below inherit — ink would otherwise leave the typed
1374+
text on the terminal's default foreground, unreadable against
1375+
the panel on a light theme — and it gives the inverse caret a
1376+
known pair of colours to swap. */}
1377+
<Text
1378+
key={`${composer.text}:${composer.cursor}:${focused && navKey === null}`}
1379+
color={theme.foreground}
1380+
>
1381+
<Text color={focused && navKey === null ? theme.foreground : theme.muted}>
13731382
{SELECTION_GLYPH}{' '}
13741383
</Text>
13751384
{composer.text.slice(0, composer.cursor)}
@@ -1389,7 +1398,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
13891398
</Text>
13901399
</Box>
13911400
)}
1392-
{!props.hideMetaLine && <Text dimColor>{metaLine}</Text>}
1401+
{!props.hideMetaLine && <Text color={theme.muted}>{metaLine}</Text>}
13931402
</Box>
13941403
</Box>
13951404
)
@@ -2096,12 +2105,11 @@ const RowLine = React.memo(function RowLine({
20962105
},
20972106
]
20982107
: row.spans
2099-
// A pulsing mark's off beat SWAPS ITS COLOUR rather than setting ink's
2100-
// dimColor: dim is \x1b[2m, which a fair number of terminals drop entirely
2101-
// when a 24-bit foreground is also set — the blink would silently do nothing
2102-
// there. Bone → grey is a real colour change, so it reads everywhere.
2103-
const markColor = (span: RowSpan): string | undefined =>
2104-
span.pulse && !pulseOn ? theme.muted : span.color
2108+
// Every span, gutter mark and right-hand readout below paints an explicit
2109+
// brand colour: `spanColor` resolves the pulse's off beat, a `dim` span and a
2110+
// span with no colour of its own onto real hexes, so nothing on the row is
2111+
// left to the terminal's own palette. See its comment in transcriptRows.
2112+
const markColor = (span: RowSpan): string => spanColor(span, pulseOn)
21052113
// Durations always render parenthesized, in the right-hand metadata column.
21062114
const right = row.tick
21072115
? { text: `(${[humanDuration(seconds), row.right?.text].filter(Boolean).join(' ')})`, dim: true }
@@ -2124,7 +2132,6 @@ const RowLine = React.memo(function RowLine({
21242132
a heartbeat rather than a character swapping in and out. */}
21252133
<Text
21262134
color={selected || !row.gutter ? theme.foreground : markColor(row.gutter)}
2127-
dimColor={!selected && !row.gutter?.pulse && (row.gutter?.dim ?? false)}
21282135
wrap="truncate"
21292136
>
21302137
{marker ? SELECTION_GLYPH : (row.gutter?.text ?? '')}
@@ -2136,7 +2143,6 @@ const RowLine = React.memo(function RowLine({
21362143
<Text
21372144
key={i}
21382145
color={selected ? theme.foreground : markColor(span)}
2139-
dimColor={!selected && !span.pulse && span.dim}
21402146
bold={span.bold}
21412147
>
21422148
{span.text}
@@ -2147,8 +2153,7 @@ const RowLine = React.memo(function RowLine({
21472153
{right && (
21482154
<Box flexShrink={0} paddingLeft={1}>
21492155
<Text
2150-
color={selected ? theme.foreground : right.color}
2151-
dimColor={!selected && right.dim}
2156+
color={selected ? theme.foreground : markColor(right)}
21522157
wrap="truncate"
21532158
>
21542159
{right.text}

src/ui/SessionsApp.tsx

Lines changed: 40 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -472,7 +472,7 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement {
472472
<Box flexShrink={0}>
473473
{/* truncate, never wrap: the bar is budgeted at exactly one row, and
474474
a wrapped meta line pushes the rule off the bottom of the band. */}
475-
<Text dimColor wrap="truncate">
475+
<Text color={theme.muted} wrap="truncate">
476476
{metaText ?? whoText}
477477
</Text>
478478
</Box>
@@ -515,10 +515,10 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement {
515515
paddingRight={1}
516516
paddingTop={1}
517517
>
518-
<Text dimColor>
518+
<Text color={theme.muted}>
519519
{loadError ? `✗ ${loadError}` : `loading ${mainPane.sessionId}…`}
520520
</Text>
521-
{loadError && <Text dimColor>esc: back to the sessions</Text>}
521+
{loadError && <Text color={theme.muted}>esc: back to the sessions</Text>}
522522
<EscOnlyInput active={focus === 'chat'} rawMode={isRawModeSupported} onEsc={focusNav} />
523523
</Box>
524524
)
@@ -620,27 +620,30 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement {
620620
{cursorHere ? SELECTION_GLYPH : g.glyph}
621621
</Text>{' '}
622622
<Text
623-
color={cursorHere ? theme.foreground : attention.has(s.id) ? theme.foreground : undefined}
623+
color={
624+
cursorHere || attention.has(s.id) || !g.dim
625+
? theme.foreground
626+
: theme.muted
627+
}
624628
bold={isOpen}
625-
dimColor={!cursorHere && !attention.has(s.id) && g.dim}
626629
>
627630
{desc.slice(0, descW)}
628631
</Text>
629632
</Text>
630633
</Box>
631634
<Box flexShrink={0}>
632-
<Text dimColor>{meta}</Text>
635+
<Text color={theme.muted}>{meta}</Text>
633636
</Box>
634637
</Box>
635638
)
636639
})}
637640
{rows.length === 0 && (
638-
<Text dimColor>{polledOnce ? 'no sessions yet' : 'loading sessions…'}</Text>
641+
<Text color={theme.muted}>{polledOnce ? 'no sessions yet' : 'loading sessions…'}</Text>
639642
)}
640643
{/* Absorbs the rows a short list leaves empty, keeping the hint on the
641644
band's bottom edge. */}
642645
<Box flexGrow={1} />
643-
<Text wrap="truncate" dimColor>
646+
<Text wrap="truncate" color={theme.muted}>
644647
{navFocused
645648
? `↑↓ move · enter open · n new · esc chat · q quit${
646649
win.end < rows.length ? ` · ${rows.length - win.end} more below` : ''
@@ -1045,21 +1048,23 @@ function NewSessionPane({
10451048
<Box width={width} height={height} flexDirection="column">
10461049
<Box flexGrow={1} />
10471050
<Box justifyContent="center">
1048-
<Text bold>What are we shipping today?</Text>
1051+
<Text bold color={theme.foreground}>
1052+
What are we shipping today?
1053+
</Text>
10491054
</Box>
10501055
{/* The fact box is sized to the text (capped so long facts wrap at a
10511056
readable measure) so short facts sit centered, not left-aligned
10521057
inside a fixed column. */}
10531058
<Box justifyContent="center" paddingTop={1}>
10541059
<Box width={Math.min(fact.length, Math.max(8, width - 4), 72)}>
1055-
<Text dimColor wrap="wrap">
1060+
<Text color={theme.muted} wrap="wrap">
10561061
{fact}
10571062
</Text>
10581063
</Box>
10591064
</Box>
10601065
<Box flexGrow={1} />
10611066
{error && <Text color={theme.error}>{error}</Text>}
1062-
{starting && <Text dimColor> ✻ Starting session…</Text>}
1067+
{starting && <Text color={theme.muted}> ✻ Starting session…</Text>}
10631068
{/* The input panel — the SAME surface as the chat composer, stepping
10641069
onto the lighter active surface while ANY of its four rows is where
10651070
you are (a picker row, its open option list, or the prompt), no
@@ -1085,8 +1090,10 @@ function NewSessionPane({
10851090
if (isOpen) {
10861091
return (
10871092
<Box key={r.key} flexDirection="column" width={inputWidth}>
1088-
<Text dimColor>{' '}{r.label}:</Text>
1089-
{win.start > 0 && <Text dimColor>{' '}{win.start} more</Text>}
1093+
<Text color={theme.muted}>{' '}{r.label}:</Text>
1094+
{win.start > 0 && (
1095+
<Text color={theme.muted}>{' '}{win.start} more</Text>
1096+
)}
10901097
{openOptions.slice(win.start, win.end).map((opt, j) => {
10911098
const at = win.start + j
10921099
const hovered = at === openHover
@@ -1098,30 +1105,29 @@ function NewSessionPane({
10981105
<Text color={theme.foreground}>
10991106
{hovered ? SELECTION_GLYPH : ' '}
11001107
</Text>{' '}
1101-
<Text
1102-
color={hovered ? theme.foreground : undefined}
1103-
dimColor={!hovered && !picked}
1104-
>
1108+
<Text color={hovered || picked ? theme.foreground : theme.muted}>
11051109
{`[${picked ? 'x' : ' '}] ${opt.label}`}
11061110
</Text>
11071111
</Text>
11081112
</Box>
11091113
)
11101114
})}
11111115
{win.end < openOptions.length && (
1112-
<Text dimColor>{' '}{openOptions.length - win.end} more</Text>
1116+
<Text color={theme.muted}>
1117+
{' '}{openOptions.length - win.end} more
1118+
</Text>
11131119
)}
11141120
</Box>
11151121
)
11161122
}
11171123
return (
11181124
<Box key={r.key} width={inputWidth}>
11191125
<Text wrap="truncate">
1120-
<Text color={theme.foreground} dimColor={!active}>
1126+
<Text color={active ? theme.foreground : theme.muted}>
11211127
{active ? SELECTION_GLYPH : ' '}
11221128
</Text>{' '}
1123-
<Text dimColor>{r.label}: </Text>
1124-
<Text color={active ? theme.foreground : undefined} dimColor={!active}>
1129+
<Text color={theme.muted}>{r.label}: </Text>
1130+
<Text color={active ? theme.foreground : theme.muted}>
11251131
{rowValue(r.key)}
11261132
</Text>
11271133
</Text>
@@ -1140,7 +1146,15 @@ function NewSessionPane({
11401146
remounts the node so a stale measurement can't wrap the caret
11411147
onto the border row. */}
11421148
<Box width={inputWidth} minHeight={2} alignItems="flex-start">
1143-
<Text wrap="wrap" key={`${text}:${cursor}:${focused && row === 'prompt'}`}>
1149+
{/* The colour on the parent is what the bare text children below
1150+
inherit (ink would leave typed text on the terminal's default
1151+
foreground, which a light theme paints near-black on our panel),
1152+
and it gives the inverse caret a known pair to swap. */}
1153+
<Text
1154+
wrap="wrap"
1155+
key={`${text}:${cursor}:${focused && row === 'prompt'}`}
1156+
color={theme.foreground}
1157+
>
11441158
<Text color={theme.foreground}>
11451159
{focused && row === 'prompt' && openPicker === null ? SELECTION_GLYPH : ' '}{' '}
11461160
</Text>
@@ -1155,10 +1169,12 @@ function NewSessionPane({
11551169
{text === '' && caretVisible && (
11561170
<Text>
11571171
<Text inverse>S</Text>
1158-
<Text dimColor>tart a cloud session…</Text>
1172+
<Text color={theme.muted}>tart a cloud session…</Text>
11591173
</Text>
11601174
)}
1161-
{text === '' && !caretVisible && <Text dimColor>Start a cloud session…</Text>}
1175+
{text === '' && !caretVisible && (
1176+
<Text color={theme.muted}>Start a cloud session…</Text>
1177+
)}
11621178
</Text>
11631179
</Box>
11641180
</Box>

0 commit comments

Comments
 (0)