diff --git a/scripts/verify-color-tiers.mjs b/scripts/verify-color-tiers.mjs new file mode 100644 index 0000000..1c0bbfd --- /dev/null +++ b/scripts/verify-color-tiers.mjs @@ -0,0 +1,58 @@ +// Renders the same buddy card under each of the four terminal capability tiers +// so reviewers can eyeball the actual output before merge. Pairs with the +// end-to-end assertions in src/__tests__/card-color.test.ts. +// +// Usage: npm run build && node scripts/verify-color-tiers.mjs + +import { renderCard } from '../dist/lib/card.js'; +import { totalXpForLevel } from '../dist/lib/leveling.js'; + +const TIERS = [ + { name: 'NO_COLOR', caps: { truecolor: false, ansi256: false, ansi16: false, noColor: true } }, + { name: 'ANSI-16', caps: { truecolor: false, ansi256: false, ansi16: true, noColor: false } }, + { name: 'ANSI-256', caps: { truecolor: false, ansi256: true, ansi16: false, noColor: false } }, + { name: 'truecolor', caps: { truecolor: true, ansi256: false, ansi16: false, noColor: false } }, +]; + +const companion = { + name: 'Steven', + personalityBio: 'a sample buddy used to verify each color tier.', + rarity: 'rare', + species: 'Cactus', + eye: '·', + hat: 'none', + shiny: false, + stats: { DEBUGGING: 50, PATIENCE: 40, CHAOS: 30, WISDOM: 20, SNARK: 10 }, + level: 25, + xp: totalXpForLevel(25), + mood: 'neutral', + availablePoints: 0, + hatchedAt: Date.now(), +}; + +const ESC = /\x1b\[[^m]*m/g; + +function describe(card) { + const escapes = card.match(ESC) ?? []; + const has24bit = card.includes('\x1b[38;2;'); + const has256 = card.includes('\x1b[38;5;'); + const has16 = /\x1b\[3[0-7]m/.test(card); + return { + escapeCount: escapes.length, + has24bit, + has256, + has16, + }; +} + +for (const { name, caps } of TIERS) { + const card = renderCard(companion, caps); + const stats = describe(card); + console.log('='.repeat(60)); + console.log(`tier: ${name}`); + console.log(`escapes emitted: ${stats.escapeCount}` + + ` | 24-bit: ${stats.has24bit} | 256-color: ${stats.has256} | 16-color: ${stats.has16}`); + console.log('-'.repeat(60)); + console.log(card); + console.log(); +} diff --git a/src/__tests__/__snapshots__/color-snapshot.test.ts.snap b/src/__tests__/__snapshots__/color-snapshot.test.ts.snap new file mode 100644 index 0000000..8ce5861 --- /dev/null +++ b/src/__tests__/__snapshots__/color-snapshot.test.ts.snap @@ -0,0 +1,31 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`color fixtures (snapshot contract) > common-cactus-lv1 renders to a stable ANSI string 1`] = `""`; + +exports[`color fixtures (snapshot contract) > common-robot-lv50-iron renders to a stable ANSI string 1`] = `""`; + +exports[`color fixtures (snapshot contract) > epic-cactus-lv1 renders to a stable ANSI string 1`] = `""`; + +exports[`color fixtures (snapshot contract) > epic-robot-lv50-diamond renders to a stable ANSI string 1`] = `""`; + +exports[`color fixtures (snapshot contract) > fallback-pegasus renders to a stable ANSI string 1`] = `""`; + +exports[`color fixtures (snapshot contract) > legendary-cactus-lv1 renders to a stable ANSI string 1`] = `""`; + +exports[`color fixtures (snapshot contract) > legendary-robot-lv50-aurum renders to a stable ANSI string 1`] = `""`; + +exports[`color fixtures (snapshot contract) > rare-cactus-lv1 renders to a stable ANSI string 1`] = `""`; + +exports[`color fixtures (snapshot contract) > rare-penguin-lv35-bridge renders to a stable ANSI string 1`] = `""`; + +exports[`color fixtures (snapshot contract) > rare-robot-lv50-gold renders to a stable ANSI string 1`] = `""`; + +exports[`color fixtures (snapshot contract) > uncommon-cactus-lv1 renders to a stable ANSI string 1`] = `""`; + +exports[`color fixtures (snapshot contract) > uncommon-octopus-lv10 renders to a stable ANSI string 1`] = `""`; + +exports[`color fixtures (snapshot contract) > uncommon-octopus-lv20 renders to a stable ANSI string 1`] = `""`; + +exports[`color fixtures (snapshot contract) > uncommon-octopus-lv30 renders to a stable ANSI string 1`] = `""`; + +exports[`color fixtures (snapshot contract) > uncommon-robot-lv50-copper renders to a stable ANSI string 1`] = `""`; diff --git a/src/__tests__/card-color.test.ts b/src/__tests__/card-color.test.ts new file mode 100644 index 0000000..eec305b --- /dev/null +++ b/src/__tests__/card-color.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from 'vitest'; +import { renderCard } from '../lib/card.js'; +import { colorFor, type TerminalCapabilities } from '../lib/color.js'; +import type { Companion } from '../lib/types.js'; + +function makeCompanion(overrides: Partial = {}): Companion { + return { + name: 'Testy', + personalityBio: 'A test buddy.', + rarity: 'rare', + species: 'Cactus', + eye: '·', + hat: 'none', + shiny: false, + stats: { DEBUGGING: 50, PATIENCE: 40, CHAOS: 30, WISDOM: 20, SNARK: 10 }, + level: 1, + xp: 0, + mood: 'neutral', + availablePoints: 0, + hatchedAt: Date.now(), + ...overrides, + }; +} + +describe('renderCard color integration', () => { + it('sprite lines are wrapped in colorFor escape', () => { + const companion = makeCompanion({ species: 'Cactus', rarity: 'rare', xp: 0 }); + const card = renderCard(companion); + const expectedColor = colorFor('Cactus', 'rare', 0); + // Guard: under NO_COLOR, colorFor returns '' and toContain('') is vacuously true. + // Fail loudly if the test env can't actually exercise the colorization path. + expect(expectedColor).not.toBe(''); + expect(card).toContain(expectedColor); + }); + + it('different rarities produce different color codes in card output', () => { + const common = renderCard(makeCompanion({ rarity: 'common' })); + const legendary = renderCard(makeCompanion({ rarity: 'legendary' })); + expect(common).not.toEqual(legendary); + }); + + it('Lv 1 vs Lv 50 same buddy produce different color codes', () => { + const lv1 = renderCard(makeCompanion({ level: 1, xp: 0 })); + const lv50 = renderCard(makeCompanion({ level: 50, xp: 100000 })); + expect(lv1).not.toEqual(lv50); + }); +}); + +// End-to-end capability tier fallback. Steven's PR #126 review asked us to +// verify NO_COLOR / ANSI-16 / ANSI-256 / truecolor before merge: these tests +// drive renderCard with an explicit TerminalCapabilities and assert the *shape* +// of the escapes in the actual card output — closing the loop the unit tests +// in color.test.ts leave open (those only assert colorFor's return value, not +// what renderCard ultimately emits). +describe('renderCard capability tier fallback (end-to-end)', () => { + const TRUECOLOR: TerminalCapabilities = { truecolor: true, ansi256: false, ansi16: false, noColor: false }; + const ANSI256: TerminalCapabilities = { truecolor: false, ansi256: true, ansi16: false, noColor: false }; + const ANSI16: TerminalCapabilities = { truecolor: false, ansi256: false, ansi16: true, noColor: false }; + const NOCOLOR: TerminalCapabilities = { truecolor: false, ansi256: false, ansi16: false, noColor: true }; + + it('NO_COLOR → card output contains zero ANSI escape sequences', () => { + const card = renderCard(makeCompanion({ rarity: 'rare' }), NOCOLOR); + expect(card).not.toMatch(/\x1b\[/); + }); + + it('ansi16 → emits only 16-color escapes (no 38;5 or 38;2 sequences)', () => { + const card = renderCard(makeCompanion({ rarity: 'rare' }), ANSI16); + expect(card).not.toContain('38;5'); + expect(card).not.toContain('38;2'); + expect(card).toMatch(/\x1b\[3[0-7]m/); + }); + + it('ansi256 → emits 256-color escapes and no truecolor 38;2 sequences', () => { + const card = renderCard(makeCompanion({ rarity: 'rare' }), ANSI256); + expect(card).toMatch(/\x1b\[38;5;\d+m/); + expect(card).not.toContain('38;2'); + }); + + it('truecolor → emits 24-bit truecolor escapes', () => { + const card = renderCard(makeCompanion({ rarity: 'rare' }), TRUECOLOR); + expect(card).toMatch(/\x1b\[38;2;\d+;\d+;\d+m/); + }); +}); diff --git a/src/__tests__/color-snapshot.test.ts b/src/__tests__/color-snapshot.test.ts new file mode 100644 index 0000000..9230fcb --- /dev/null +++ b/src/__tests__/color-snapshot.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { colorFor, type TerminalCapabilities } from '../lib/color.js'; +import { totalXpForLevel } from '../lib/leveling.js'; + +const TRUECOLOR: TerminalCapabilities = { + truecolor: true, ansi256: false, ansi16: false, noColor: false, +}; + +interface Fixture { + species: string; + rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'; + level: number; + label: string; +} + +const FIXTURES: Fixture[] = [ + { species: 'Cactus', rarity: 'common', level: 1, label: 'common-cactus-lv1' }, + { species: 'Cactus', rarity: 'uncommon', level: 1, label: 'uncommon-cactus-lv1' }, + { species: 'Cactus', rarity: 'rare', level: 1, label: 'rare-cactus-lv1' }, + { species: 'Cactus', rarity: 'epic', level: 1, label: 'epic-cactus-lv1' }, + { species: 'Cactus', rarity: 'legendary', level: 1, label: 'legendary-cactus-lv1' }, + + { species: 'Octopus', rarity: 'uncommon', level: 10, label: 'uncommon-octopus-lv10' }, + { species: 'Octopus', rarity: 'uncommon', level: 20, label: 'uncommon-octopus-lv20' }, + { species: 'Octopus', rarity: 'uncommon', level: 30, label: 'uncommon-octopus-lv30' }, + + { species: 'Penguin', rarity: 'rare', level: 35, label: 'rare-penguin-lv35-bridge' }, + + { species: 'Robot', rarity: 'common', level: 50, label: 'common-robot-lv50-iron' }, + { species: 'Robot', rarity: 'uncommon', level: 50, label: 'uncommon-robot-lv50-copper' }, + { species: 'Robot', rarity: 'rare', level: 50, label: 'rare-robot-lv50-gold' }, + { species: 'Robot', rarity: 'epic', level: 50, label: 'epic-robot-lv50-diamond' }, + { species: 'Robot', rarity: 'legendary', level: 50, label: 'legendary-robot-lv50-aurum' }, + + { species: 'Pegasus', rarity: 'uncommon', level: 1, label: 'fallback-pegasus' }, +]; + +describe('color fixtures (snapshot contract)', () => { + for (const fx of FIXTURES) { + it(`${fx.label} renders to a stable ANSI string`, () => { + const xp = fx.level === 1 ? 0 : totalXpForLevel(fx.level); + const escape = colorFor(fx.species, fx.rarity, xp, TRUECOLOR); + expect(escape).toMatchSnapshot(); + }); + } +}); diff --git a/src/__tests__/color.test.ts b/src/__tests__/color.test.ts new file mode 100644 index 0000000..4af3ae1 --- /dev/null +++ b/src/__tests__/color.test.ts @@ -0,0 +1,429 @@ +import { describe, it, expect } from 'vitest'; +import type { RGB, TerminalCapabilities } from '../lib/color.js'; +import { NEUTRAL_GRAY } from '../lib/color.js'; +import { SPECIES_PALETTES, FALLBACK_SPECIES_PALETTE } from '../lib/color.js'; +import { RARITY_METALS, RARITY_SATURATION } from '../lib/color.js'; +import { clamp, lerpRGB } from '../lib/color.js'; +import { rampPosition } from '../lib/color.js'; +import { interpolateAnchors } from '../lib/color.js'; +import { applySaturationTint } from '../lib/color.js'; +import { computeRGB } from '../lib/color.js'; +import { detectCapabilities } from '../lib/color.js'; +import { rgbTo256 } from '../lib/color.js'; +import { rgbToAnsi16 } from '../lib/color.js'; +import { colorFor } from '../lib/color.js'; +import { SPECIES_LIST } from '../lib/species.js'; +import { RARITIES } from '../lib/types.js'; +import { totalXpForLevel } from '../lib/leveling.js'; + +describe('color module — types and constants', () => { + it('exports NEUTRAL_GRAY as RGB [128, 128, 128]', () => { + expect(NEUTRAL_GRAY).toEqual([128, 128, 128]); + }); + + it('RGB type accepts a 3-tuple of numbers', () => { + const sample: RGB = [10, 20, 30]; + expect(sample).toHaveLength(3); + }); + + it('TerminalCapabilities type has the four boolean flags', () => { + const caps: TerminalCapabilities = { + truecolor: true, + ansi256: false, + ansi16: false, + noColor: false, + }; + expect(caps.truecolor).toBe(true); + }); +}); + +describe('SPECIES_PALETTES', () => { + it('has an entry for every species in SPECIES_LIST', () => { + for (const species of SPECIES_LIST) { + expect(SPECIES_PALETTES[species], `missing palette for ${species}`).toBeDefined(); + } + }); + + it('has 21 entries total', () => { + expect(Object.keys(SPECIES_PALETTES)).toHaveLength(21); + }); + + it('every palette has exactly 4 RGB anchors with values in [0, 255]', () => { + for (const [species, anchors] of Object.entries(SPECIES_PALETTES)) { + expect(anchors, `${species} should have 4 anchors`).toHaveLength(4); + for (const [r, g, b] of anchors) { + expect(r).toBeGreaterThanOrEqual(0); + expect(r).toBeLessThanOrEqual(255); + expect(g).toBeGreaterThanOrEqual(0); + expect(g).toBeLessThanOrEqual(255); + expect(b).toBeGreaterThanOrEqual(0); + expect(b).toBeLessThanOrEqual(255); + } + } + }); + + it('FALLBACK_SPECIES_PALETTE has 4 RGB anchors', () => { + expect(FALLBACK_SPECIES_PALETTE).toHaveLength(4); + }); +}); + +describe('RARITY_METALS and RARITY_SATURATION', () => { + it('RARITY_METALS has an entry for every rarity', () => { + for (const rarity of RARITIES) { + expect(RARITY_METALS[rarity], `missing metals for ${rarity}`).toBeDefined(); + } + }); + + it('every rarity has exactly 2 metal anchors', () => { + for (const rarity of RARITIES) { + expect(RARITY_METALS[rarity]).toHaveLength(2); + } + }); + + it('RARITY_SATURATION values match the spec table', () => { + expect(RARITY_SATURATION.common).toBe(0.85); + expect(RARITY_SATURATION.uncommon).toBe(1.00); + expect(RARITY_SATURATION.rare).toBe(1.05); + expect(RARITY_SATURATION.epic).toBe(1.12); + expect(RARITY_SATURATION.legendary).toBe(1.20); + }); +}); + +describe('clamp', () => { + it('returns value when within range', () => { + expect(clamp(50, 0, 100)).toBe(50); + }); + it('returns min when below', () => { + expect(clamp(-10, 0, 100)).toBe(0); + }); + it('returns max when above', () => { + expect(clamp(200, 0, 100)).toBe(100); + }); +}); + +describe('lerpRGB', () => { + it('returns a at t=0', () => { + expect(lerpRGB([10, 20, 30], [100, 200, 250], 0)).toEqual([10, 20, 30]); + }); + it('returns b at t=1', () => { + expect(lerpRGB([10, 20, 30], [100, 200, 250], 1)).toEqual([100, 200, 250]); + }); + it('returns midpoint at t=0.5', () => { + expect(lerpRGB([0, 0, 0], [200, 200, 200], 0.5)).toEqual([100, 100, 100]); + }); + it('rounds to integer channels', () => { + const result = lerpRGB([0, 0, 0], [3, 3, 3], 0.5); + expect(result[0]).toBe(2); // 1.5 rounds to 2 + expect(Number.isInteger(result[0])).toBe(true); + }); +}); + +describe('rampPosition', () => { + it('returns 0 at totalXp=0 (Lv 1, no progress)', () => { + expect(rampPosition(0)).toBe(0); + }); + + it('returns 1.0 at total XP for Lv 50', () => { + expect(rampPosition(totalXpForLevel(50))).toBe(1.0); + }); + + it('returns 1.0 for XP beyond max level', () => { + expect(rampPosition(totalXpForLevel(50) + 10000)).toBe(1.0); + }); + + it('returns ~0.6 at Lv 30 with zero progress (species → metal bridge entry)', () => { + const result = rampPosition(totalXpForLevel(30)); + // (30 - 1 + 0) / 49 = 0.5918... + expect(result).toBeCloseTo(29 / 49, 3); + }); + + it('is monotonically increasing across the level range', () => { + let prev = -1; + for (let lvl = 1; lvl <= 50; lvl++) { + const p = rampPosition(totalXpForLevel(lvl)); + expect(p, `p at Lv ${lvl}`).toBeGreaterThanOrEqual(prev); + prev = p; + } + }); +}); + +describe('interpolateAnchors', () => { + const anchors: RGB[] = [ + [0, 0, 0], + [50, 100, 150], + [100, 200, 250], + [255, 255, 255], + ]; + const breakpoints = [0, 0.3, 0.7, 1.0]; + + it('returns first anchor at p=0', () => { + expect(interpolateAnchors(anchors, breakpoints, 0)).toEqual([0, 0, 0]); + }); + + it('returns exact anchor at internal breakpoint', () => { + expect(interpolateAnchors(anchors, breakpoints, 0.3)).toEqual([50, 100, 150]); + }); + + it('returns last anchor at p=1', () => { + expect(interpolateAnchors(anchors, breakpoints, 1.0)).toEqual([255, 255, 255]); + }); + + it('interpolates linearly within a segment (p=0.5 between breakpoints 0.3 and 0.7)', () => { + // local t = (0.5 - 0.3) / (0.7 - 0.3) = 0.5; midway between [50,100,150] and [100,200,250] + expect(interpolateAnchors(anchors, breakpoints, 0.5)).toEqual([75, 150, 200]); + }); +}); + +describe('applySaturationTint', () => { + it('factor=1.0 is identity', () => { + expect(applySaturationTint([200, 50, 100], 1.0)).toEqual([200, 50, 100]); + }); + + it('factor=0 collapses to neutral gray', () => { + expect(applySaturationTint([200, 50, 100], 0)).toEqual([128, 128, 128]); + }); + + it('factor=0.85 (common) moves toward gray', () => { + // r: 128 + (200-128)*0.85 = 128 + 61.2 → 189 + expect(applySaturationTint([200, 200, 200], 0.85)).toEqual([189, 189, 189]); + }); + + it('factor=1.2 extrapolates away from gray and clamps to [0, 255]', () => { + // r: 128 + (250-128)*1.2 = 128 + 146.4 → 274 → clamped to 255 + const result = applySaturationTint([250, 250, 250], 1.2); + expect(result[0]).toBe(255); + expect(result[1]).toBe(255); + expect(result[2]).toBe(255); + }); + + it('factor=1.2 clamps to 0 when extrapolating dark', () => { + // r: 128 + (10-128)*1.2 = 128 - 141.6 = -13.6 → clamped to 0 + const result = applySaturationTint([10, 10, 10], 1.2); + expect(result).toEqual([0, 0, 0]); + }); +}); + +describe('computeRGB', () => { + it('returns the first species anchor (tinted) at Lv 1 totalXp=0', () => { + // Cactus anchor 0 = [0x9b, 0x87, 0x57] = [155, 135, 87]. Uncommon factor = 1.0 (identity). + expect(computeRGB('Cactus', 'uncommon', 0)).toEqual([155, 135, 87]); + }); + + it('common rarity mutes the species color', () => { + // Cactus anchor 0 tinted by 0.85: each channel pulled toward 128. + // r: 128 + (155-128)*0.85 = 128 + 22.95 → 151 + // g: 128 + (135-128)*0.85 = 128 + 5.95 → 134 + // b: 128 + (87-128)*0.85 = 128 + -34.85 → 93 + expect(computeRGB('Cactus', 'common', 0)).toEqual([151, 134, 93]); + }); + + it('legendary rarity boosts saturation', () => { + // Cactus anchor 0 tinted by 1.2: + // r: 128 + (155-128)*1.2 = 128 + 32.4 → 160 + // g: 128 + (135-128)*1.2 = 128 + 8.4 → 136 + // b: 128 + (87-128)*1.2 = 128 + -49.2 → 79 + expect(computeRGB('Cactus', 'legendary', 0)).toEqual([160, 136, 79]); + }); + + it('falls back to FALLBACK_SPECIES_PALETTE for unknown species', () => { + const result = computeRGB('Pegasus', 'uncommon', 0); // not a real species + expect(result).toEqual([0x66, 0x66, 0x66]); // fallback anchor 0 + }); + + it('falls back to common metals/saturation for unknown rarity (no crash)', () => { + // Runtime safety: TS narrows callers to valid Rarity, but JSON deserialization or + // schema drift could feed an unknown string. Should fall back to common, not throw. + const unknown = computeRGB('Cactus', 'mythic' as never, 0); + const commonAt0 = computeRGB('Cactus', 'common', 0); + expect(unknown).toEqual(commonAt0); + }); + + it('produces the rarity metal 1 color (tinted) at Lv 40', () => { + // p = (40-1)/49 = 0.7959, which falls in the species4→metal1 bridge segment [0.6, 0.8]. + // At p=0.7959, localT = (0.7959-0.6) / (0.8-0.6) = 0.9796 — very close to metal1. + // For rare Cactus: species[3]=[0xe8,0xb0,0x4a]=[232,176,74], metal1=[0xc8,0x9a,0x2e]=[200,154,46] + // lerp at t=0.9796: r=232+(200-232)*0.9796≈201, g=176+(154-176)*0.9796≈155, b=74+(46-74)*0.9796≈47 + // Then tint by rare (1.05): r=128+(201-128)*1.05=204.65→205, g=128+(155-128)*1.05=156.35→156, b=128+(47-128)*1.05=43.05→43 + expect(computeRGB('Cactus', 'rare', totalXpForLevel(40))).toEqual([205, 155, 43]); + }); + + it('returns the final metal anchor (tinted) at Lv 50', () => { + // p = 1.0 (level >= 50 short-circuit). interpolateAnchors returns last anchor = metal2. + // For rare Cactus: metal2 = [0xf4, 0xc9, 0x48] = [244, 201, 72]. Tint by rare (1.05): + // r: 128 + (244-128)*1.05 = 128 + 121.8 → 250 + // g: 128 + (201-128)*1.05 = 128 + 76.65 → 205 + // b: 128 + (72-128)*1.05 = 128 - 58.8 → 69 + expect(computeRGB('Cactus', 'rare', totalXpForLevel(50))).toEqual([250, 205, 69]); + }); +}); + +describe('detectCapabilities', () => { + // Each test passes an explicit env to avoid global mutation. + it('NO_COLOR defined → noColor true (highest priority)', () => { + const caps = detectCapabilities({ NO_COLOR: '1', COLORTERM: 'truecolor' }); + expect(caps.noColor).toBe(true); + expect(caps.truecolor).toBe(false); + }); + + it('NO_COLOR empty string still triggers no-color (per spec convention)', () => { + const caps = detectCapabilities({ NO_COLOR: '' }); + expect(caps.noColor).toBe(true); + }); + + it('COLORTERM=truecolor → truecolor', () => { + const caps = detectCapabilities({ COLORTERM: 'truecolor' }); + expect(caps.truecolor).toBe(true); + }); + + it('COLORTERM=24bit → truecolor', () => { + const caps = detectCapabilities({ COLORTERM: '24bit' }); + expect(caps.truecolor).toBe(true); + }); + + it('WT_SESSION set → truecolor (Windows Terminal)', () => { + const caps = detectCapabilities({ WT_SESSION: 'some-guid' }); + expect(caps.truecolor).toBe(true); + }); + + it("TERM_PROGRAM=iTerm.app → truecolor", () => { + const caps = detectCapabilities({ TERM_PROGRAM: 'iTerm.app' }); + expect(caps.truecolor).toBe(true); + }); + + it("TERM_PROGRAM=vscode → truecolor", () => { + const caps = detectCapabilities({ TERM_PROGRAM: 'vscode' }); + expect(caps.truecolor).toBe(true); + }); + + it('TERM ending in -truecolor → truecolor', () => { + const caps = detectCapabilities({ TERM: 'xterm-truecolor' }); + expect(caps.truecolor).toBe(true); + }); + + it('TERM ending in -direct → truecolor', () => { + const caps = detectCapabilities({ TERM: 'xterm-direct' }); + expect(caps.truecolor).toBe(true); + }); + + it('TERM ending in -256color → ansi256', () => { + const caps = detectCapabilities({ TERM: 'xterm-256color' }); + expect(caps.ansi256).toBe(true); + expect(caps.truecolor).toBe(false); + }); + + it('plain TERM=xterm → ansi16', () => { + const caps = detectCapabilities({ TERM: 'xterm' }); + expect(caps.ansi16).toBe(true); + }); + + it('empty env → ansi16 fallback', () => { + const caps = detectCapabilities({}); + expect(caps.ansi16).toBe(true); + }); +}); + +describe('rgbTo256', () => { + it('maps pure black to 16 (start of 6×6×6 cube)', () => { + expect(rgbTo256([0, 0, 0])).toBe(16); + }); + + it('maps pure white to 231 (end of 6×6×6 cube)', () => { + expect(rgbTo256([255, 255, 255])).toBe(231); + }); + + it('maps pure red to 196 (16 + 36*5 + 0 + 0)', () => { + expect(rgbTo256([255, 0, 0])).toBe(196); + }); + + it('maps pure green to 46 (16 + 0 + 6*5 + 0)', () => { + expect(rgbTo256([0, 255, 0])).toBe(46); + }); + + it('maps pure blue to 21 (16 + 0 + 0 + 5)', () => { + expect(rgbTo256([0, 0, 255])).toBe(21); + }); + + it('returns a value in [16, 231]', () => { + for (const [r, g, b] of [[100, 50, 200], [10, 200, 30], [128, 128, 128]] as RGB[]) { + const idx = rgbTo256([r, g, b]); + expect(idx).toBeGreaterThanOrEqual(16); + expect(idx).toBeLessThanOrEqual(231); + } + }); +}); + +describe('rgbToAnsi16', () => { + it('maps pure red to ANSI 31 (red)', () => { + expect(rgbToAnsi16([255, 0, 0])).toBe('\x1b[31m'); + }); + it('maps pure green to ANSI 32 (green)', () => { + expect(rgbToAnsi16([0, 255, 0])).toBe('\x1b[32m'); + }); + it('maps pure blue to ANSI 34 (blue)', () => { + expect(rgbToAnsi16([0, 0, 255])).toBe('\x1b[34m'); + }); + it('maps pure yellow (R+G) to ANSI 33 (yellow)', () => { + expect(rgbToAnsi16([255, 255, 0])).toBe('\x1b[33m'); + }); + it('maps pure cyan (G+B) to ANSI 36 (cyan)', () => { + expect(rgbToAnsi16([0, 255, 255])).toBe('\x1b[36m'); + }); + it('maps pure magenta (R+B) to ANSI 35 (magenta)', () => { + expect(rgbToAnsi16([255, 0, 255])).toBe('\x1b[35m'); + }); + it('maps near-white to ANSI 37 (white)', () => { + expect(rgbToAnsi16([240, 240, 240])).toBe('\x1b[37m'); + }); + it('maps near-black to ANSI 30 (black)', () => { + expect(rgbToAnsi16([10, 10, 10])).toBe('\x1b[30m'); + }); +}); + +describe('colorFor (public API)', () => { + const truecolor: TerminalCapabilities = { truecolor: true, ansi256: false, ansi16: false, noColor: false }; + const ansi256: TerminalCapabilities = { truecolor: false, ansi256: true, ansi16: false, noColor: false }; + const ansi16: TerminalCapabilities = { truecolor: false, ansi256: false, ansi16: true, noColor: false }; + const noColor: TerminalCapabilities = { truecolor: false, ansi256: false, ansi16: false, noColor: true }; + + it('returns empty string when NO_COLOR', () => { + expect(colorFor('Cactus', 'rare', 0, noColor)).toBe(''); + }); + + it('emits truecolor escape when truecolor', () => { + // Cactus uncommon Lv 1 = [155, 135, 87], no bold (uncommon). + expect(colorFor('Cactus', 'uncommon', 0, truecolor)).toBe('\x1b[38;2;155;135;87m'); + }); + + it('prepends bold escape for Rare buddies', () => { + expect(colorFor('Cactus', 'rare', 0, truecolor)).toMatch(/^\x1b\[1m\x1b\[38;2;/); + }); + + it('prepends bold escape for Epic buddies', () => { + expect(colorFor('Cactus', 'epic', 0, truecolor)).toMatch(/^\x1b\[1m/); + }); + + it('prepends bold escape for Legendary buddies', () => { + expect(colorFor('Cactus', 'legendary', 0, truecolor)).toMatch(/^\x1b\[1m/); + }); + + it('does NOT prepend bold for Common', () => { + expect(colorFor('Cactus', 'common', 0, truecolor).startsWith('\x1b[1m')).toBe(false); + }); + + it('does NOT prepend bold for Uncommon', () => { + expect(colorFor('Cactus', 'uncommon', 0, truecolor).startsWith('\x1b[1m')).toBe(false); + }); + + it('emits 256-color escape when ansi256', () => { + expect(colorFor('Cactus', 'uncommon', 0, ansi256)).toMatch(/^\x1b\[38;5;\d+m$/); + }); + + it('emits ANSI 16-color escape when ansi16', () => { + expect(colorFor('Cactus', 'uncommon', 0, ansi16)).toMatch(/^\x1b\[3[0-7]m$/); + }); + + it('returns empty string when no capability flags are set', () => { + const none: TerminalCapabilities = { truecolor: false, ansi256: false, ansi16: false, noColor: false }; + expect(colorFor('Cactus', 'rare', 0, none)).toBe(''); + }); +}); diff --git a/src/__tests__/share-color.test.ts b/src/__tests__/share-color.test.ts new file mode 100644 index 0000000..516c9bb --- /dev/null +++ b/src/__tests__/share-color.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest'; +import { renderShareHtml } from '../lib/share.js'; +import { computeRGB } from '../lib/color.js'; +import type { Companion } from '../lib/types.js'; + +function makeCompanion(overrides: Partial = {}): Companion { + return { + name: 'Testy', + personalityBio: 'A test buddy.', + rarity: 'rare', + species: 'Cactus', + eye: '·', + hat: 'none', + shiny: false, + stats: { DEBUGGING: 50, PATIENCE: 40, CHAOS: 30, WISDOM: 20, SNARK: 10 }, + level: 1, + xp: 0, + mood: 'neutral', + availablePoints: 0, + hatchedAt: Date.now(), + ...overrides, + }; +} + +describe('renderShareHtml color', () => { + it('uses computeRGB output as inline sprite color', () => { + const companion = makeCompanion({ species: 'Cactus', rarity: 'rare', xp: 0 }); + const html = renderShareHtml(companion); + const [r, g, b] = computeRGB('Cactus', 'rare', 0); + expect(html).toContain(`rgb(${r}, ${g}, ${b})`); + }); + + it('applies bold weight for Rare buddies', () => { + const html = renderShareHtml(makeCompanion({ rarity: 'rare' })); + expect(html).toMatch(/font-weight:\s*bold/); + }); + + it('applies bold weight for Epic buddies', () => { + const html = renderShareHtml(makeCompanion({ rarity: 'epic' })); + expect(html).toMatch(/font-weight:\s*bold/); + }); + + it('applies bold weight for Legendary buddies', () => { + const html = renderShareHtml(makeCompanion({ rarity: 'legendary' })); + expect(html).toMatch(/font-weight:\s*bold/); + }); + + it('does NOT apply bold weight for Common', () => { + const html = renderShareHtml(makeCompanion({ rarity: 'common' })); + expect(html).not.toMatch(/font-weight:\s*bold/); + }); + + it('does NOT apply bold weight for Uncommon', () => { + const html = renderShareHtml(makeCompanion({ rarity: 'uncommon' })); + expect(html).not.toMatch(/font-weight:\s*bold/); + }); + + it('Lv 1 and Lv 50 same buddy produce different inline colors', () => { + const lv1 = renderShareHtml(makeCompanion({ species: 'Cactus', rarity: 'uncommon', level: 1, xp: 0 })); + const lv50 = renderShareHtml(makeCompanion({ species: 'Cactus', rarity: 'uncommon', level: 50, xp: 100000 })); + const matchColor = (html: string) => html.match(/rgb\(\d+,\s*\d+,\s*\d+\)/)?.[0]; + expect(matchColor(lv1)).not.toEqual(matchColor(lv50)); + }); +}); diff --git a/src/lib/card.ts b/src/lib/card.ts index 322e0a4..863a989 100644 --- a/src/lib/card.ts +++ b/src/lib/card.ts @@ -4,11 +4,17 @@ import { renderSprite } from './species.js'; import { type Companion, STAT_NAMES, RARITY_STARS } from './types.js'; import { statBar } from './rng.js'; import { levelProgress } from './leveling.js'; +import { colorFor, type TerminalCapabilities } from './color.js'; +import { RESET } from './ansi.js'; /** * Render a bordered ASCII stat card for a companion. + * + * `caps` is forwarded to `colorFor` so tests (and any future caller that + * needs to force a tier) can drive deterministic output. In production + * callers omit it and the cached env-detected capabilities are used. */ -export function renderCard(companion: Companion): string { +export function renderCard(companion: Companion, caps?: TerminalCapabilities): string { const art = renderSprite(companion); const stars = RARITY_STARS[companion.rarity]; const statLines = STAT_NAMES.map(s => statBar(s, companion.stats[s])); @@ -41,11 +47,21 @@ export function renderCard(companion: Companion): string { if (cur) bioLines.push(ln(' ' + cur)); } + const spriteColor = colorFor(companion.species, companion.rarity, companion.xp, caps); + const spriteReset = spriteColor ? RESET : ''; + const coloredArt = art.map(l => { + const padded = ln(l); + const prefix = '| '; + const suffix = ' |'; + const artInner = padded.slice(prefix.length, padded.length - suffix.length); + return `${prefix}${spriteColor}${artInner}${spriteReset}${suffix}`; + }); + return [ topBorder, headerLine, emptyLine, - ...art.map(l => ln(l)), + ...coloredArt, emptyLine, ln(companion.name), ...(bioLines.length > 0 ? [emptyLine, ...bioLines] : []), diff --git a/src/lib/color.ts b/src/lib/color.ts new file mode 100644 index 0000000..dc1e766 --- /dev/null +++ b/src/lib/color.ts @@ -0,0 +1,246 @@ +// src/lib/color.ts — buddy color progression (species × rarity × XP → ANSI escape) + +import type { Rarity } from './types.js'; +import { levelProgress } from './leveling.js'; + +export type RGB = readonly [number, number, number]; + +export interface TerminalCapabilities { + truecolor: boolean; + ansi256: boolean; + ansi16: boolean; + noColor: boolean; +} + +export const NEUTRAL_GRAY: RGB = [128, 128, 128]; + +// 21 species × 4 RGB anchors. First-cut shades from the design spec — tunable. +// Anchor 0 sits at Lv 1 (p=0.0), Anchor 3 sits at Lv 30 (p=0.6). Between +// Lv 30 and Lv 40 the color bridges into the rarity's first metal anchor. +export const SPECIES_PALETTES: Record = { + 'Void Cat': [[0x1a, 0x1a, 0x2a], [0x4a, 0x3a, 0x6e], [0xc3, 0x3a, 0x8e], [0xd6, 0xd6, 0xf0]], + 'Rust Hound': [[0xa0, 0x4a, 0x2a], [0xd4, 0x4a, 0x2e], [0xd6, 0x8a, 0x3e], [0xb8, 0x7a, 0x4a]], + 'Data Drake': [[0x5f, 0xbb, 0x33], [0x4a, 0xd6, 0xc2], [0xe8, 0x3a, 0x9c], [0x9c, 0x3a, 0xff]], + 'Log Golem': [[0x5e, 0x48, 0x36], [0x5a, 0x7a, 0x3a], [0x7a, 0x7a, 0x7a], [0x8a, 0x9a, 0x6e]], + 'Cache Crow': [[0x2a, 0x2a, 0x2a], [0x6a, 0x6a, 0x76], [0x4a, 0x5a, 0xa8], [0xd6, 0xd6, 0xe6]], + 'Shell Turtle': [[0x6e, 0x52, 0x36], [0x5a, 0x7a, 0x3a], [0x2e, 0x7a, 0x5a], [0xd6, 0x8a, 0x3e]], + 'Duck': [[0x5a, 0x7a, 0x4a], [0x4a, 0x8a, 0x9a], [0xd6, 0x8a, 0x3a], [0xf4, 0xc9, 0x48]], + 'Goose': [[0xaa, 0xa9, 0xa3], [0x6a, 0x8a, 0xa8], [0x4a, 0x8a, 0x99], [0x7e, 0xc9, 0xc6]], + 'Blob': [[0x5f, 0xbb, 0x33], [0xf4, 0xc9, 0x48], [0xe8, 0x3a, 0x9c], [0x9c, 0x3a, 0xff]], + 'Octopus': [[0x3d, 0x2a, 0x5a], [0x5d, 0x4c, 0xad], [0x3d, 0x8a, 0xd6], [0x3e, 0xd6, 0xc2]], + 'Owl': [[0x5d, 0x4c, 0xad], [0x2a, 0x3a, 0x6e], [0xd6, 0xd4, 0xa6], [0xe8, 0xb0, 0x4a]], + 'Penguin': [[0xd4, 0xe4, 0xeb], [0x5d, 0x9c, 0xd6], [0x4e, 0xc5, 0xb9], [0x6c, 0xd9, 0x9a]], + 'Snail': [[0xaa, 0xa9, 0xa3], [0x5a, 0x7a, 0x4a], [0xd4, 0xa6, 0xb9], [0xcf, 0xd9, 0xd4]], + 'Ghost': [[0xaa, 0xa9, 0xa3], [0x6a, 0x8a, 0xa8], [0xc4, 0xe4, 0xe6], [0xf0, 0xf0, 0xf0]], + 'Axolotl': [[0xd6, 0x8a, 0x8a], [0xe9, 0x6a, 0x5a], [0xf4, 0xb6, 0xc2], [0xb6, 0xe4, 0xc2]], + 'Capybara': [[0x8a, 0x6a, 0x4a], [0xd6, 0x8a, 0x4a], [0xe8, 0xc4, 0x6a], [0x8a, 0xa6, 0x6e]], + 'Cactus': [[0x9b, 0x87, 0x57], [0x5a, 0x8a, 0x3a], [0xc7, 0x5d, 0x8a], [0xe8, 0xb0, 0x4a]], + 'Robot': [[0x5a, 0x5a, 0x66], [0x3a, 0x8a, 0xa4], [0x5f, 0xbb, 0x33], [0xe8, 0x44, 0x3e]], + 'Rabbit': [[0xf4, 0xb6, 0xc2], [0xf4, 0xe6, 0xc4], [0xe8, 0xb0, 0x6f], [0xf6, 0xf6, 0xf4]], + 'Mushroom': [[0x5e, 0x48, 0x36], [0x8b, 0x6d, 0x4b], [0xc3, 0x3a, 0x2e], [0xe8, 0xb0, 0x6f]], + 'Chonk': [[0xe6, 0xd6, 0xb4], [0xd6, 0x8a, 0x4a], [0xc4, 0x84, 0x3e], [0x6e, 0x4a, 0x2a]], +}; + +// Defensive fallback when an unknown species is encountered (should not happen in +// practice — every Companion has a species from SPECIES_LIST — but avoids throws). +// Generic neutral ramp: gray → blue → green → amber. +export const FALLBACK_SPECIES_PALETTE: readonly [RGB, RGB, RGB, RGB] = [ + [0x66, 0x66, 0x66], + [0x4a, 0x6a, 0xa8], + [0x4a, 0xa8, 0x6a], + [0xd6, 0xa8, 0x4a], +]; + +// Tier-break rarity ladder. Common/Uncommon get utilitarian metals (Iron, Copper); +// the visible break to precious materials happens at Rare ("rare should mean rare"). +export const RARITY_METALS: Record = { + common: [[0x6a, 0x6a, 0x6e], [0x8a, 0x8a, 0x8e]], // Iron → Polished Iron + uncommon: [[0xa8, 0x6a, 0x3a], [0xb8, 0x8a, 0x5e]], // Copper → Patina Copper + rare: [[0xc8, 0x9a, 0x2e], [0xf4, 0xc9, 0x48]], // Gold I → Gold II (the jump) + epic: [[0x8a, 0xcd, 0xd9], [0xdc, 0xee, 0xf4]], // Diamond → Iridescent + legendary: [[0xca, 0xbc, 0x94], [0xf4, 0xee, 0xdc]], // Aurum → Aurum Sheen +}; + +// Applied uniformly across species AND metal segments. Common buddies render +// slightly muted, legendary buddies slightly extra-saturated — rarity is +// readable from Lv 1 through Lv 50. +export const RARITY_SATURATION: Record = { + common: 0.85, + uncommon: 1.00, + rare: 1.05, + epic: 1.12, + legendary: 1.20, +}; + +export function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +export function lerpRGB(a: RGB, b: RGB, t: number): RGB { + return [ + Math.round(a[0] + (b[0] - a[0]) * t), + Math.round(a[1] + (b[1] - a[1]) * t), + Math.round(a[2] + (b[2] - a[2]) * t), + ]; +} + +export function rampPosition(totalXp: number): number { + const { level, currentXp, neededXp } = levelProgress(totalXp); + if (level >= 50) return 1.0; + const progress = neededXp > 0 ? currentXp / neededXp : 0; + return clamp((level - 1 + progress) / 49, 0, 1); +} + +export function interpolateAnchors( + anchors: readonly RGB[], + breakpoints: readonly number[], + p: number, +): RGB { + for (let i = 1; i < breakpoints.length; i++) { + if (p <= breakpoints[i]!) { + const localT = (p - breakpoints[i - 1]!) / (breakpoints[i]! - breakpoints[i - 1]!); + return lerpRGB(anchors[i - 1]!, anchors[i]!, localT); + } + } + return anchors[anchors.length - 1]!; +} + +export function applySaturationTint(rgb: RGB, factor: number): RGB { + const [gr, gg, gb] = NEUTRAL_GRAY; + return [ + clamp(Math.round(gr + (rgb[0] - gr) * factor), 0, 255), + clamp(Math.round(gg + (rgb[1] - gg) * factor), 0, 255), + clamp(Math.round(gb + (rgb[2] - gb) * factor), 0, 255), + ]; +} + +const BREAKPOINTS = [0, 0.2, 0.4, 0.6, 0.8, 1.0] as const; + +export function computeRGB(species: string, rarity: Rarity, totalXp: number): RGB { + const p = rampPosition(totalXp); + const speciesAnchors = SPECIES_PALETTES[species] ?? FALLBACK_SPECIES_PALETTE; + // Fall back to common metals/saturation for invalid rarities (e.g., a buddy + // state file with a stale or unknown rarity from a schema migration). Better + // to render a muted sprite than to crash the entire statusline. + const metalAnchors = RARITY_METALS[rarity] ?? RARITY_METALS.common; + const saturation = RARITY_SATURATION[rarity] ?? RARITY_SATURATION.common; + + const anchors: RGB[] = [ + speciesAnchors[0], speciesAnchors[1], speciesAnchors[2], speciesAnchors[3], + metalAnchors[0], metalAnchors[1], + ]; + + const interpolated = interpolateAnchors(anchors, [...BREAKPOINTS], p); + return applySaturationTint(interpolated, saturation); +} + +export function detectCapabilities(env: NodeJS.ProcessEnv = process.env): TerminalCapabilities { + const caps: TerminalCapabilities = { + truecolor: false, ansi256: false, ansi16: false, noColor: false, + }; + + // 1. NO_COLOR — highest priority, any value (including "") counts. + if (env.NO_COLOR !== undefined) { + caps.noColor = true; + return caps; + } + + // 2. COLORTERM explicit truecolor declaration. + if (env.COLORTERM === 'truecolor' || env.COLORTERM === '24bit') { + caps.truecolor = true; + return caps; + } + + // 3. Windows Terminal sets WT_SESSION; it supports truecolor. + if (env.WT_SESSION) { + caps.truecolor = true; + return caps; + } + + // 4. Well-known truecolor TERM_PROGRAMs. + if (env.TERM_PROGRAM === 'iTerm.app' || env.TERM_PROGRAM === 'vscode') { + caps.truecolor = true; + return caps; + } + + // 5. TERM suffix. + const term = env.TERM ?? ''; + if (term.endsWith('-truecolor') || term.endsWith('-direct')) { + caps.truecolor = true; + return caps; + } + if (term.endsWith('-256color')) { + caps.ansi256 = true; + return caps; + } + + // 6. Fallback. + caps.ansi16 = true; + return caps; +} + +// Map a 24-bit RGB triple into the 256-color cube index (16-231 range). +// Uses the standard 6×6×6 cube formula. Grayscale ramp (232-255) is not used — +// the cube provides adequate fidelity and avoids hue distortion. +export function rgbTo256(rgb: RGB): number { + const r6 = Math.round((rgb[0] / 255) * 5); + const g6 = Math.round((rgb[1] / 255) * 5); + const b6 = Math.round((rgb[2] / 255) * 5); + return 16 + 36 * r6 + 6 * g6 + b6; +} + +// Map RGB to one of the 8 base ANSI hues by classifying the dominant channel(s). +// Coarse but functional fallback for terminals without 256-color support. +export function rgbToAnsi16(rgb: RGB): string { + const [r, g, b] = rgb; + const brightness = (r + g + b) / 3; + const threshold = 96; // below this, treat as black/dim + + // Classify per-channel as "on" (> threshold) or "off" (<= threshold). + const rOn = r > threshold; + const gOn = g > threshold; + const bOn = b > threshold; + + if (!rOn && !gOn && !bOn) return '\x1b[30m'; // black + if (rOn && gOn && bOn) { + return brightness > 200 ? '\x1b[37m' : '\x1b[30m'; // white or black + } + if (rOn && gOn && !bOn) return '\x1b[33m'; // yellow + if (rOn && !gOn && bOn) return '\x1b[35m'; // magenta + if (!rOn && gOn && bOn) return '\x1b[36m'; // cyan + if (rOn && !gOn && !bOn) return '\x1b[31m'; // red + if (!rOn && gOn && !bOn) return '\x1b[32m'; // green + return '\x1b[34m'; // blue (only remaining case) +} + +let cachedCaps: TerminalCapabilities | null = null; + +function getDefaultCapabilities(): TerminalCapabilities { + if (cachedCaps === null) cachedCaps = detectCapabilities(); + return cachedCaps; +} + +export const BOLD_RARITIES: ReadonlySet = new Set(['rare', 'epic', 'legendary']); + +export function colorFor( + species: string, + rarity: Rarity, + totalXp: number, + caps: TerminalCapabilities = getDefaultCapabilities(), +): string { + if (caps.noColor) return ''; + + const rgb = computeRGB(species, rarity, totalXp); + const boldPrefix = BOLD_RARITIES.has(rarity) ? '\x1b[1m' : ''; + + if (caps.truecolor) { + return `${boldPrefix}\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m`; + } + if (caps.ansi256) { + return `${boldPrefix}\x1b[38;5;${rgbTo256(rgb)}m`; + } + if (caps.ansi16) { + return `${boldPrefix}${rgbToAnsi16(rgb)}`; + } + return ''; +} diff --git a/src/lib/share.ts b/src/lib/share.ts index 57ead7f..34c0d5e 100644 --- a/src/lib/share.ts +++ b/src/lib/share.ts @@ -1,5 +1,6 @@ import { type Companion, STAT_NAMES, RARITY_STARS } from './types.js'; import { levelProgress } from './leveling.js'; +import { computeRGB, BOLD_RARITIES } from './color.js'; export function escapeHtml(unsafe: string): string { return unsafe @@ -15,6 +16,9 @@ export const SPRITE_PLACEHOLDER = 'BUDDY_SPRITE_7f3a9'; export function renderShareHtml(companion: Companion): string { const stars = RARITY_STARS[companion.rarity]; const { level, currentXp, neededXp } = levelProgress(companion.xp); + const [r, g, b] = computeRGB(companion.species, companion.rarity, companion.xp); + const fontWeight = BOLD_RARITIES.has(companion.rarity) ? 'bold' : 'normal'; + const spriteStyle = `color: rgb(${r}, ${g}, ${b}); font-weight: ${fontWeight}`; const statsHtml = STAT_NAMES.map(s => { const val = companion.stats[s] ?? 0; @@ -197,7 +201,7 @@ export function renderShareHtml(companion: Companion): string {
-
${SPRITE_PLACEHOLDER}
+
${SPRITE_PLACEHOLDER}

