Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
4abfbdc
docs: add buddy color progression design spec
Ldaco May 12, 2026
4bbfb3a
docs: clarify saturation tint scope, name color, and bridge muddiness
Ldaco May 12, 2026
dd2eb56
docs: add buddy color progression implementation plan
Ldaco May 13, 2026
caf45c6
feat(color): scaffold color module with types and NEUTRAL_GRAY
Ldaco May 13, 2026
a16c673
feat(color): add 21-species palette table
Ldaco May 13, 2026
0fd8e4d
feat(color): add rarity metal anchors and saturation tints
Ldaco May 13, 2026
2bc3fa2
feat(color): add clamp and lerpRGB helpers
Ldaco May 13, 2026
1697809
feat(color): add rampPosition to map XP onto [0, 1] curve
Ldaco May 13, 2026
630212c
feat(color): add interpolateAnchors for piecewise RGB lerp
Ldaco May 13, 2026
ce16f68
feat(color): add applySaturationTint rarity modulation
Ldaco May 13, 2026
74d1e2d
feat(color): add computeRGB composition function
Ldaco May 13, 2026
0eb6476
test(color): add computeRGB tests for bridge and metal segments
Ldaco May 13, 2026
62bc21b
feat(color): add terminal capability detection cascade
Ldaco May 13, 2026
5a1b5ff
feat(color): add rgbTo256 quantization for 256-color terminals
Ldaco May 13, 2026
4298217
feat(color): add rgbToAnsi16 for 16-color terminal fallback
Ldaco May 13, 2026
fc7751e
feat(color): add colorFor public API with capability-driven output
Ldaco May 13, 2026
3b4c813
test(color): snapshot fixtures lock visual contract for 15 cases
Ldaco May 13, 2026
7a8df7f
feat(statusline): use colorFor for sprite art in normal mode
Ldaco May 13, 2026
9748471
feat(statusline): use colorFor for sprite art in bubble mode
Ldaco May 13, 2026
ceaf144
feat(card): colorize sprite lines via colorFor in renderCard
Ldaco May 13, 2026
f31a318
feat(share): colorize sprite PNG via computeRGB
Ldaco May 13, 2026
1b6c3d5
chore: remove transient spec and plan docs
Ldaco May 13, 2026
a41bd34
fix(color): address Copilot review on PR #126
Ldaco May 13, 2026
6cf9891
fix(color): address Copilot review round 2
Ldaco May 13, 2026
d8aa263
test(color): end-to-end capability tier fallback verification
Ldaco May 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions scripts/verify-color-tiers.mjs
Original file line number Diff line number Diff line change
@@ -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();
}
31 changes: 31 additions & 0 deletions src/__tests__/__snapshots__/color-snapshot.test.ts.snap
Original file line number Diff line number Diff line change
@@ -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`] = `""`;
83 changes: 83 additions & 0 deletions src/__tests__/card-color.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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);
});
Comment thread
Ldaco marked this conversation as resolved.

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/);
});
});
46 changes: 46 additions & 0 deletions src/__tests__/color-snapshot.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
}
});
Loading