diff --git a/bin/icon.ts b/bin/icon.ts index 681abff..1fa8cda 100755 --- a/bin/icon.ts +++ b/bin/icon.ts @@ -32,7 +32,7 @@ import { join } from 'node:path'; import { openaiImages } from '../src/emoji.ts'; import { resolveCredentials } from '../src/credentials.ts'; -import { HQ_CONCURRENCY, buildHq, drawHq, guardCredits, withStyle } from '../src/icon-hq.ts'; +import { HQ_CONCURRENCY, buildHq, drawHq, guardCredits, loadBrandColors, withStyle } from '../src/icon-hq.ts'; import { AGENTIC_STYLES, STYLES, type StyleSpec, resolveStyle } from '../src/icon-styles.ts'; import { isMain } from '../src/is-main.ts'; @@ -51,14 +51,17 @@ const USAGE = `Usage: [--style-dir DIR] [--dry-run] A colour style draws every UI icon with an image model (needs OPENAI_API_KEY), -each pinned to its own simple line glyph so the silhouette and meaning hold; -brands are never drawn, they take their owners' colours. +each pinned to its own simple line glyph so the silhouette and meaning hold. hq glass and enamel under a warm key light, drawn beside the OpenEmoji - masters (--style-dir, default ~/brand-assets/openemoji/master) + masters (--style-dir, default ~/brand-assets/openemoji/master). + Brands are not drawn: they stay the owner's flat mark in the + owner's published colour. agentic the three flat-material styles, which take no emoji reference: matte (the default, and the one that holds at 20px on any ground), - machined, emissive + machined, emissive. These also re-render the brand marks in the + material, pinned to the owner's own mark: same geometry, same + colour, new surface. Masters go to /master and are never redrawn; --no-draw only rebuilds sizes and the manifest, and --draw-only only draws. Drawing a whole @@ -231,13 +234,19 @@ if (isMain(import.meta.url)) { if (!existsSync(manifestFile)) throw new UsageError(`no ${manifestFile}; run \`icon build --out ${out}\` first`); const keys = csv(values, '--only').length ? csv(values, '--only').map((n) => find(n)?.key ?? n) - : GENERIC.map((i) => i.key); - const drawable = keys.filter((k) => GENERIC.some((i) => i.key === k)); + : [...GENERIC.map((i) => i.key), ...BRANDS.map((b) => b.key)]; + // What is drawable depends on the style: every style draws the generic + // icons, and one that knows how to re-render a mark also draws the + // brands. For any other style a brand is still a recolour, so asking + // for one is a no-op rather than an error. + const drawableFor = (style: StyleSpec) => + keys.filter((k) => GENERIC.some((i) => i.key === k) || (style.promptForBrand && BRANDS.some((b) => b.key === k))); const quality = values.get('--quality') ?? 'medium'; if (!['low', 'medium', 'high'].includes(quality)) throw new UsageError('--quality must be low, medium or high'); if (flags.has('--dry-run')) { for (const style of chosen) { + const drawable = drawableFor(style); const pending = drawable.filter((k) => flags.has('--force') || !existsSync(join(out, style.dir, 'master', `${k}.png`))); process.stdout.write(`${style.id}: ${flags.has('--no-draw') ? 0 : pending.length} icons to draw, then sizes, brand colours and openicon.json in ${out}\n`); } @@ -245,15 +254,23 @@ if (isMain(import.meta.url)) { } for (const style of chosen) { + const drawable = drawableFor(style); const pending = drawable.filter((k) => flags.has('--force') || !existsSync(join(out, style.dir, 'master', `${k}.png`))); if (!flags.has('--no-draw') && pending.length) { const key = resolveCredentials()['OPENAI_API_KEY']; if (!key) throw new Error('no OpenAI key: export OPENAI_API_KEY or run `cli-tools config set openai`'); const { Resvg } = await import('@resvg/resvg-js'); + // A style that restyles marks needs the owner colours at draw + // time, not only at derive time: without them every mark is drawn + // as "monochrome" and comes back graphite. + const brandColors = style.promptForBrand && drawable.some((k) => BRANDS.some((b) => b.key === k)) + ? await loadBrandColors() + : undefined; const report = await drawHq({ out, style, keys: drawable, + ...(brandColors ? { brandColors } : {}), caller: guardCredits(openaiImages(key)), styleDir: values.get('--style-dir') ?? join(process.env['HOME'] ?? '', 'brand-assets', 'openemoji', 'master'), concurrency: Number(values.get('--concurrency') ?? HQ_CONCURRENCY), diff --git a/src/icon-hq.ts b/src/icon-hq.ts index 0927052..7879ede 100644 --- a/src/icon-hq.ts +++ b/src/icon-hq.ts @@ -11,7 +11,10 @@ * the emoji; the agentic styles deliberately use none, because those masters * are where the gloss comes from. * - * Brands are never drawn in any style: their coloured form is the same logo in + * Brands are drawn only by a style that says how (`promptForBrand`), and then + * only as a material re-rendering of the owner's own mark, pinned by that mark + * as the reference: the geometry is never the model's to invent. A style that + * says nothing — `hq` — keeps the older behaviour, the same logo recoloured to * the owner's published colour (Simple Icons' `hex`, or a cited brand page for * the few that came from Font Awesome). * @@ -60,6 +63,35 @@ export const FA_BRAND_COLORS: Record = export const SIMPLE_ICONS_DATA = `https://cdn.jsdelivr.net/npm/simple-icons@${SIMPLE_ICONS_VERSION}/data/simple-icons.json`; +/** A brand's published colour and where it is published, or undefined. */ +export interface BrandColor { + hex: string; + source: string; +} + +/** + * Every brand's owner colour, from Simple Icons where it carries one and from + * the cited brand page for the nine that came from Font Awesome. Both the draw + * pass and the derive pass need this — the draw pass to tell the model what + * colour the mark is, the derive pass to record it — so it is resolved once. + */ +export async function loadBrandColors(fetchImpl: typeof fetch = fetch): Promise> { + return brandColorsFrom(await loadSimpleIconsHex(fetchImpl)); +} + +export function brandColorsFrom(siHex: Map): Map { + const out = new Map(); + for (const brand of BRANDS) { + if (brand.source === 'simple-icons') { + const hex = siHex.get(brand.slug); + if (hex) out.set(brand.key, { hex, source: `simple-icons:${brand.slug}` }); + } else if (FA_BRAND_COLORS[brand.key]) { + out.set(brand.key, FA_BRAND_COLORS[brand.key]!); + } + } + return out; +} + // ------------------------------------------------------------------ draw export class CreditError extends Error {} @@ -92,6 +124,11 @@ export interface HqDrawOptions { sleep?: (ms: number) => Promise; /** Which style to draw; the HQ style when a caller does not say. */ style?: StyleSpec; + /** + * Owner colours, needed only when the style restyles brands. Absent means + * brand keys are rejected exactly as they were before styles could draw them. + */ + brandColors?: Map; } export interface HqDrawReport { @@ -112,8 +149,16 @@ export async function drawHq(options: HqDrawOptions): Promise { const byKey = new Map(GENERIC.map((i) => [i.key, i])); const sleep = options.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))); + const brandByKey = new Map(BRANDS.map((b) => [b.key, b])); + const drawsBrands = Boolean(style.promptForBrand); + const todo = options.keys.filter((key) => { - if (!byKey.has(key)) throw new Error(`${key} is not a drawn icon (brands take their colour, they are not drawn)`); + if (!byKey.has(key)) { + if (!brandByKey.has(key)) throw new Error(`${key} is not an icon in the set`); + // A brand is drawable only for a style that says how to re-render a mark; + // for every other style its coloured form is still the flat recolour. + if (!drawsBrands) throw new Error(`${key} is a brand and ${style.id} does not restyle marks (it recolours them)`); + } if (!options.force && existsSync(join(master, `${key}.png`))) { report.skipped.push(key); return false; @@ -123,26 +168,52 @@ export async function drawHq(options: HqDrawOptions): Promise { await pool(todo, options.concurrency, async (key) => { if (report.stoppedForCredit) return; - const icon = byKey.get(key)!; - // The line glyph, dark on white: a clear silhouette for the model to keep. - const line = await options.render( - strokeSvg(icon.body).replace(' f.endsWith('.png')) : []; const generic = new Set(GENERIC.map((i) => i.key)); + const brandKeys = new Set(BRANDS.map((b) => b.key)); + const drawnBrands = new Set(); await pool(masters, 8, async (file) => { const key = file.slice(0, -4); - if (!generic.has(key)) return; + if (!generic.has(key) && !brandKeys.has(key)) return; const source = join(masterDir, file); const outputs = [ ...options.sizes.map((s) => join(hq, 'png', String(s), `${key}.png`)), @@ -276,23 +357,30 @@ export async function buildHq(options: HqBuildOptions): Promise e.made_by === 'ai').length} drawn, ${[...entries.values()].filter((e) => e.hex).length} brand-coloured)`); + options.log( + `${entries.size} ${style.id} icons (${[...entries.values()].filter((e) => e.made_by === 'ai').length} drawn, ` + + `${drawnBrands.size} marks in the material, ${[...entries.values()].filter((e) => e.made_by === 'human').length} marks recoloured)`, + ); if (broken.length) options.log(`${broken.length} unreadable masters, left out of the manifest: ${broken.join(', ')}`); return entries; } diff --git a/src/icon-styles.ts b/src/icon-styles.ts index bdf178b..a441032 100644 --- a/src/icon-styles.ts +++ b/src/icon-styles.ts @@ -58,6 +58,29 @@ export interface StyleSpec { /** The style paragraph, without the icon. */ styleText: string; promptFor(icon: IconDef): string; + /** + * The prompt for a brand mark, or absent when this style does not restyle + * marks at all. A brand is not drawn from a description the way a generic + * icon is: the mark itself is the reference and the model's only job is to + * re-render it in this style's material, in the owner's own colour. Every + * rule that protects the drawn icons (keep the silhouette, add nothing, no + * text) matters more here, not less. + * + * `hq` deliberately leaves this undefined: its brands stay the owner's flat + * mark in the owner's colour, which is what the spec's rule 10 describes and + * what a first-release reader already got. A style with no `promptForBrand` + * falls back to that recolour, so adding one is the only switch needed to + * bring another style's marks into the material. + */ + promptForBrand?(brand: BrandSubject): string; +} + +/** What a style needs to know about a brand to re-render its mark. */ +export interface BrandSubject { + key: string; + title: string; + /** The owner's published colour, when they publish one. */ + hex?: string; } export const HQ_STYLE = `Design one icon for a premium, original colour icon set that sits beside a glossy 3D emoji set as one family. @@ -79,6 +102,89 @@ The reference image is this icon's line drawing: keep its exact silhouette, part Composition: the single object, centred, filling about 84% of the square, straight-on front view, nothing cropped. Fully transparent background, no ground shadow, no badge, no frame, no backdrop. It must stay instantly readable at 20 pixels. Every design is original: never resemble a logo, mascot or product from any brand, film or game.`; +/** + * The frame for a brand mark. The drawn-icon frame ends by forbidding any + * resemblance to a logo, which is exactly backwards here: the mark IS the + * subject. What replaces that guard is a tighter one — reproduce this mark and + * nothing else — because the only defensible way to restyle a trademark is to + * change its material and never its geometry. + */ +export const BRAND_FRAME = `Re-render one existing logo mark as a solid object in a specific material. +The reference image is the mark itself. Reproduce its exact geometry: every shape, proportion, angle, counter and piece of negative space, in the same arrangement. This is the SAME mark in a new material, never a redesign: do not simplify it, do not stylise its shapes, do not add or remove parts, do not add text or lettering it does not have, do not place it on a badge, tile, rounded square or any container it does not already have. +Keep figure and ground exactly as the reference has them: where the mark is a solid shape with a cut-out counter inside it, the solid stays solid and the counter stays empty. Never invert them, never fill a counter, never hollow out a solid. +Keep the mark's colour. The material changes the surface, never the hue: a red mark stays red, a blue mark stays blue, a multi-coloured mark keeps each of its colours in the same places. Do not wash the mark to white, grey or a single neutral, and do not recolour it to match the material. +Composition: the mark alone, centred, filling about 84% of the square, straight-on front view, nothing cropped. Fully transparent background, no ground shadow, no frame, no backdrop. +It must stay instantly recognisable at 20 pixels.`; + +/** + * sRGB relative luminance, 0 (black) to 1 (white). Needed because a style that + * works by emitting light has no answer for a mark whose own colour is black: + * roughly fifteen of the set's marks are, and told to "glow in its own colour" + * a model invents a hue instead (GitHub came back glowing blue). + */ +export function luminanceOf(hex: string): number { + const n = Number.parseInt(hex.replace('#', ''), 16); + const channel = (c: number) => { + const s = c / 255; + return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * channel((n >> 16) & 255) + 0.7152 * channel((n >> 8) & 255) + 0.0722 * channel(n & 255); +} + +/** Below this a mark cannot carry a style that works by emitting light. */ +export const DARK_MARK = 0.06; + +/** Below this a colour has no hue left to lighten towards; white is honest. */ +export const ACHROMATIC = 0.25; + +/** HSV saturation, 0 (grey) to 1: how much hue a colour still has. */ +export function saturationOf(hex: string): number { + const n = Number.parseInt(hex.replace('#', ''), 16); + const channels = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((c) => c / 255); + const max = Math.max(...channels); + return max === 0 ? 0 : (max - Math.min(...channels)) / max; +} + +/** + * What an emissive mark should give off. Of the set's 111 marks, 26 are too + * dark to emit their own colour; 22 of those are achromatic (GitHub, Apple, + * OpenAI — black, with no hue to raise) and 4 are simply dark but coloured + * (Slack's aubergine, PayPal's navy), where a lightened tint of the brand's + * own hue keeps the identity that plain white throws away. + */ +export type Emission = { kind: 'own'; hex: string } | { kind: 'tint'; hex: string } | { kind: 'neutral' }; + +export function emissionFor(hex: string | undefined): Emission { + if (!hex) return { kind: 'neutral' }; + if (luminanceOf(hex) >= DARK_MARK) return { kind: 'own', hex }; + return saturationOf(hex) >= ACHROMATIC ? { kind: 'tint', hex } : { kind: 'neutral' }; +} + +/** + * Each style's material, written for a mark rather than a drawn object, and + * told the mark's colour outright. "Its own colour" is not enough: the agentic + * family's register (engineered, graphite, dark) is strong enough on its own + * that every one of six pilot marks came back black until the hex was named + * here, in the material, rather than in a sentence beside it. + */ +const AGENTIC_BRAND_MATERIALS: Record string> = { + matte: (hex) => `Style: matte flat-dimensional. ${ + hex + ? `The mark's colour is ${hex}, and that colour fills the mark and dominates the whole image.` + : `The mark is monochrome and stays monochrome.` + } Build the mark from two or three FLAT tonal planes of that one colour — a lit face, a slightly darker shaded side where a form turns, a darker base — with hard, clean boundaries between them. The mark is a SOLID filled shape, never an outline. Do not render it graphite, charcoal, grey or black unless that is the colour named above. Absolutely no gloss: no specular highlight, no white hotspot, no shiny plastic, no glassy sheen, no bevel, no inflated 3D bubble, no soft blended gradient. Crisp geometric vector edges, one thin cooler edge-light along the top-left edge only. Restrained, modern, engineered.`, + machined: (hex) => `Style: precision-machined hardware. The mark is milled from aluminium ${ + hex ? `anodized in ${hex}: that anodized colour covers the whole mark and dominates the image` : `left raw and unanodized, so it stays neutral metal` + }. Very fine brushed texture along the faces, hairline chamfers where faces meet, soft even diffuse lighting from directly above. The mark is a SOLID milled shape, never an outline, and the surrounding background stays empty — the graphite of this style belongs to hardware bodies, not to a mark that has its own colour. No specular highlight, no glossy sheen, no shiny plastic, no glassy bubble, no soft blended gradient, no cartoon bevel. Tight tolerances, technical, industrial-grade.`, + emissive: (hex, emission = emissionFor(hex)) => `Style: emissive dark instrument. The mark is a SOLID form that emits light: fill every solid area of the mark completely with the emitting material, and leave its cut-out counters empty. It is never a neon outline, never a hollow tube, never a thin glowing stroke tracing the edge of a shape that should be filled. ${ + emission.kind === 'own' + ? `The mark emits ${emission.hex} — its own colour, and no other. It must not emit white, near-white or any hue the mark does not have.` + : emission.kind === 'tint' + ? `The mark's own colour, ${emission.hex}, is too dark to give off light, so it emits a brighter, lighter tint of that same hue — the same colour raised until it can carry light, never a different hue and never plain white.` + : `The mark emits a clean neutral white light: its own colour is too dark and too close to grey to emit, so do not invent a hue for it.` + } It sits on nothing: the background is fully transparent, and the glow falls away into that transparency. No gloss, no specular highlight, no shiny plastic, no glassy bubble, no soft blended gradient over the whole form; the only light in the image is the mark itself. Crisp vector edges. It reads like a lit panel in a dark rack.`, +}; + const AGENTIC_MATERIALS: Record = { matte: { label: 'Agentic Matte', @@ -110,6 +216,12 @@ function agenticStyle(variant: keyof typeof AGENTIC_MATERIALS): StyleSpec { promptFor(icon) { return `${AGENTIC_FRAME}\n${paragraph}\nAccent colour: ${accentFor(icon)}. Use it as the dominant or accent colour as the style dictates.\n\n${subjectLine(icon)}`; }, + promptForBrand(brand) { + // Matte and machined take the hex as it is. Emissive cannot: a mark has + // to be bright enough to give off its own colour, so emissionFor decides + // between the colour itself, a lightened tint of it, and neutral white. + return `${BRAND_FRAME}\n${AGENTIC_BRAND_MATERIALS[variant]!(brand.hex)}\n\nThe mark: the ${brand.title} logo, exactly as shown in the reference image.`; + }, }; } diff --git a/src/icon.ts b/src/icon.ts index 2b6ed9a..d2f9349 100644 --- a/src/icon.ts +++ b/src/icon.ts @@ -91,12 +91,18 @@ export interface IconEntry { styles?: Record; } -/** Where one icon's files live in one style. `{size}` is the caller's to fill. */ +/** + * Where one icon's files live in one style. `{size}` is the caller's to fill. + * + * `made_by` is rule 8's vocabulary: `ai` for a drawn icon, `human` for a mark + * taken from its owner unchanged, and `both` for a mark re-rendered in a + * style's material — the geometry is the owner's, the surface is ours. + */ export interface StyleFiles { png: string; webp: string; svg?: string; - made_by: 'ai' | 'human'; + made_by: 'ai' | 'human' | 'both'; hex?: string; hex_source?: string; } diff --git a/test/icon-hq.test.ts b/test/icon-hq.test.ts index 7792909..c14c90b 100644 --- a/test/icon-hq.test.ts +++ b/test/icon-hq.test.ts @@ -96,9 +96,76 @@ describe('drawHq', () => { expect(report.calls).toBe(3); }); - it('refuses to draw a brand', async () => { + it('refuses to draw a brand for a style that only recolours marks', async () => { const { out, styleDir } = await setup(); - await expect(drawHq({ ...base(out, styleDir, async () => ({ png: PNG, tokens: 1 })), keys: ['github'] })).rejects.toThrow(/not a drawn icon/); + await expect(drawHq({ ...base(out, styleDir, async () => ({ png: PNG, tokens: 1 })), keys: ['github'] })).rejects.toThrow( + /github is a brand and hq does not restyle marks/, + ); + }); + + it('refuses a key that is in no part of the set', async () => { + const { out, styleDir } = await setup(); + await expect(drawHq({ ...base(out, styleDir, async () => ({ png: PNG, tokens: 1 })), keys: ['nope'] })).rejects.toThrow( + /not an icon in the set/, + ); + }); + + it('draws a brand for an agentic style, pinned to the owner mark and with no emoji reference', async () => { + const { out, styleDir } = await setup(); + await mkdir(join(out, 'svg'), { recursive: true }); + await writeFile(join(out, 'svg', 'github.svg'), ''); + const seen: ImageRequest[] = []; + const report = await drawHq({ + ...base(out, styleDir, async (r) => { seen.push(r); return { png: PNG, tokens: 1 }; }), + style: resolveStyle('agentic-emissive'), + keys: ['github'], + brandColors: new Map([['github', { hex: '#181717', source: 'simple-icons:github' }]]), + }); + expect(report.drawn).toEqual(['github']); + // The mark is the only reference: an emoji master beside it invites a redraw. + expect(seen[0]!.references).toHaveLength(1); + expect(seen[0]!.prompt).toContain('Reproduce its exact geometry'); + expect(seen[0]!.prompt).toContain('GitHub'); + // #181717 emits nothing and has no hue to raise, so emissive is told to + // light it neutrally rather than left to invent a colour GitHub does not own. + expect(seen[0]!.prompt).toContain('neutral white light'); + expect(seen[0]!.prompt).not.toContain('#181717'); + // The guard that protects drawn icons from looking like logos must not + // survive into a prompt whose whole subject is a logo. + expect(seen[0]!.prompt).not.toContain('never resemble a logo'); + }); + + it('names the hex in the material for a mark bright enough to carry it', async () => { + const { out, styleDir } = await setup(); + await mkdir(join(out, 'svg'), { recursive: true }); + await writeFile(join(out, 'svg', 'gitlab.svg'), ''); + const seen: ImageRequest[] = []; + for (const id of ['agentic-matte', 'agentic-machined', 'agentic-emissive']) { + await drawHq({ + ...base(out, styleDir, async (r) => { seen.push(r); return { png: PNG, tokens: 1 }; }), + style: resolveStyle(id), + keys: ['gitlab'], + force: true, + brandColors: new Map([['gitlab', { hex: '#FC6D26', source: 'simple-icons:gitlab' }]]), + }); + } + // The hex has to sit inside the material paragraph, not in a sentence + // beside it: six pilot marks came back graphite when it did not. + for (const request of seen) expect(request.prompt).toContain('#FC6D26'); + // Every style has to say the mark is filled: emissive drew neon outlines + // of hollow shapes until it did. + for (const request of seen) expect(request.prompt).toContain('SOLID'); + }); + + it('fails a brand cleanly when its mark is not on disk yet', async () => { + const { out, styleDir } = await setup(); + const report = await drawHq({ + ...base(out, styleDir, async () => ({ png: PNG, tokens: 1 })), + style: resolveStyle('agentic-matte'), + keys: ['github'], + }); + expect(report.drawn).toEqual([]); + expect(report.failed[0]!.error).toMatch(/no mark at/); }); it('guardCredits leaves other errors alone', async () => { @@ -127,6 +194,28 @@ describe('buildHq', () => { expect(existsSync(join(out, 'hq', 'webp', '64', 'mail.webp'))).toBe(true); }); + it('a drawn brand master wins over the flat recolour, and carries no svg', async () => { + const { out } = await setup(); + await mkdir(join(out, 'svg'), { recursive: true }); + await writeFile(join(out, 'svg', 'github.svg'), ''); + await writeFile(join(out, 'svg', 'slack.svg'), ''); + const style = resolveStyle('agentic-emissive'); + await mkdir(join(out, style.dir, 'master'), { recursive: true }); + await writeFile(join(out, style.dir, 'master', 'github.png'), await sharpSquare()); + + const entries = await buildHq({ + out, sizes: [16, 32], style, log: () => {}, render: () => PNG, simpleIconsHex: new Map([['github', '#181717']]), + }); + // github was drawn into the material: raster only, and `both` because the + // geometry is the owner's while the surface is ours. No `svg`, or a reader + // would show the flat mark and never the style. + expect(entries.get('github')).toMatchObject({ made_by: 'both', hex: '#181717', hex_source: 'simple-icons:github' }); + expect(entries.get('github')).not.toHaveProperty('svg'); + expect(existsSync(join(out, style.dir, 'webp', '64', 'github.webp'))).toBe(true); + // slack had no master, so it keeps the old behaviour for this style. + expect(entries.get('slack')).toMatchObject({ made_by: 'human', svg: `${style.dir}/svg/slack.svg` }); + }); + it('every Font Awesome brand has a documented colour', () => { for (const b of BRANDS.filter((b) => b.source === 'font-awesome')) { expect(FA_BRAND_COLORS[b.key]?.hex, b.key).toMatch(/^#[0-9A-F]{6}$/); @@ -151,6 +240,22 @@ describe('withHq', () => { }); }); +describe('emission', () => { + it('splits marks into emitting their colour, a tint of it, or neutral white', async () => { + const { emissionFor } = await import('../src/icon-styles.ts'); + // Bright enough to give off its own colour. + expect(emissionFor('#FC6D26')).toEqual({ kind: 'own', hex: '#FC6D26' }); + expect(emissionFor('#CB3837')).toEqual({ kind: 'own', hex: '#CB3837' }); + // Dark but still coloured: raise the hue rather than throw it away. + expect(emissionFor('#4A154B')).toEqual({ kind: 'tint', hex: '#4A154B' }); + expect(emissionFor('#002991')).toEqual({ kind: 'tint', hex: '#002991' }); + // Dark and grey: there is no hue to raise, so white is the honest answer. + expect(emissionFor('#181717')).toEqual({ kind: 'neutral' }); + expect(emissionFor('#000000')).toEqual({ kind: 'neutral' }); + expect(emissionFor(undefined)).toEqual({ kind: 'neutral' }); + }); +}); + describe('styles', () => { it('agentic takes no emoji reference and names its own material', () => { const mail = GENERIC.find((i) => i.key === 'mail')!;