From 81e5694eb856d4d09c77957d14525d1bfcf9cfe7 Mon Sep 17 00:00:00 2001 From: "ellipsis-dev[bot]" Date: Wed, 29 Jul 2026 17:41:10 +0000 Subject: [PATCH] ui: paint the whole frame from the brand palette, so a light terminal theme cannot bleed through 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: Claude --- src/lib/markdown.ts | Bin 10450 -> 11556 bytes src/lib/theme.ts | 68 ++++++++++++++++++++++++++++++++++++--- src/ui/ConnectApp.tsx | 37 ++++++++++++--------- src/ui/SessionsApp.tsx | 64 ++++++++++++++++++++++-------------- src/ui/transcriptRows.ts | 23 +++++++++++++ test/connect-app.test.ts | 59 +++++++++++++++++++++++++++++++++ test/markdown.test.ts | 47 +++++++++++++++++++++++++++ test/theme.test.ts | 57 ++++++++++++++++++++++++++++++++ 8 files changed, 310 insertions(+), 45 deletions(-) create mode 100644 test/theme.test.ts diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index e4cec3ae3ecfd294ea32c462c1d207e19bc69816..25a2ff8e4eb1dc732f5ae6ad052fedc249f7cfe7 100644 GIT binary patch delta 1069 zcmZuwL2eT<6kW8dpe_Q7BBV-xLD~tOOob}3sMJa+ASf!WC=?I~HO|aW#;Rin+tYN! zBAkIK5(nS_pdJ8m1&+WOH~{}UB{YKBjA!is{Qi6I|9JBG)tB}9cx?@~jYbeUgRW6o z3`!m&;0f9ijL*?Rjv|w~pFk(gg*r|wW*u&&1$}}e7*M1#?isyUf68Rt*-T}XE(oIxl~r& zYAlW0kXbR(f2CxOG4X?m4uisDmRt%4q-3bkiuDPeu#(U%Ype$cEyN-w2V^?6n)Uv# z*W(LEA#%up;HXgq8O)-l1fp%mY<4h7ka9sF%w}bTHYMw{#jTJ~%)^S5A_ba5z-VGP zL}48ZFEgGSK$nW)4rKt}7N}Di3A)DOP>GZ=Y@x}+9$!2F=_o3_i>V>Bk<5Hf13`QS zC|p6aRehUZd;}=zyoHe{lUlFPAv+VwGoEhHT(PCiKMQzADV_MoDIwE33yT}{&!R8v gg*`OF%nqYVCbGpbM8viUn_I+MA%C22HO}UK1I=G#i~s-t delta 67 zcmV-J0KEUCTGCOlGZB*m6CeU|Ws^J;nFc3ka$$KVlhqTF4|HK-Y-KKVZ&P$~X>MmI ZvxF4E5tG0suaj9OC6jt59kZD!ivvAg7~TK? diff --git a/src/lib/theme.ts b/src/lib/theme.ts index 900642c..0af4df7 100644 --- a/src/lib/theme.ts +++ b/src/lib/theme.ts @@ -1,3 +1,5 @@ +import chalk from 'chalk' + // The Ellipsis brand palette, dark mode — the CLI's one source of color. // // These hexes are COPIES of brand/tokens.json in the ellipsis monorepo (the @@ -11,20 +13,76 @@ // accent in dark mode is BONE, not brand blue. Brand ink #175173 scores // 1.79:1 on the panel — unreadable as terminal text. So "you are here" is // carried by brightness (bone against stone), not by hue. +// +// Because the CLI paints its own canvas, the palette only holds if it is used +// for EVERY cell of the frame. Two rules keep it whole on a terminal whose own +// theme is light: +// +// 1. Every glyph takes a color from this file. Ink leaves a `` with no +// `color` prop on the terminal's DEFAULT foreground, which under a light +// theme is near-black — the same near-black we just painted the canvas +// with, so the text vanishes. `dimColor` on its own is that bug plus an +// \x1b[2m: secondary copy takes `muted`, never a bare `dimColor`. (dim is +// fine ON TOP of an explicit color, where it only shades a known hue.) +// 2. Surfaces reach ink already quantized for the terminal's color depth — +// see `surfaceFor`, which is why the three surface entries below are +// computed rather than literal. + +// The surfaces as authored. Call sites never read these: they take the +// `theme.*` entries, which are these run through `surfaceFor`. +const BRAND_SURFACES = { + canvas: '#1c1b1a', + panel: '#262523', + panelActive: '#343330', +} as const + +// A surface hex ink can paint at `level` without losing the step between one +// surface and the next. +// +// chalk resolves a hex onto the 256-color palette two different ways: to the +// 24-rung GREYSCALE RAMP (indexes 232-255, ~10 units apart) when r, g and b +// are equal, and otherwise to the 6x6x6 COLOR CUBE, whose darkest step above +// black is rgb(95,95,95). The brand surfaces are WARM greys — their channels +// differ by a point or two — so on a terminal that does 256 colors but not +// truecolor (Terminal.app, tmux without RGB, mosh, plain conhost) all three +// land on cube index 59 simultaneously: the near-black canvas paints as a mid +// grey slab, and the panel and active steps disappear along with every "you +// are here" highlight that was carried by them. +// +// Averaging the channels is invisible at this brightness (a warm near-black +// and a neutral near-black are the same wall of dark) and puts each surface +// back on its own rung: 234, 235, 236. Truecolor terminals get the authored +// warmth untouched; a 16-color terminal renders both spellings as its palette +// black, so the substitution costs nothing there either. +export function surfaceFor(hex: string, level: number): string { + if (level >= 3) return hex + const value = hex.replace('#', '') + const channels = [0, 2, 4].map((i) => Number.parseInt(value.slice(i, i + 2), 16)) + if (channels.some(Number.isNaN)) return hex + const mean = Math.round((channels[0] + channels[1] + channels[2]) / 3) + return `#${mean.toString(16).padStart(2, '0').repeat(3)}` +} + +// `chalk.level` is read once, at import: ink colorizes through this very chalk +// instance (it is a hoisted single copy), so what it can render is what we +// quantize for. +const COLOR_LEVEL: number = chalk.level export const theme = { // The app canvas and the lifted panel an input sits on. ~1.1:1 apart: barely // a lift, which is the point — a panel should separate, not stripe. - canvas: '#1c1b1a', - panel: '#262523', + canvas: surfaceFor(BRAND_SURFACES.canvas, COLOR_LEVEL), + panel: surfaceFor(BRAND_SURFACES.panel, COLOR_LEVEL), // One step lighter than `panel`: the brand border hairline, doing duty as // the "you are here" surface (highlighted message, focused composer, // selected nav row). Selection is a brightness step between surfaces — // never the full inverse flash, which reads bone-white and far too loud. - panelActive: '#343330', + panelActive: surfaceFor(BRAND_SURFACES.panelActive, COLOR_LEVEL), // Type. `foreground` is body copy and doubles as the accent (see above); - // `muted` is every secondary string (meta, hints, timestamps). + // `muted` is every secondary string (meta, hints, timestamps) — and, since + // rule 1 above rules out a bare `dimColor`, it is also how a quiet line + // reads quiet. 7.4:1 on the canvas, so quiet still means legible. foreground: '#f0efe9', muted: '#a8a59c', @@ -40,7 +98,7 @@ export const theme = { // use for code blocks, so a snippet reads the same in the CLI as in the docs. syntaxLiteral: '#d9bd8d', syntaxString: '#c8c6bc', -} as const +} // The elevated surface an input area sits on. Named separately from // `theme.panel` because call sites mean "this is an input", not "this is diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index a1a1bf0..a675d60 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -55,6 +55,7 @@ import { rowViewport, snapToEntry, spacerRow, + spanColor, type RowSpan, type ScrollAnchor, type TranscriptRow, @@ -1293,7 +1294,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { inside the budget so they never push a row out. */} {view.showAbove && ( - + {` ↑ ${view.hiddenAbove} more line${view.hiddenAbove === 1 ? '' : 's'} above`} )} @@ -1319,7 +1320,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { /> ))} {view.showBelow && ( - + {` ↓ ${view.hiddenBelow} more line${view.hiddenBelow === 1 ? '' : 's'} below`} )} @@ -1333,7 +1334,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { detail) can't grow the frame past the pane. */} {notice && noticeRows > 0 && ( - · {notice} + · {notice} )} {/* The composer: the input area on the elevated surface — one step @@ -1374,8 +1375,16 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { {/* The prompt is the selection glyph while the composer is where you are (focused, no transcript highlight) — the same cyan marker as everywhere else — and dim when it isn't. */} - - + {/* The explicit colour on the parent is what the bare text + children below inherit — ink would otherwise leave the typed + text on the terminal's default foreground, unreadable against + the panel on a light theme — and it gives the inverse caret a + known pair of colours to swap. */} + + {SELECTION_GLYPH}{' '} {composer.text.slice(0, composer.cursor)} @@ -1395,7 +1404,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { )} - {!props.hideMetaLine && {metaLine}} + {!props.hideMetaLine && {metaLine}} ) @@ -2102,12 +2111,11 @@ const RowLine = React.memo(function RowLine({ }, ] : row.spans - // A pulsing mark's off beat SWAPS ITS COLOUR rather than setting ink's - // dimColor: dim is \x1b[2m, which a fair number of terminals drop entirely - // when a 24-bit foreground is also set — the blink would silently do nothing - // there. Bone → grey is a real colour change, so it reads everywhere. - const markColor = (span: RowSpan): string | undefined => - span.pulse && !pulseOn ? theme.muted : span.color + // Every span, gutter mark and right-hand readout below paints an explicit + // brand colour: `spanColor` resolves the pulse's off beat, a `dim` span and a + // span with no colour of its own onto real hexes, so nothing on the row is + // left to the terminal's own palette. See its comment in transcriptRows. + const markColor = (span: RowSpan): string => spanColor(span, pulseOn) // Durations always render parenthesized, in the right-hand metadata column. const right = row.tick ? { text: `(${[humanDuration(seconds), row.right?.text].filter(Boolean).join(' ')})`, dim: true } @@ -2130,7 +2138,6 @@ const RowLine = React.memo(function RowLine({ a heartbeat rather than a character swapping in and out. */} {marker ? SELECTION_GLYPH : (row.gutter?.text ?? '')} @@ -2142,7 +2149,6 @@ const RowLine = React.memo(function RowLine({ {span.text} @@ -2153,8 +2159,7 @@ const RowLine = React.memo(function RowLine({ {right && ( {right.text} diff --git a/src/ui/SessionsApp.tsx b/src/ui/SessionsApp.tsx index dce47f7..ad0803c 100644 --- a/src/ui/SessionsApp.tsx +++ b/src/ui/SessionsApp.tsx @@ -472,7 +472,7 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { {/* truncate, never wrap: the bar is budgeted at exactly one row, and a wrapped meta line pushes the rule off the bottom of the band. */} - + {metaText ?? whoText} @@ -515,10 +515,10 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { paddingRight={1} paddingTop={1} > - + {loadError ? `✗ ${loadError}` : `loading ${mainPane.sessionId}…`} - {loadError && esc: back to the sessions} + {loadError && esc: back to the sessions} ) @@ -620,27 +620,30 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { {cursorHere ? SELECTION_GLYPH : g.glyph} {' '} {desc.slice(0, descW)} - {meta} + {meta} ) })} {rows.length === 0 && ( - {polledOnce ? 'no sessions yet' : 'loading sessions…'} + {polledOnce ? 'no sessions yet' : 'loading sessions…'} )} {/* Absorbs the rows a short list leaves empty, keeping the hint on the band's bottom edge. */} - + {navFocused ? `↑↓ move · enter open · n new · esc chat · q quit${ win.end < rows.length ? ` · ${rows.length - win.end} more below` : '' @@ -1045,21 +1048,23 @@ function NewSessionPane({ - What are we shipping today? + + What are we shipping today? + {/* The fact box is sized to the text (capped so long facts wrap at a readable measure) so short facts sit centered, not left-aligned inside a fixed column. */} - + {fact} {error && ✗ {error}} - {starting && ✻ Starting session…} + {starting && ✻ Starting session…} {/* The input panel — the SAME surface as the chat composer, stepping onto the lighter active surface while ANY of its four rows is where you are (a picker row, its open option list, or the prompt), no @@ -1085,8 +1090,10 @@ function NewSessionPane({ if (isOpen) { return ( - {' '}{r.label}: - {win.start > 0 && {' '}… {win.start} more} + {' '}{r.label}: + {win.start > 0 && ( + {' '}… {win.start} more + )} {openOptions.slice(win.start, win.end).map((opt, j) => { const at = win.start + j const hovered = at === openHover @@ -1098,10 +1105,7 @@ function NewSessionPane({ {hovered ? SELECTION_GLYPH : ' '} {' '} - + {`[${picked ? 'x' : ' '}] ${opt.label}`} @@ -1109,7 +1113,9 @@ function NewSessionPane({ ) })} {win.end < openOptions.length && ( - {' '}… {openOptions.length - win.end} more + + {' '}… {openOptions.length - win.end} more + )} ) @@ -1117,11 +1123,11 @@ function NewSessionPane({ return ( - + {active ? SELECTION_GLYPH : ' '} {' '} - {r.label}: - + {r.label}: + {rowValue(r.key)} @@ -1140,7 +1146,15 @@ function NewSessionPane({ remounts the node so a stale measurement can't wrap the caret onto the border row. */} - + {/* The colour on the parent is what the bare text children below + inherit (ink would leave typed text on the terminal's default + foreground, which a light theme paints near-black on our panel), + and it gives the inverse caret a known pair to swap. */} + {focused && row === 'prompt' && openPicker === null ? SELECTION_GLYPH : ' '}{' '} @@ -1155,10 +1169,12 @@ function NewSessionPane({ {text === '' && caretVisible && ( S - tart a cloud session… + tart a cloud session… )} - {text === '' && !caretVisible && Start a cloud session…} + {text === '' && !caretVisible && ( + Start a cloud session… + )} diff --git a/src/ui/transcriptRows.ts b/src/ui/transcriptRows.ts index 37877c3..ccf9478 100644 --- a/src/ui/transcriptRows.ts +++ b/src/ui/transcriptRows.ts @@ -48,6 +48,29 @@ export type RowSpan = { pulse?: boolean } +// The colour a span actually paints in. Every span resolves to a brand hex — +// there is no "unstyled" span and no `dimColor` — because neither of the two +// things a bare span would fall back on belongs to us: +// +// * NO COLOUR MEANS THE TERMINAL'S COLOUR. Ink leaves an uncoloured `` +// on the terminal's default foreground, which under a LIGHT theme is the +// same near-black as the canvas we paint beneath it. Most spans carry no +// colour of their own (the assistant's prose included, via styleFor), so +// the bulk of a transcript came out dark on dark. See theme.ts, rule 1. +// * DIM IS OPTIONAL, as far as terminals are concerned: \x1b[2m is dropped +// outright by a fair number of them once a 24-bit foreground is also set. +// That is the same reason a pulsing mark SWAPS its colour on the off beat +// instead of dimming — bone → grey is a real colour change, so it reads +// everywhere. `dim` resolving to `muted` applies that everywhere else. +export function spanColor( + span: Pick, + pulseOn: boolean, +): string { + if (span.pulse && !pulseOn) return theme.muted + if (span.color) return span.color + return span.dim ? theme.muted : theme.foreground +} + export type TranscriptRow = { // Unique per row, for React keys. id: string diff --git a/test/connect-app.test.ts b/test/connect-app.test.ts index 916a8cb..f777a66 100644 --- a/test/connect-app.test.ts +++ b/test/connect-app.test.ts @@ -21,10 +21,12 @@ import { layOutItems, rowViewport, snapToEntry, + spanColor, withRenderedMarkdown, type TranscriptRow, } from '../src/ui/transcriptRows' import stripAnsi from 'strip-ansi' +import { theme } from '../src/lib/theme' import type { TranscriptItem } from '@ellipsis-dev/sdk/store' let seq = 0 @@ -947,3 +949,60 @@ describe('foldRun', () => { expect(foldRun('grp:missing', [prose('a')])).toEqual([]) }) }) + +describe('spanColor', () => { + // Nothing on a row may fall through to the terminal's own foreground: under a + // light theme that is the same near-black as the canvas painted beneath it. + it('gives an uncoloured span the brand foreground, never the terminal default', () => { + expect(spanColor({}, true)).toBe(theme.foreground) + }) + + it('resolves dim to muted instead of leaning on the terminal honouring dim', () => { + expect(spanColor({ dim: true }, true)).toBe(theme.muted) + }) + + it('keeps a span its own colour', () => { + expect(spanColor({ color: theme.error, dim: true }, true)).toBe(theme.error) + }) + + it('swaps a pulsing mark to muted on the off beat, whatever it is coloured', () => { + expect(spanColor({ color: theme.success, pulse: true }, false)).toBe(theme.muted) + expect(spanColor({ color: theme.success, pulse: true }, true)).toBe(theme.success) + }) +}) + +describe('itemRows colours', () => { + // styleFor leaves textColor off for most kinds, the assistant's prose (the + // bulk of a transcript) included, so this is the path that used to reach ink + // with no colour and render in the terminal's default foreground. + // What each kind's body text lands on: prose and the work it describes read + // primary, the infrastructure and a tool's output read quiet, a failure reads + // error. Quiet is a COLOUR, so it survives a terminal that ignores dim. + const bodyColor: Array<[TranscriptItem['kind'], string]> = [ + ['assistant', theme.foreground], + ['user', theme.foreground], + ['tool', theme.foreground], + ['tool_result', theme.muted], + ['thinking', theme.muted], + ['system', theme.muted], + ['notice', theme.muted], + ['summary', theme.muted], + ['error', theme.error], + ] + + it('resolves each kind to its brand colour, never to the terminal default', () => { + for (const [kind, expected] of bodyColor) { + const rows = itemRows({ key: `k:${kind}`, kind, text: 'some text' }, 60, { clamp: false }) + const body = rows.filter((r) => r.spans.some((s) => s.text.includes('some text'))) + expect(body.length, kind).toBeGreaterThan(0) + for (const row of body) { + for (const span of row.spans) expect(spanColor(span, true), kind).toBe(expected) + if (row.gutter) { + expect(new Set(Object.values(theme)), `${kind} gutter`).toContain( + spanColor(row.gutter, true), + ) + } + } + } + }) +}) diff --git a/test/markdown.test.ts b/test/markdown.test.ts index 674a602..2aa819f 100644 --- a/test/markdown.test.ts +++ b/test/markdown.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' +import chalk from 'chalk' import stripAnsi from 'strip-ansi' import { fitLines, hasMarkdown, renderMarkdown, visibleWidth } from '../src/lib/markdown' +import { theme } from '../src/lib/theme' const lines = (text: string): string[] => text.split('\n') const plain = (text: string): string => stripAnsi(text) @@ -132,3 +134,48 @@ describe('fitLines', () => { expect(visibleWidth(out[0])).toBeLessThanOrEqual(20) }) }) + +// Everything a table draws used to resolve through the terminal's OWN 16-colour +// palette: chalk.red on the header cells, and cli-table3's `border: ['gray']`, +// which only takes a colour NAME. Whatever the user's theme mapped those two +// slots to was what a table came out as. +describe('renderMarkdown colours', () => { + const table = '| Name | Status |\n| --- | --- |\n| alpha | ok |' + + // Rendered escapes only exist when chalk is actually emitting colour, which + // it isn't under a test runner's pipe. Force a level, and vary the width so + // the (width, source)-keyed cache can't hand back an uncoloured render. + const coloured = (source: string, width: number): string => { + const level = chalk.level + chalk.level = 3 + try { + return renderMarkdown(source, width) + } finally { + chalk.level = level + } + } + + it('draws the header in brand bone, not the palette red', () => { + const out = coloured(table, 41) + expect(out).not.toContain('') + expect(out).toContain(chalk.hex(theme.foreground).bold('Name')) + }) + + it('draws the rules in brand muted, not the palette grey', () => { + const out = coloured(table, 42) + expect(out).not.toContain('') + // Every box-drawing run carries the muted foreground. + for (const run of out.match(/[│┌┐└┘├┤┬┴┼─]+/g) ?? []) { + expect(out).toContain(chalk.hex(theme.muted)(run)) + } + }) + + it('leaves cell text uncoloured so the row it lands on supplies the brand fg', () => { + // The transcript wraps each row in an explicit colour (see spanColor), and + // chalk re-opens it after a nested reset, so a cell needs no colour of its + // own — but it must not carry a full  either, which would drop the + // row's background tint for the rest of the line. + const out = coloured(table, 43) + expect(out).not.toContain('') + }) +}) diff --git a/test/theme.test.ts b/test/theme.test.ts new file mode 100644 index 0000000..f0fa567 --- /dev/null +++ b/test/theme.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { Chalk } from 'chalk' +import { surfaceFor, theme } from '../src/lib/theme' + +// The 256-color index chalk resolves a hex to, which is what ink paints with +// on a terminal that does 256 colors but not truecolor. +const ansi256 = (hex: string): number => { + const match = new Chalk({ level: 2 }).bgHex(hex)('x').match(/48;5;(\d+)/) + return Number(match?.[1]) +} + +describe('surfaceFor', () => { + it('leaves the authored warmth alone on a truecolor terminal', () => { + expect(surfaceFor('#1c1b1a', 3)).toBe('#1c1b1a') + expect(surfaceFor('#262523', 3)).toBe('#262523') + }) + + it('neutralizes the channels below truecolor', () => { + expect(surfaceFor('#1c1b1a', 2)).toBe('#1b1b1b') + expect(surfaceFor('#262523', 2)).toBe('#252525') + expect(surfaceFor('#343330', 2)).toBe('#323232') + expect(surfaceFor('#1c1b1a', 1)).toBe('#1b1b1b') + }) + + it('passes a malformed hex through rather than painting garbage', () => { + expect(surfaceFor('nonsense', 2)).toBe('nonsense') + }) + + // The bug this exists for: chalk sends any hex whose channels differ to the + // 6x6x6 color cube, whose darkest step above black is rgb(95,95,95). All + // three warm brand surfaces landed on that ONE index, so the near-black + // canvas painted mid grey and every surface step (and with it every "you are + // here" highlight) disappeared on a 256-color terminal. + it('keeps the three surfaces three distinct steps on a 256-color terminal', () => { + const authored = [ansi256('#1c1b1a'), ansi256('#262523'), ansi256('#343330')] + expect(new Set(authored).size).toBe(1) + expect(authored[0]).toBe(59) + + const painted = [ + ansi256(surfaceFor('#1c1b1a', 2)), + ansi256(surfaceFor('#262523', 2)), + ansi256(surfaceFor('#343330', 2)), + ] + expect(new Set(painted).size).toBe(3) + // On the greyscale ramp (232-255), where a near-black stays near-black. + expect(painted.every((index) => index >= 232)).toBe(true) + expect(painted).toEqual([...painted].sort((a, b) => a - b)) + }) +}) + +describe('theme', () => { + it('carries a color for every token, so no call site has to fall back', () => { + for (const [name, value] of Object.entries(theme)) { + expect(value, name).toMatch(/^#[0-9a-f]{6}$/) + } + }) +})