${escapeHtml(companion.name)}

diff --git a/src/statusline-wrapper.ts b/src/statusline-wrapper.ts index 01526b5..104c9f1 100644 --- a/src/statusline-wrapper.ts +++ b/src/statusline-wrapper.ts @@ -4,7 +4,8 @@ import { join } from "path"; import { homedir } from "os"; import { SPECIES_ANIMATIONS, SPRITE_BODIES, renderSprite } from "./lib/species.js"; import { HAT_LINES, RARITY_ANSI, type Hat } from "./lib/types.js"; -import { RESET, DIM, CYAN, YELLOW, GREEN, MAGENTA, stripAnsi } from "./lib/ansi.js"; +import { RESET, DIM, CYAN, YELLOW, GREEN, stripAnsi } from "./lib/ansi.js"; +import { colorFor } from "./lib/color.js"; import { BUDDY_STATUS_PATH } from "./lib/constants.js"; import { getAnimationProfile, getAnimationState, pickFrame, DEFAULT_DWELL_MS } from "./lib/animation.js"; import { seededIndex } from "./lib/rng.js"; @@ -166,6 +167,8 @@ try { // Colorize bubble lines — the bubble is plain text from renderSpeechBubble(). // Left side = text bubble (borders + content), right side = sprite art after connector. + const bubbleSpriteColor = colorFor(buddy.species, buddy.rarity || 'common', buddy.xp ?? 0); + const bubbleSpriteReset = bubbleSpriteColor ? RESET : ''; for (const line of bubbleLines) { // Lines with " - " connector or " " gutter have sprite art on the right const connectorMatch = line.match(/^(.+?)( - | )(.+)$/); @@ -175,7 +178,7 @@ try { const isName = right.trim() === buddy.name; const coloredRight = isName ? `${CYAN}${right}${RESET}` - : `${MAGENTA}${right}${RESET}`; + : `${bubbleSpriteColor}${right}${bubbleSpriteReset}`; const fadedLeft = isFading ? `${DIM}${DIM}${left}${RESET}` : `${DIM}${left}${RESET}`; buddyRight.push(`${fadedLeft}${DIM}${sep}${RESET}${coloredRight}`); } else { @@ -277,8 +280,10 @@ try { const ambientText = hasReactionActive ? '' : `${DIM}${ambientPool[ambientIdx]}${RESET}`; const artWidth = Math.max(...asciiLines.map((l: string) => l.length)); + const spriteColor = colorFor(buddy.species, buddy.rarity || 'common', buddy.xp ?? 0); + const spriteReset = spriteColor ? RESET : ''; for (let i = 0; i < asciiLines.length; i++) { - const artPart = `${MAGENTA}${(asciiLines[i] || "").padEnd(artWidth)}${RESET}`; + const artPart = `${spriteColor}${(asciiLines[i] || "").padEnd(artWidth)}${spriteReset}`; if (i === 0) { buddyRight.push(`${artPart} ${nameInfo}`); } else if (i === 1) {