From 4abfbdc44f01d219337e938c2b8010f2e5b8034e Mon Sep 17 00:00:00 2001 From: Dave Ambrose Date: Tue, 12 May 2026 19:32:36 -0400 Subject: [PATCH 01/25] docs: add buddy color progression design spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brainstormed three-input color model (species × rarity × XP) that replaces the current single-MAGENTA sprite color with a continuous gradient through 21 species palettes (84 colors) and 5 rarity-metal tiers (10 colors). Rarity uses a tier-break ladder (Iron/Copper/Gold/Diamond/Aurum) so "rare" is visibly rare. Saturation tint and bold weight at Rare+ make rarity readable from Lv 1. Co-Authored-By: Claude Opus 4.7 --- ...26-05-12-buddy-color-progression-design.md | 304 ++++++++++++++++++ 1 file changed, 304 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-12-buddy-color-progression-design.md diff --git a/docs/superpowers/specs/2026-05-12-buddy-color-progression-design.md b/docs/superpowers/specs/2026-05-12-buddy-color-progression-design.md new file mode 100644 index 0000000..1d80dab --- /dev/null +++ b/docs/superpowers/specs/2026-05-12-buddy-color-progression-design.md @@ -0,0 +1,304 @@ +# Buddy Color Progression — Design + +**Date:** 2026-05-12 +**Status:** Brainstormed, awaiting review + +## Why + +Today every buddy in the statusline is rendered in the same MAGENTA color (`src/lib/ansi.ts:9`, applied at `src/statusline-wrapper.ts:281` for sprite art and `src/statusline-wrapper.ts:178` for the reaction-bubble sprite). Rarity barely shows: a single colored star at the end of the mood line via `RARITY_ANSI` (`src/lib/types.ts:63`). + +Two things suffer: + +1. **Species feels invisible.** A Void Cat looks the same color as a Cactus. Species identity exists in the ASCII shape but not in the eye-grabbing color signal. +2. **Rarity barely matters.** Rolling a Legendary (1% drop rate per `RARITY_WEIGHTS` in `src/lib/types.ts:39`) gets you one more star and a slightly different star color. The visceral "I got something rare" moment is muted. + +This design replaces the single MAGENTA with a three-input pure function `colorFor(species, rarity, totalXp) → RGB` whose output drifts continuously as the buddy levels up. 21 species × 5 rarities = 105 distinct (species, rarity) color journeys; every XP point nudges the displayed hue. + +## Goal + +Replace MAGENTA in the statusline sprite (and the stat card returned by hatch / status / rescue) with a color computed from three inputs: + +- **species** — defines a 4-color thematic ramp riding Lv 1 through Lv ~40 (the journey) +- **rarity** — defines the 2-color metal anchor at Lv 40–50 (the destination) and a saturation tint applied across the entire ramp +- **total XP** — drives a continuous position along the curve, every observe shifts the color + +## Model + +### The math + +Let `p ∈ [0, 1]` be the buddy's position on the level curve: + +``` +p = clamp((level - 1 + progressInLevel) / 49, 0, 1) +``` + +where `level` and `progressInLevel` come from existing `levelProgress(totalXp)` in `src/lib/leveling.ts`. At Lv 1 with 0 XP, `p = 0`. At Lv 50, `p = 1`. + +There are **6 color anchors** distributed along `p`: + +| Index | Source | Position p | Level (approx) | +|------:|--------|-----------:|---------------:| +| 0 | species color 1 | 0.0 | 1 | +| 1 | species color 2 | 0.2 | 10 | +| 2 | species color 3 | 0.4 | 20 | +| 3 | species color 4 | 0.6 | 30 | +| 4 | rarity metal 1 | 0.8 | 40 | +| 5 | rarity metal 2 | 1.0 | 50 | + +The function interpolates linearly in RGB between adjacent anchors. The transition from species color 4 → metal 1 (Lv 30 → 40) is a smooth bridge — no plateau, no hard cut. + +### Saturation tint by rarity + +After interpolation, the color is modulated by a rarity-specific saturation factor (applied as a "mix toward neutral gray" — simple linear blend, no HSL conversion needed): + +``` +final = mix(neutralGray, interpolated, satFactor) +``` + +where: + +| Rarity | `satFactor` | Effect | +|--------|------------:|--------| +| Common | 0.85 | -15% toward gray (muted) | +| Uncommon | 1.00 | unchanged | +| Rare | 1.05 | +5% boost | +| Epic | 1.12 | +12% boost | +| Legendary | 1.20 | +20% boost (visual "glow") | + +`neutralGray` is `rgb(128, 128, 128)`. Multiplying away from gray brightens, toward gray mutes. A satFactor > 1 extrapolates beyond the original (clamped to `[0, 255]` per channel). + +### Bold weight at Rare+ + +Rare, Epic, and Legendary buddies also have the ANSI bold attribute (`\x1b[1m`) prepended to their color escape. Common and Uncommon render in normal weight. This adds a second visual axis for rarity, works universally (every terminal supports bold), and costs nothing. + +## Palette tables + +All 94 anchor colors as 24-bit RGB hex. These are **first-cut values** intended to be tunable during implementation without re-spec — the model is the contract; specific shades are advisory. + +### Species palettes — 21 species × 4 anchors = 84 colors + +| # | Species | Anchor 0 (Lv 1) | Anchor 1 (Lv 10) | Anchor 2 (Lv 20) | Anchor 3 (Lv 30) | Theme | +|--:|---------|-----------------|------------------|------------------|------------------|-------| +| 01 | Void Cat | `#1a1a2a` | `#4a3a6e` | `#c33a8e` | `#d6d6f0` | void → cosmic → nebula → starfield | +| 02 | Rust Hound | `#a04a2a` | `#d44a2e` | `#d68a3e` | `#b87a4a` | rust → ember → copper → iron | +| 03 | Data Drake | `#5fbb33` | `#4ad6c2` | `#e83a9c` | `#9c3aff` | terminal → cyan → laser → neon violet | +| 04 | Log Golem | `#5e4836` | `#5a7a3a` | `#7a7a7a` | `#8a9a6e` | bark → moss → stone → lichen | +| 05 | Cache Crow | `#2a2a2a` | `#6a6a76` | `#4a5aa8` | `#d6d6e6` | obsidian → silver → indigo → starlight | +| 06 | Shell Turtle | `#6e5236` | `#5a7a3a` | `#2e7a5a` | `#d68a3e` | shell → moss → emerald → amber | +| 07 | Duck | `#5a7a4a` | `#4a8a9a` | `#d68a3a` | `#f4c948` | pond → mallard → sunset → soft yellow | +| 08 | Goose | `#aaa9a3` | `#6a8aa8` | `#4a8a99` | `#7ec9c6` | pale gray → dusty blue → twilight → moonlight | +| 09 | Blob | `#5fbb33` | `#f4c948` | `#e83a9c` | `#9c3aff` | slime → toxic → neon pink → electric purple | +| 10 | Octopus | `#3d2a5a` | `#5d4cad` | `#3d8ad6` | `#3ed6c2` | abyss → tide → reef → shallows teal | +| 11 | Owl | `#5d4cad` | `#2a3a6e` | `#d6d4a6` | `#e8b04a` | twilight → midnight → moonglow → amber | +| 12 | Penguin | `#d4e4eb` | `#5d9cd6` | `#4ec5b9` | `#6cd99a` | ice → arctic → glacier → aurora | +| 13 | Snail | `#aaa9a3` | `#5a7a4a` | `#d4a6b9` | `#cfd9d4` | trail silver → pond → pearl pink → opal | +| 14 | Ghost | `#aaa9a3` | `#6a8aa8` | `#c4e4e6` | `#f0f0f0` | pale → faded blue → ethereal cyan → white glow | +| 15 | Axolotl | `#d68a8a` | `#e96a5a` | `#f4b6c2` | `#b6e4c2` | salmon → coral → blush → mint | +| 16 | Capybara | `#8a6a4a` | `#d68a4a` | `#e8c46a` | `#8aa66e` | warm brown → sunset → mellow → calm green | +| 17 | Cactus | `#9b8757` | `#5a8a3a` | `#c75d8a` | `#e8b04a` | desert sand → cactus green → bloom → desert gold | +| 18 | Robot | `#5a5a66` | `#3a8aa4` | `#5fbb33` | `#e8443e` | brushed steel → circuit cyan → terminal green → warning red | +| 19 | Rabbit | `#f4b6c2` | `#f4e6c4` | `#e8b06f` | `#f6f6f4` | pastel pink → cream → ear-tip orange → fluff white | +| 20 | Mushroom | `#5e4836` | `#8b6d4b` | `#c33a2e` | `#e8b06f` | forest floor → stem tan → red cap → spore glow | +| 21 | Chonk | `#e6d6b4` | `#d68a4a` | `#c4843e` | `#6e4a2a` | warm cream → tabby → sleepy amber → cozy brown | + +### Rarity metals — 5 rarities × 2 anchors = 10 colors + +Tier-break ladder ("rare should mean rare" — the visible jump is Uncommon → Rare): + +| Rarity | Metal 1 (Lv 40) | Metal 2 (Lv 50) | Material | +|--------|-----------------|-----------------|----------| +| Common ★ | `#6a6a6e` | `#8a8a8e` | Iron → Polished Iron | +| Uncommon ★★ | `#a86a3a` | `#b88a5e` | Copper → Patina Copper | +| **Rare ★★★** | `#c89a2e` | `#f4c948` | **Gold I → Gold II** *(the jump)* | +| Epic ★★★★ | `#8acdd9` | `#dceef4` | Diamond → Iridescent | +| Legendary ★★★★★ | `#cabc94` | `#f4eedc` | Aurum → Aurum Sheen | + +Common and Uncommon get utilitarian metals (Iron, Copper). The visible break to *precious* materials happens at Rare. This honors the actual drop rate (`RARITY_WEIGHTS` in `src/lib/types.ts`): Common 60%, Uncommon 25%, Rare 10%, Epic 4%, Legendary 1%. + +## Algorithm + +```typescript +// src/lib/color.ts + +type RGB = readonly [number, number, number]; + +interface TerminalCapabilities { + truecolor: boolean; + ansi256: boolean; + ansi16: boolean; + noColor: boolean; +} + +function colorFor( + species: string, + rarity: Rarity, + totalXp: number, + caps: TerminalCapabilities = detectCapabilities() +): string { + if (caps.noColor) return ''; + + const rgb = computeRGB(species, rarity, totalXp); + const boldPrefix = (rarity === 'rare' || rarity === 'epic' || rarity === 'legendary') + ? '\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`; + } + // ansi16 fallback — nearest of 8 base hues + return `${boldPrefix}${rgbToAnsi16(rgb)}`; +} + +function computeRGB(species: string, rarity: Rarity, totalXp: number): RGB { + const p = rampPosition(totalXp); + const speciesAnchors = SPECIES_PALETTES[species] ?? FALLBACK_SPECIES_PALETTE; + const metalAnchors = RARITY_METALS[rarity]; + + const anchors: RGB[] = [...speciesAnchors, ...metalAnchors]; + const breakpoints = [0, 0.2, 0.4, 0.6, 0.8, 1.0]; + + const interpolated = interpolateAnchors(anchors, breakpoints, p); + return applySaturationTint(interpolated, RARITY_SATURATION[rarity]); +} + +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); +} + +function interpolateAnchors(anchors: RGB[], breakpoints: number[], p: number): RGB { + for (let i = 1; i < breakpoints.length; i++) { + if (p <= breakpoints[i]) { + const t = (p - breakpoints[i - 1]) / (breakpoints[i] - breakpoints[i - 1]); + return lerpRGB(anchors[i - 1], anchors[i], t); + } + } + return anchors[anchors.length - 1]; +} + +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), + ]; +} + +function applySaturationTint(rgb: RGB, factor: number): RGB { + const GRAY = 128; + return [ + clamp(Math.round(GRAY + (rgb[0] - GRAY) * factor), 0, 255), + clamp(Math.round(GRAY + (rgb[1] - GRAY) * factor), 0, 255), + clamp(Math.round(GRAY + (rgb[2] - GRAY) * factor), 0, 255), + ]; +} +``` + +## Terminal capability & fallbacks + +Detect in this order (first match wins): + +1. **`process.env.NO_COLOR` defined** (any value, including empty) → `noColor: true`. Return empty string for every call. +2. **`process.env.COLORTERM === 'truecolor'` or `'24bit'`** → `truecolor: true`. Emit `\x1b[38;2;R;G;Bm`. +3. **`process.env.WT_SESSION` defined** (Windows Terminal) → `truecolor: true`. +4. **`process.env.TERM_PROGRAM` is `'iTerm.app'` or `'vscode'`** → `truecolor: true`. +5. **`process.env.TERM` ends in `-truecolor` or `-direct`** → `truecolor: true`. +6. **`process.env.TERM` ends in `-256color`** → `ansi256: true`. Emit `\x1b[38;5;Nm` where `N = rgbTo256(rgb)`. +7. **Otherwise** → `ansi16: true`. Emit one of `\x1b[3{0-7}m` by nearest-hue match. + +`rgbTo256`: standard 6×6×6 color cube formula — `16 + 36*r6 + 6*g6 + b6` where `r6, g6, b6 ∈ {0..5}`. Or use grayscale ramp `232..255` for near-gray colors. + +`rgbToAnsi16`: classify by dominant channel and brightness; map to one of the 8 base ANSI hues (red, green, yellow, blue, magenta, cyan, white, black). Coarse but functional. + +## Code architecture + +### New file: `src/lib/color.ts` + +Exports: +- `colorFor(species, rarity, totalXp, caps?) → string` — primary public API +- `detectCapabilities() → TerminalCapabilities` — cached on first call +- `SPECIES_PALETTES: Record` — the 21-species table +- `RARITY_METALS: Record` — the 5-rarity table +- `RARITY_SATURATION: Record` — the tint factors +- `FALLBACK_SPECIES_PALETTE: [RGB, RGB, RGB, RGB]` — generic ramp for unknown species (defensive) + +Internal helpers: `computeRGB`, `rampPosition`, `interpolateAnchors`, `lerpRGB`, `applySaturationTint`, `rgbTo256`, `rgbToAnsi16`, `clamp`. + +### Modify: `src/statusline-wrapper.ts` + +- **Line 178** (reaction-bubble sprite right-side): replace `${MAGENTA}${right}${RESET}` with `${colorFor(buddy.species, buddy.rarity, buddy.xp)}${right}${RESET}`. +- **Line 281** (sprite art in normal mode): replace `${MAGENTA}${(asciiLines[i] || "").padEnd(artWidth)}${RESET}` with `${colorFor(...)}${...}${RESET}`. +- The buddy's *name* (line 154) stays `CYAN` — it's identity text, not sprite art. +- The species name in parens stays `DIM`. + +### Modify: `src/lib/card.ts` + +- `renderCard()`: wrap each sprite line (line 48: `...art.map(l => ln(l))`) in `colorFor(companion.species, companion.rarity, companion.xp)` ... `RESET`. The card header (rarity stars + species label) keeps its current treatment. +- `hatchAnimation()` and `rescueAnimation()`: the `hatched` / `rescued` reveal block (egg-cracked sprite with sparkles) gets the same wrapping around the `...art` lines. + +### Untouched + +- `src/lib/ansi.ts` — keep `MAGENTA` exported; other consumers may still use it. +- `src/lib/types.ts` — `RARITY_ANSI` (star colors) stays as-is. +- `src/lib/species.ts` — `renderSprite()` continues to return plain ASCII; coloring happens at the integration sites. + +## Testing + +### Unit tests (`src/__tests__/color.test.ts` — new) + +- **Ramp position math:** `rampPosition(0) === 0`, `rampPosition(xpForLv50) === 1`, monotonically increasing. +- **Anchor interpolation:** at exact breakpoints, returns the anchor; at midpoints, returns the linear midpoint. +- **Saturation tint:** `factor=1.0` is identity; `factor=0` returns `(128, 128, 128)`; `factor>1` extrapolates and clamps. +- **`colorFor` end-to-end:** known (species, rarity, xp) → expected ANSI escape (snapshot table of 10–15 representative cases covering each rarity and a mix of species and levels). +- **Terminal capability detection:** mock `process.env` for each branch — NO_COLOR, COLORTERM=truecolor, WT_SESSION, TERM=-256color, TERM=xterm. Each maps to expected mode. +- **Bold weight:** Rare/Epic/Legendary include `\x1b[1m`; Common/Uncommon do not. +- **NO_COLOR:** returns empty string regardless of other inputs. +- **Unknown species:** falls back to `FALLBACK_SPECIES_PALETTE` without throwing. + +### Snapshot tests + +- A fixture of 20 (species, rarity, xp) combos rendered to ANSI strings; committed and snapshot-asserted to lock the visual contract. + +### Manual verification + +- Hatch a Common Cactus at Lv 1; observe color = Iron-tinted desert sand (muted). +- Grind to Lv 10; observe color drift toward cactus green. +- Hatch a Legendary Octopus at Lv 1; observe color = abyss with +20% saturation glow + bold. +- Force `NO_COLOR=1` and re-run statusline; observe plain ASCII. +- Run on plain `cmd.exe` (no `WT_SESSION`) and observe 16-color fallback. + +## In scope (this PR) + +- All 21 species palettes (4 RGB anchors each). +- All 5 rarity metal anchors (2 RGB each). +- The `colorFor` function with truecolor / 256-color / 16-color / NO_COLOR paths. +- Bold weight at Rare+. +- Saturation tint by rarity. +- Integration at the three call sites (statusline normal, statusline bubble, card / hatch / rescue). +- Unit + snapshot tests as above. + +## Out of scope (deferred) + +- **Shimmer / prismatic animation** at Epic+ and Legendary. Statusline refreshes at 2s, which would produce a slow pulse rather than a shimmer. Revisit if refresh rate becomes faster. +- **Mood-driven color shifts.** Composable later — `applyMoodModulation(rgb, mood)` could slot in. +- **Updating `RARITY_ANSI` star colors** to match the new metal palette. Separable polish. +- **README screenshots** showing the progression. Doc task. +- **Hatch animation egg coloring** (the cracked-egg frames before the buddy reveals). +- **Rarity-drop balance changes.** Drop rates stay where they are. + +## Risks & mitigations + +- **Color blindness.** Color is supplementary — the level number (`Lv.5`, `Lv.50 MAX`) and rarity star count remain visible plain-text. `NO_COLOR` env strips everything for users who prefer it. +- **Terminal compatibility surprises.** Detection cascades through five env vars; defaults to 16-color fallback (safe). Manual verification on Windows Terminal, cmd.exe, plain bash, and a `NO_COLOR=1` run is in the test plan. +- **First-cut palette shades may not feel right in practice.** RGB values are not load-bearing on the design — they are tunable during implementation. The model (3 inputs, 6 anchors, saturation tint, bold weight) is the contract. +- **Adding color to the stat card changes a returned MCP tool result.** The card is shown verbatim by the host LLM in a code block; ANSI escapes may render as raw text in some hosts. Mitigation: the stat card already lives in Claude Code which strips/displays ANSI correctly; for other hosts the worst case is visible escape codes (functional but ugly). Acceptable for v1. + +## Followups + +- Build the actual feature on a branch `feature/color-progression`. (Branch creation is part of the implementation phase, not the spec.) +- After landing, evaluate whether to revisit out-of-scope items (shimmer, mood, star recolor). From 4bbfb3a2997237aa447c6a3729acb16089fc5568 Mon Sep 17 00:00:00 2001 From: Dave Ambrose Date: Tue, 12 May 2026 19:37:56 -0400 Subject: [PATCH 02/25] docs: clarify saturation tint scope, name color, and bridge muddiness Spec self-review pass: tighten three ambiguities. - Saturation tint applies uniformly to species AND metal segments (intentional). - Buddy's user-given name stays CYAN; only sprite-art colors change. - Acknowledge Lv 30-40 bridge can look muddy for some (species, rarity) combos; flag for per-species tuning post-launch. Co-Authored-By: Claude Opus 4.7 --- .../specs/2026-05-12-buddy-color-progression-design.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/specs/2026-05-12-buddy-color-progression-design.md b/docs/superpowers/specs/2026-05-12-buddy-color-progression-design.md index 1d80dab..441eabb 100644 --- a/docs/superpowers/specs/2026-05-12-buddy-color-progression-design.md +++ b/docs/superpowers/specs/2026-05-12-buddy-color-progression-design.md @@ -49,7 +49,7 @@ The function interpolates linearly in RGB between adjacent anchors. The transiti ### Saturation tint by rarity -After interpolation, the color is modulated by a rarity-specific saturation factor (applied as a "mix toward neutral gray" — simple linear blend, no HSL conversion needed): +After interpolation, the color is modulated by a rarity-specific saturation factor (applied as a "mix toward neutral gray" — simple linear blend, no HSL conversion needed). The tint applies **uniformly** across the entire ramp — both species and metal segments. This means a Legendary's metal anchors get the +20% boost (visibly extra-saturated metal) while a Common's metal anchors get -15% (visibly muted iron). This is intentional: rarity reads consistently from Lv 1 through Lv 50. ``` final = mix(neutralGray, interpolated, satFactor) @@ -233,8 +233,9 @@ Internal helpers: `computeRGB`, `rampPosition`, `interpolateAnchors`, `lerpRGB`, - **Line 178** (reaction-bubble sprite right-side): replace `${MAGENTA}${right}${RESET}` with `${colorFor(buddy.species, buddy.rarity, buddy.xp)}${right}${RESET}`. - **Line 281** (sprite art in normal mode): replace `${MAGENTA}${(asciiLines[i] || "").padEnd(artWidth)}${RESET}` with `${colorFor(...)}${...}${RESET}`. -- The buddy's *name* (line 154) stays `CYAN` — it's identity text, not sprite art. -- The species name in parens stays `DIM`. +- The buddy's user-given *name* on line 154 (the `CYAN`-wrapped `buddy.name`) **stays CYAN** — names are identity text the user picks/reads, not sprite art. Coloring them with the gradient would obscure readability. +- The species name in parens (`${DIM}(${buddy.species})${RESET}`) **stays DIM**. +- `MAGENTA` import stays in the imports — other code may still reference it. ### Modify: `src/lib/card.ts` @@ -297,6 +298,7 @@ Internal helpers: `computeRGB`, `rampPosition`, `interpolateAnchors`, `lerpRGB`, - **Terminal compatibility surprises.** Detection cascades through five env vars; defaults to 16-color fallback (safe). Manual verification on Windows Terminal, cmd.exe, plain bash, and a `NO_COLOR=1` run is in the test plan. - **First-cut palette shades may not feel right in practice.** RGB values are not load-bearing on the design — they are tunable during implementation. The model (3 inputs, 6 anchors, saturation tint, bold weight) is the contract. - **Adding color to the stat card changes a returned MCP tool result.** The card is shown verbatim by the host LLM in a code block; ANSI escapes may render as raw text in some hosts. Mitigation: the stat card already lives in Claude Code which strips/displays ANSI correctly; for other hosts the worst case is visible escape codes (functional but ugly). Acceptable for v1. +- **Species color 4 → Metal 1 bridge (Lv 30–40) may look muddy for some (species, rarity) combinations.** E.g., a Common Penguin transitions from `aurora green` to `iron gray` over 10 levels — the midpoint will be a desaturated greenish-gray. This is expected; the per-species palettes can be tuned during implementation so the final species color foreshadows the rarity's metals (e.g., shift Penguin's Anchor 3 cooler to ease the iron bridge). 105 bridges to hand-tune is overkill — accept some muddiness in v1 and refine the worst offenders post-launch. ## Followups From dd2eb5690b393d1dd5fc06f5c51f5ffd97e9374e Mon Sep 17 00:00:00 2001 From: Dave Ambrose Date: Tue, 12 May 2026 20:05:29 -0400 Subject: [PATCH 03/25] docs: add buddy color progression implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 18-task plan covering: new src/lib/color.ts module (palettes, capability detection, RGB math), integration into statusline-wrapper.ts and card.ts, unit + snapshot + integration tests, manual verification across truecolor / 256-color / 16-color / NO_COLOR modes. Each task is TDD-disciplined (test → fail → impl → pass → commit) with exact file paths, commands, and code shown inline. Co-Authored-By: Claude Opus 4.7 --- .../2026-05-12-buddy-color-progression.md | 1654 +++++++++++++++++ 1 file changed, 1654 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-12-buddy-color-progression.md diff --git a/docs/superpowers/plans/2026-05-12-buddy-color-progression.md b/docs/superpowers/plans/2026-05-12-buddy-color-progression.md new file mode 100644 index 0000000..c07c780 --- /dev/null +++ b/docs/superpowers/plans/2026-05-12-buddy-color-progression.md @@ -0,0 +1,1654 @@ +# Buddy Color Progression Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the single MAGENTA buddy sprite color with a three-input gradient (species × rarity × XP) so every buddy reads as a distinct, evolving color identity. + +**Architecture:** New `src/lib/color.ts` module exports a pure `colorFor(species, rarity, totalXp) → ansiEscape` function. It interpolates linearly across 6 RGB anchors (4 species + 2 rarity metal) along the level curve, applies a rarity-specific saturation tint, prepends ANSI bold for Rare+, and emits truecolor / 256-color / 16-color / NO_COLOR output based on terminal capability detection. `statusline-wrapper.ts` and `card.ts` swap their hard-coded MAGENTA for this function. + +**Tech Stack:** TypeScript (ESM, `.js` suffix imports), vitest for tests, Node.js 18+. + +**Spec:** `docs/superpowers/specs/2026-05-12-buddy-color-progression-design.md` + +--- + +### Task 0: Create the feature branch + +**Files:** (none — git operation) + +- [ ] **Step 1: Verify clean working tree on master** + +```bash +git status +``` + +Expected: on master, optional uncommitted changes from session work (`.claude/settings.local.json`, `package-lock.json`, `.npm-install.log`, `.superpowers/`) are OK but should not be staged. Spec commits are already on master. + +- [ ] **Step 2: Create and check out the feature branch** + +```bash +git checkout -b feature/color-progression +git branch --show-current +``` + +Expected output: `feature/color-progression` + +- [ ] **Step 3: No commit yet — branch created from current master tip** + +--- + +### Task 1: Scaffold `src/lib/color.ts` with types and stub exports + +**Files:** +- Create: `src/lib/color.ts` +- Create: `src/__tests__/color.test.ts` + +- [ ] **Step 1: Write the failing test for module structure** + +Create `src/__tests__/color.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest'; +import type { RGB, TerminalCapabilities } from '../lib/color.js'; +import { NEUTRAL_GRAY } from '../lib/color.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); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/__tests__/color.test.ts` +Expected: FAIL — module `../lib/color.js` does not exist. + +- [ ] **Step 3: Create the module with types and `NEUTRAL_GRAY` constant** + +Create `src/lib/color.ts`: + +```typescript +// src/lib/color.ts — buddy color progression (species × rarity × XP → ANSI escape) +// +// See docs/superpowers/specs/2026-05-12-buddy-color-progression-design.md for the design. + +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]; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/__tests__/color.test.ts` +Expected: PASS — 3 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/color.ts src/__tests__/color.test.ts +git commit -m "feat(color): scaffold color module with types and NEUTRAL_GRAY" +``` + +--- + +### Task 2: Add `SPECIES_PALETTES` constant (21 species × 4 RGB anchors) + +**Files:** +- Modify: `src/lib/color.ts` +- Modify: `src/__tests__/color.test.ts` + +- [ ] **Step 1: Write the failing test for the palette table** + +Append to `src/__tests__/color.test.ts`: + +```typescript +import { SPECIES_PALETTES, FALLBACK_SPECIES_PALETTE } from '../lib/color.js'; +import { SPECIES_LIST } from '../lib/species.js'; + +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); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/__tests__/color.test.ts -t "SPECIES_PALETTES"` +Expected: FAIL — `SPECIES_PALETTES` not exported. + +- [ ] **Step 3: Add the palette table (transcribe RGB values from spec section "Species palettes")** + +Append to `src/lib/color.ts`: + +```typescript +// 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], +]; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/__tests__/color.test.ts -t "SPECIES_PALETTES"` +Expected: PASS — 4 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/color.ts src/__tests__/color.test.ts +git commit -m "feat(color): add 21-species palette table" +``` + +--- + +### Task 3: Add `RARITY_METALS` and `RARITY_SATURATION` constants + +**Files:** +- Modify: `src/lib/color.ts` +- Modify: `src/__tests__/color.test.ts` + +- [ ] **Step 1: Write the failing test** + +Append to `src/__tests__/color.test.ts`: + +```typescript +import { RARITY_METALS, RARITY_SATURATION } from '../lib/color.js'; +import { RARITIES } from '../lib/types.js'; + +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); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/__tests__/color.test.ts -t "RARITY_METALS"` +Expected: FAIL — `RARITY_METALS` / `RARITY_SATURATION` not exported. + +- [ ] **Step 3: Add the constants** + +Append to `src/lib/color.ts`: + +```typescript +import type { Rarity } from './types.js'; + +// 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, +}; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/__tests__/color.test.ts -t "RARITY_METALS"` +Expected: PASS — 3 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/color.ts src/__tests__/color.test.ts +git commit -m "feat(color): add rarity metal anchors and saturation tints" +``` + +--- + +### Task 4: Implement `clamp` and `lerpRGB` helpers + +**Files:** +- Modify: `src/lib/color.ts` +- Modify: `src/__tests__/color.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Append to `src/__tests__/color.test.ts`: + +```typescript +import { clamp, lerpRGB } from '../lib/color.js'; + +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); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run src/__tests__/color.test.ts -t "clamp"` +Expected: FAIL — `clamp` not exported. + +- [ ] **Step 3: Implement the helpers** + +Append to `src/lib/color.ts`: + +```typescript +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), + ]; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run src/__tests__/color.test.ts -t "clamp\\|lerpRGB"` +Expected: PASS — 7 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/color.ts src/__tests__/color.test.ts +git commit -m "feat(color): add clamp and lerpRGB helpers" +``` + +--- + +### Task 5: Implement `rampPosition` (XP → 0..1 curve position) + +**Files:** +- Modify: `src/lib/color.ts` +- Modify: `src/__tests__/color.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Append to `src/__tests__/color.test.ts`: + +```typescript +import { rampPosition } from '../lib/color.js'; +import { totalXpForLevel } from '../lib/leveling.js'; + +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; + } + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run src/__tests__/color.test.ts -t "rampPosition"` +Expected: FAIL — `rampPosition` not exported. + +- [ ] **Step 3: Implement `rampPosition`** + +Append to `src/lib/color.ts`: + +```typescript +import { levelProgress } from './leveling.js'; + +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); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run src/__tests__/color.test.ts -t "rampPosition"` +Expected: PASS — 5 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/color.ts src/__tests__/color.test.ts +git commit -m "feat(color): add rampPosition to map XP onto [0, 1] curve" +``` + +--- + +### Task 6: Implement `interpolateAnchors` (multi-anchor piecewise lerp) + +**Files:** +- Modify: `src/lib/color.ts` +- Modify: `src/__tests__/color.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Append to `src/__tests__/color.test.ts`: + +```typescript +import { interpolateAnchors } from '../lib/color.js'; + +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]); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run src/__tests__/color.test.ts -t "interpolateAnchors"` +Expected: FAIL — `interpolateAnchors` not exported. + +- [ ] **Step 3: Implement `interpolateAnchors`** + +Append to `src/lib/color.ts`: + +```typescript +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]!; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run src/__tests__/color.test.ts -t "interpolateAnchors"` +Expected: PASS — 4 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/color.ts src/__tests__/color.test.ts +git commit -m "feat(color): add interpolateAnchors for piecewise RGB lerp" +``` + +--- + +### Task 7: Implement `applySaturationTint` + +**Files:** +- Modify: `src/lib/color.ts` +- Modify: `src/__tests__/color.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Append to `src/__tests__/color.test.ts`: + +```typescript +import { applySaturationTint } from '../lib/color.js'; + +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]); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run src/__tests__/color.test.ts -t "applySaturationTint"` +Expected: FAIL. + +- [ ] **Step 3: Implement `applySaturationTint`** + +Append to `src/lib/color.ts`: + +```typescript +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), + ]; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run src/__tests__/color.test.ts -t "applySaturationTint"` +Expected: PASS — 5 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/color.ts src/__tests__/color.test.ts +git commit -m "feat(color): add applySaturationTint rarity modulation" +``` + +--- + +### Task 8: Implement `computeRGB` (composition of all the math) + +**Files:** +- Modify: `src/lib/color.ts` +- Modify: `src/__tests__/color.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Append to `src/__tests__/color.test.ts`: + +```typescript +import { computeRGB } from '../lib/color.js'; + +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 + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run src/__tests__/color.test.ts -t "computeRGB"` +Expected: FAIL. + +- [ ] **Step 3: Implement `computeRGB`** + +Append to `src/lib/color.ts`: + +```typescript +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; + const metalAnchors = RARITY_METALS[rarity]; + + const anchors: RGB[] = [ + speciesAnchors[0], speciesAnchors[1], speciesAnchors[2], speciesAnchors[3], + metalAnchors[0], metalAnchors[1], + ]; + + const interpolated = interpolateAnchors(anchors, [...BREAKPOINTS], p); + return applySaturationTint(interpolated, RARITY_SATURATION[rarity]); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run src/__tests__/color.test.ts -t "computeRGB"` +Expected: PASS — 4 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/color.ts src/__tests__/color.test.ts +git commit -m "feat(color): add computeRGB composition function" +``` + +--- + +### Task 9: Implement `detectCapabilities` (terminal capability cascade) + +**Files:** +- Modify: `src/lib/color.ts` +- Modify: `src/__tests__/color.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Append to `src/__tests__/color.test.ts`: + +```typescript +import { detectCapabilities } from '../lib/color.js'; + +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); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run src/__tests__/color.test.ts -t "detectCapabilities"` +Expected: FAIL. + +- [ ] **Step 3: Implement `detectCapabilities`** + +Append to `src/lib/color.ts`: + +```typescript +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; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run src/__tests__/color.test.ts -t "detectCapabilities"` +Expected: PASS — 12 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/color.ts src/__tests__/color.test.ts +git commit -m "feat(color): add terminal capability detection cascade" +``` + +--- + +### Task 10: Implement `rgbTo256` (truecolor → 256-color quantization) + +**Files:** +- Modify: `src/lib/color.ts` +- Modify: `src/__tests__/color.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Append to `src/__tests__/color.test.ts`: + +```typescript +import { rgbTo256 } from '../lib/color.js'; + +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); + } + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run src/__tests__/color.test.ts -t "rgbTo256"` +Expected: FAIL. + +- [ ] **Step 3: Implement `rgbTo256`** + +Append to `src/lib/color.ts`: + +```typescript +// 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; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run src/__tests__/color.test.ts -t "rgbTo256"` +Expected: PASS — 6 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/color.ts src/__tests__/color.test.ts +git commit -m "feat(color): add rgbTo256 quantization for 256-color terminals" +``` + +--- + +### Task 11: Implement `rgbToAnsi16` (truecolor → 8-base-hue fallback) + +**Files:** +- Modify: `src/lib/color.ts` +- Modify: `src/__tests__/color.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Append to `src/__tests__/color.test.ts`: + +```typescript +import { rgbToAnsi16 } from '../lib/color.js'; + +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'); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run src/__tests__/color.test.ts -t "rgbToAnsi16"` +Expected: FAIL. + +- [ ] **Step 3: Implement `rgbToAnsi16`** + +Append to `src/lib/color.ts`: + +```typescript +// 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) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run src/__tests__/color.test.ts -t "rgbToAnsi16"` +Expected: PASS — 8 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/color.ts src/__tests__/color.test.ts +git commit -m "feat(color): add rgbToAnsi16 for 16-color terminal fallback" +``` + +--- + +### Task 12: Implement `colorFor` (public API) + +**Files:** +- Modify: `src/lib/color.ts` +- Modify: `src/__tests__/color.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Append to `src/__tests__/color.test.ts`: + +```typescript +import { colorFor } from '../lib/color.js'; + +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$/); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run src/__tests__/color.test.ts -t "colorFor"` +Expected: FAIL. + +- [ ] **Step 3: Implement `colorFor` and a cached default detector** + +Append to `src/lib/color.ts`: + +```typescript +let cachedCaps: TerminalCapabilities | null = null; + +function getDefaultCapabilities(): TerminalCapabilities { + if (cachedCaps === null) cachedCaps = detectCapabilities(); + return cachedCaps; +} + +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`; + } + return `${boldPrefix}${rgbToAnsi16(rgb)}`; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run src/__tests__/color.test.ts -t "colorFor"` +Expected: PASS — 9 tests. + +- [ ] **Step 5: Run the full color test file to verify nothing broke** + +Run: `npx vitest run src/__tests__/color.test.ts` +Expected: PASS — all 60+ tests. + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/color.ts src/__tests__/color.test.ts +git commit -m "feat(color): add colorFor public API with capability-driven output" +``` + +--- + +### Task 13: Snapshot fixture tests (locked visual contract) + +**Files:** +- Create: `src/__tests__/color-snapshot.test.ts` + +- [ ] **Step 1: Write the snapshot fixture test** + +Create `src/__tests__/color-snapshot.test.ts`: + +```typescript +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[] = [ + // Each rarity at Lv 1 (start of ramp, species color tinted) + { 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' }, + + // Mid-ramp (species color 2 territory) + { 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' }, + + // Bridge zone (species → metal handoff) + { species: 'Penguin', rarity: 'rare', level: 35, label: 'rare-penguin-lv35-bridge' }, + + // Metal zone (Lv 40-50) per rarity + { 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' }, + + // Defensive: unknown species falls back gracefully + { 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(); + }); + } +}); +``` + +- [ ] **Step 2: Run the fixture test to GENERATE snapshots** + +Run: `npx vitest run src/__tests__/color-snapshot.test.ts -u` +Expected: PASS — snapshots written. A `__snapshots__` folder appears next to the test file with `color-snapshot.test.ts.snap`. + +- [ ] **Step 3: Run again WITHOUT `-u` to verify snapshots are stable** + +Run: `npx vitest run src/__tests__/color-snapshot.test.ts` +Expected: PASS — all 15 fixtures. + +- [ ] **Step 4: Spot-check the snapshot file** + +Run: `cat src/__tests__/__snapshots__/color-snapshot.test.ts.snap | head -30` +Expected: see entries like `exports['color fixtures (snapshot contract) > common-cactus-lv1 ... 1'] = '';` + +- [ ] **Step 5: Commit** + +```bash +git add src/__tests__/color-snapshot.test.ts src/__tests__/__snapshots__/ +git commit -m "test(color): snapshot fixtures lock visual contract for 15 cases" +``` + +--- + +### Task 14: Integrate `colorFor` into `statusline-wrapper.ts` (normal sprite mode, line 281) + +**Files:** +- Modify: `src/statusline-wrapper.ts` + +- [ ] **Step 1: Read current context around line 281** + +Run: `head -290 src/statusline-wrapper.ts | tail -20` +Expected: see the `for (let i = 0; i < asciiLines.length; i++) { const artPart = \`${MAGENTA}...\` ... }` block at lines ~279-291. + +- [ ] **Step 2: Add color.ts import at the top of the file** + +Locate the existing import line (around line 7): + +```typescript +import { RESET, DIM, CYAN, YELLOW, GREEN, MAGENTA, stripAnsi } from "./lib/ansi.js"; +``` + +Add immediately after it: + +```typescript +import { colorFor } from "./lib/color.js"; +``` + +- [ ] **Step 3: Replace the `MAGENTA` wrapping on line 281** + +Find the existing line 281: + +```typescript +const artPart = `${MAGENTA}${(asciiLines[i] || "").padEnd(artWidth)}${RESET}`; +``` + +Replace with: + +```typescript +const spriteColor = colorFor(buddy.species, buddy.rarity, buddy.xp); +const artPart = `${spriteColor}${(asciiLines[i] || "").padEnd(artWidth)}${RESET}`; +``` + +(Hoist `spriteColor` outside the per-line loop — compute once per sprite render to avoid re-running capability detection on every line. See refactor in Step 4.) + +- [ ] **Step 4: Hoist `spriteColor` outside the loop** + +Find the block at line 279: + +```typescript +const artWidth = Math.max(...asciiLines.map((l: string) => l.length)); +for (let i = 0; i < asciiLines.length; i++) { + const artPart = `${MAGENTA}${(asciiLines[i] || "").padEnd(artWidth)}${RESET}`; + ... +} +``` + +Replace the whole block with: + +```typescript +const artWidth = Math.max(...asciiLines.map((l: string) => l.length)); +const spriteColor = colorFor(buddy.species, buddy.rarity, buddy.xp); +for (let i = 0; i < asciiLines.length; i++) { + const artPart = `${spriteColor}${(asciiLines[i] || "").padEnd(artWidth)}${RESET}`; + if (i === 0) { + buddyRight.push(`${artPart} ${nameInfo}`); + } else if (i === 1) { + buddyRight.push(`${artPart} ${moodInfo}`); + } else if (i === 2 && ambientText) { + buddyRight.push(`${artPart} ${ambientText}`); + } else { + buddyRight.push(artPart); + } +} +``` + +- [ ] **Step 5: Build the project to verify TypeScript compiles** + +Run: `npm run build` +Expected: PASS — `tsc` exits 0, no type errors. + +- [ ] **Step 6: Run the full test suite (no regressions)** + +Run: `npm test` +Expected: PASS — all existing tests still pass; new color tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add src/statusline-wrapper.ts +git commit -m "feat(statusline): use colorFor for sprite art in normal mode" +``` + +--- + +### Task 15: Integrate `colorFor` into `statusline-wrapper.ts` (bubble mode, line 178) + +**Files:** +- Modify: `src/statusline-wrapper.ts` + +- [ ] **Step 1: Locate the bubble sprite block (line ~169-185)** + +Run: `sed -n '167,185p' src/statusline-wrapper.ts` + +Expected: see the bubble line loop with `${MAGENTA}${right}${RESET}` at line 178. + +- [ ] **Step 2: Compute `spriteColor` once before the bubble loop** + +Find this block (around line 167): + +```typescript +// Colorize bubble lines — the bubble is plain text from renderSpeechBubble(). +// Left side = text bubble (borders + content), right side = sprite art after connector. +for (const line of bubbleLines) { +``` + +Insert before the `for` loop: + +```typescript +const bubbleSpriteColor = colorFor(buddy.species, buddy.rarity, buddy.xp); +for (const line of bubbleLines) { +``` + +- [ ] **Step 3: Replace the `MAGENTA` on line 178** + +Find this existing block (line ~176-180): + +```typescript +const coloredRight = isName + ? `${CYAN}${right}${RESET}` + : `${MAGENTA}${right}${RESET}`; +``` + +Replace with: + +```typescript +const coloredRight = isName + ? `${CYAN}${right}${RESET}` + : `${bubbleSpriteColor}${right}${RESET}`; +``` + +- [ ] **Step 4: Build to verify TypeScript compiles** + +Run: `npm run build` +Expected: PASS. + +- [ ] **Step 5: Run the full test suite** + +Run: `npm test` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/statusline-wrapper.ts +git commit -m "feat(statusline): use colorFor for sprite art in bubble mode" +``` + +--- + +### Task 16: Integrate `colorFor` into `card.ts` (renderCard, hatch, rescue sprite reveals) + +**Files:** +- Modify: `src/lib/card.ts` +- Create: `src/__tests__/card-color.test.ts` + +- [ ] **Step 1: Write the failing integration test** + +Create `src/__tests__/card-color.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest'; +import { renderCard } from '../lib/card.js'; +import { colorFor } 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); + 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); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/__tests__/card-color.test.ts` +Expected: FAIL — card output does not contain colorFor escape codes (still plain text). + +- [ ] **Step 3: Wrap sprite lines in renderCard with colorFor** + +Open `src/lib/card.ts`. Find the existing imports at the top: + +```typescript +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'; +``` + +Add: + +```typescript +import { colorFor } from './color.js'; +import { RESET } from './ansi.js'; +``` + +Then find the `renderCard` function. Locate this line (around line 48): + +```typescript +...art.map(l => ln(l)), +``` + +Replace with: + +```typescript +...art.map(l => ln(`${colorFor(companion.species, companion.rarity, companion.xp)}${l}${RESET}`)), +``` + +Wait — `ln()` pads its argument to fit the card width. If we add ANSI escapes inside `ln`, the padding will be wrong (the visible width is shorter than the string length). The escape sequences must wrap the *result* of `ln`, not the input. + +Use this instead: + +```typescript +const spriteColor = colorFor(companion.species, companion.rarity, companion.xp); +const coloredArt = art.map(l => { + const padded = ln(l); // produces e.g. '| ...art... |' + // Wrap only the art portion (between the borders) in color. + // ln output structure: '| ' + padded(inner) + ' |' + const prefix = '| '; + const suffix = ' |'; + const inner = padded.slice(prefix.length, padded.length - suffix.length); + return `${prefix}${spriteColor}${inner}${RESET}${suffix}`; +}); +``` + +Then change the `return` block. Replace this existing line: + +```typescript +...art.map(l => ln(l)), +``` + +With: + +```typescript +...coloredArt, +``` + +And insert the `coloredArt` declaration just before the `return` block (after the `bioLines` setup, before `return [`). + +- [ ] **Step 4: Run the integration test to verify it passes** + +Run: `npx vitest run src/__tests__/card-color.test.ts` +Expected: PASS — 3 tests. + +- [ ] **Step 5: Run existing card-related tests to verify no regressions** + +Run: `npx vitest run src/__tests__/animation-stability.test.ts` +Expected: PASS — animation stability test still works (it uses `stripAnsi` so embedded ANSI codes are handled). + +- [ ] **Step 6: Run the full test suite** + +Run: `npm test` +Expected: PASS — all tests. + +- [ ] **Step 7: Commit** + +```bash +git add src/lib/card.ts src/__tests__/card-color.test.ts +git commit -m "feat(card): colorize sprite lines via colorFor in renderCard" +``` + +--- + +### Task 17: Manual verification across terminal modes + +**Files:** (none — verification only) + +- [ ] **Step 1: Build the latest** + +Run: `npm run build` +Expected: PASS. + +- [ ] **Step 2: Restart Claude Code and hatch a fresh buddy** (or use existing `Deltaspark`) + +The MCP server was installed earlier with the dev install. Restart Claude Code so it loads the rebuilt `dist/`. Then in a new session: + +- Observe the statusline. Should show the buddy's sprite in a color computed from (species, rarity, xp), not plain MAGENTA. +- Call `buddy_status` and observe the returned card. Sprite lines should be colorized. + +- [ ] **Step 3: Verify each rarity produces a visibly different metal at Lv 50** (manual) + +If you can rapidly hatch / observe with rigged XP, check that Common → Iron-tinted, Legendary → Aurum + bold. Otherwise, eyeball at lower levels. + +- [ ] **Step 4: Verify NO_COLOR strips color** + +In a shell with `NO_COLOR=1` set: + +```bash +NO_COLOR=1 node dist/statusline-wrapper.js < some-status-input.json +``` + +Expected: sprite art appears with no ANSI escapes. + +- [ ] **Step 5: Verify cmd.exe (16-color) fallback works** + +Open plain `cmd.exe` (not Windows Terminal). Trigger statusline by running buddy via Claude Code from cmd.exe. Expected: sprite renders with one of the 8 base ANSI colors per buddy. Not pretty, but not broken. + +- [ ] **Step 6: Verify Windows Terminal (truecolor) renders the full gradient** + +Open Windows Terminal. Hatch several buddies of different species/rarities. Expected: each has a distinctly different colored sprite that shifts as XP accrues. + +- [ ] **Step 7: No commit — manual verification step** + +If verification reveals issues, fix them and commit the fix as a new task. Otherwise proceed. + +--- + +### Task 18: Push the branch and open the PR + +**Files:** (none — git/gh operations) + +- [ ] **Step 1: Confirm all changes are committed** + +Run: `git status` +Expected: working tree clean (or only pre-existing session-state files like `.npm-install.log`). + +- [ ] **Step 2: Review the commit log on the branch** + +Run: `git log master..HEAD --oneline` +Expected: ~17 commits, one per implementation task. + +- [ ] **Step 3: Push the branch** + +Run: `git push -u origin feature/color-progression` +Expected: branch published. + +- [ ] **Step 4: Open the PR with `gh`** + +Run: + +```bash +gh pr create --title "feat: buddy color progression (species × rarity × XP)" --body "$(cat <<'EOF' +## Summary +- Replace single MAGENTA sprite color with a 3-input gradient (species × rarity × XP) +- New `src/lib/color.ts` module: 21 species palettes (84 RGB anchors), 5 rarity metal tiers (10 anchors), saturation tint, bold weight at Rare+, truecolor/256-color/16-color/NO_COLOR fallbacks +- Integration: statusline-wrapper.ts (normal + bubble sprite modes) and card.ts (renderCard sprite lines) + +Spec: `docs/superpowers/specs/2026-05-12-buddy-color-progression-design.md` + +## Test plan +- [x] Unit tests for color math (lerp, interpolate, ramp, tint) — `src/__tests__/color.test.ts` +- [x] Capability detection tests for every env-var path +- [x] Snapshot fixtures for 15 representative (species, rarity, level) cases — `src/__tests__/color-snapshot.test.ts` +- [x] Card integration tests — `src/__tests__/card-color.test.ts` +- [x] Manual verification on Windows Terminal (truecolor), cmd.exe (16-color), NO_COLOR=1 + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +Expected: PR URL returned. + +- [ ] **Step 5: Done.** + +--- + +## Self-Review + +**Spec coverage check** (each spec section → task): + +- WHY (sprite is monochrome, rarity invisible) → motivation captured in plan goal. +- Goal (3-input function replacing MAGENTA) → Tasks 1, 12, 14, 15, 16. +- Model & Math (6 anchors, breakpoints, lerp) → Tasks 5, 6 (rampPosition, interpolateAnchors). +- Saturation tint → Task 7. +- Bold weight at Rare+ → Task 12 (`BOLD_RARITIES` set, prefix logic). +- Palette tables (21 species × 4, 5 × 2 metals) → Tasks 2, 3. +- Algorithm pseudocode → Tasks 4–8, 12. +- Terminal capability cascade (NO_COLOR → COLORTERM → WT_SESSION → TERM_PROGRAM → TERM suffix → 16-color fallback) → Task 9. +- rgbTo256, rgbToAnsi16 → Tasks 10, 11. +- Code architecture (new src/lib/color.ts, modify statusline-wrapper.ts, modify card.ts) → Tasks 1, 14, 15, 16. +- Tests (unit + snapshot + manual) → Tasks 1–13, 17. +- In-scope (all 21 species, all 5 rarities, capability fallbacks, bold, tint) → all tasks. +- Out-of-scope (shimmer, mood, RARITY_ANSI star recolor, README) → not present in plan ✓ matches. +- Risks (color blindness, terminal compat, bridge muddiness) → manual verification Task 17 covers terminal compat. Bridge muddiness left for post-launch tuning. + +**Placeholder scan:** No "TBD", "TODO", "implement later", "appropriate error handling", or "similar to task N." Every step has exact code or exact commands. ✓ + +**Type consistency:** +- `RGB` type — declared in Task 1, used in Tasks 2–8, 10, 11. +- `TerminalCapabilities` — declared in Task 1, used in Tasks 9, 12. +- `Rarity` — imported from `./types.js` consistently. +- Function names match across tasks: `clamp`, `lerpRGB`, `rampPosition`, `interpolateAnchors`, `applySaturationTint`, `computeRGB`, `detectCapabilities`, `rgbTo256`, `rgbToAnsi16`, `colorFor`. +- Constant names match: `SPECIES_PALETTES`, `FALLBACK_SPECIES_PALETTE`, `RARITY_METALS`, `RARITY_SATURATION`, `NEUTRAL_GRAY`, `BREAKPOINTS`, `BOLD_RARITIES`. ✓ + +**No gaps spotted.** Plan is ready for execution. From caf45c6640a86ede32ed4847fff4ac2930861a38 Mon Sep 17 00:00:00 2001 From: Dave Ambrose Date: Tue, 12 May 2026 20:18:12 -0400 Subject: [PATCH 04/25] feat(color): scaffold color module with types and NEUTRAL_GRAY --- src/__tests__/color.test.ts | 24 ++++++++++++++++++++++++ src/lib/color.ts | 14 ++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 src/__tests__/color.test.ts create mode 100644 src/lib/color.ts diff --git a/src/__tests__/color.test.ts b/src/__tests__/color.test.ts new file mode 100644 index 0000000..5286391 --- /dev/null +++ b/src/__tests__/color.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import type { RGB, TerminalCapabilities } from '../lib/color.js'; +import { NEUTRAL_GRAY } from '../lib/color.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); + }); +}); diff --git a/src/lib/color.ts b/src/lib/color.ts new file mode 100644 index 0000000..f45c60d --- /dev/null +++ b/src/lib/color.ts @@ -0,0 +1,14 @@ +// src/lib/color.ts — buddy color progression (species × rarity × XP → ANSI escape) +// +// See docs/superpowers/specs/2026-05-12-buddy-color-progression-design.md for the design. + +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]; From a16c67372b789e94d87d51c447e4680e260d10a6 Mon Sep 17 00:00:00 2001 From: Dave Ambrose Date: Tue, 12 May 2026 20:29:13 -0400 Subject: [PATCH 05/25] feat(color): add 21-species palette table --- src/__tests__/color.test.ts | 32 ++++++++++++++++++++++++++++++++ src/lib/color.ts | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/src/__tests__/color.test.ts b/src/__tests__/color.test.ts index 5286391..e208f97 100644 --- a/src/__tests__/color.test.ts +++ b/src/__tests__/color.test.ts @@ -1,6 +1,8 @@ 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 { SPECIES_LIST } from '../lib/species.js'; describe('color module — types and constants', () => { it('exports NEUTRAL_GRAY as RGB [128, 128, 128]', () => { @@ -22,3 +24,33 @@ describe('color module — types and constants', () => { 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); + }); +}); diff --git a/src/lib/color.ts b/src/lib/color.ts index f45c60d..f291dcf 100644 --- a/src/lib/color.ts +++ b/src/lib/color.ts @@ -12,3 +12,40 @@ export interface TerminalCapabilities { } 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], +]; From 0fd8e4d8fa6d48308f0d58ad6ccd0f3da791145e Mon Sep 17 00:00:00 2001 From: Dave Ambrose Date: Tue, 12 May 2026 20:34:15 -0400 Subject: [PATCH 06/25] feat(color): add rarity metal anchors and saturation tints --- src/__tests__/color.test.ts | 24 ++++++++++++++++++++++++ src/lib/color.ts | 23 +++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/__tests__/color.test.ts b/src/__tests__/color.test.ts index e208f97..863081a 100644 --- a/src/__tests__/color.test.ts +++ b/src/__tests__/color.test.ts @@ -2,7 +2,9 @@ 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 { SPECIES_LIST } from '../lib/species.js'; +import { RARITIES } from '../lib/types.js'; describe('color module — types and constants', () => { it('exports NEUTRAL_GRAY as RGB [128, 128, 128]', () => { @@ -54,3 +56,25 @@ describe('SPECIES_PALETTES', () => { 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); + }); +}); diff --git a/src/lib/color.ts b/src/lib/color.ts index f291dcf..abcf184 100644 --- a/src/lib/color.ts +++ b/src/lib/color.ts @@ -2,6 +2,8 @@ // // See docs/superpowers/specs/2026-05-12-buddy-color-progression-design.md for the design. +import type { Rarity } from './types.js'; + export type RGB = readonly [number, number, number]; export interface TerminalCapabilities { @@ -49,3 +51,24 @@ export const FALLBACK_SPECIES_PALETTE: readonly [RGB, RGB, RGB, RGB] = [ [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, +}; From 2bc3fa26de9a061489d955ffe511b54274bace9a Mon Sep 17 00:00:00 2001 From: Dave Ambrose Date: Tue, 12 May 2026 20:37:09 -0400 Subject: [PATCH 07/25] feat(color): add clamp and lerpRGB helpers --- src/__tests__/color.test.ts | 30 ++++++++++++++++++++++++++++++ src/lib/color.ts | 12 ++++++++++++ 2 files changed, 42 insertions(+) diff --git a/src/__tests__/color.test.ts b/src/__tests__/color.test.ts index 863081a..bcb73b0 100644 --- a/src/__tests__/color.test.ts +++ b/src/__tests__/color.test.ts @@ -3,6 +3,7 @@ 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 { SPECIES_LIST } from '../lib/species.js'; import { RARITIES } from '../lib/types.js'; @@ -78,3 +79,32 @@ describe('RARITY_METALS and RARITY_SATURATION', () => { 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); + }); +}); diff --git a/src/lib/color.ts b/src/lib/color.ts index abcf184..ad1b2a0 100644 --- a/src/lib/color.ts +++ b/src/lib/color.ts @@ -72,3 +72,15 @@ export const RARITY_SATURATION: Record = { 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), + ]; +} From 16978099148fed6a3f667a749e25355b0110fef5 Mon Sep 17 00:00:00 2001 From: Dave Ambrose Date: Tue, 12 May 2026 20:40:19 -0400 Subject: [PATCH 08/25] feat(color): add rampPosition to map XP onto [0, 1] curve --- src/__tests__/color.test.ts | 31 +++++++++++++++++++++++++++++++ src/lib/color.ts | 8 ++++++++ 2 files changed, 39 insertions(+) diff --git a/src/__tests__/color.test.ts b/src/__tests__/color.test.ts index bcb73b0..1e852e1 100644 --- a/src/__tests__/color.test.ts +++ b/src/__tests__/color.test.ts @@ -4,8 +4,10 @@ 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 { 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]', () => { @@ -108,3 +110,32 @@ describe('lerpRGB', () => { 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; + } + }); +}); diff --git a/src/lib/color.ts b/src/lib/color.ts index ad1b2a0..d041886 100644 --- a/src/lib/color.ts +++ b/src/lib/color.ts @@ -3,6 +3,7 @@ // See docs/superpowers/specs/2026-05-12-buddy-color-progression-design.md for the design. import type { Rarity } from './types.js'; +import { levelProgress } from './leveling.js'; export type RGB = readonly [number, number, number]; @@ -84,3 +85,10 @@ export function lerpRGB(a: RGB, b: RGB, t: number): RGB { 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); +} From 630212ccbbbf94d8b2504ba534a95ae5c235da0e Mon Sep 17 00:00:00 2001 From: Dave Ambrose Date: Tue, 12 May 2026 20:44:09 -0400 Subject: [PATCH 09/25] feat(color): add interpolateAnchors for piecewise RGB lerp --- src/__tests__/color.test.ts | 28 ++++++++++++++++++++++++++++ src/lib/color.ts | 14 ++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/src/__tests__/color.test.ts b/src/__tests__/color.test.ts index 1e852e1..b7a688d 100644 --- a/src/__tests__/color.test.ts +++ b/src/__tests__/color.test.ts @@ -5,6 +5,7 @@ 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 { SPECIES_LIST } from '../lib/species.js'; import { RARITIES } from '../lib/types.js'; import { totalXpForLevel } from '../lib/leveling.js'; @@ -139,3 +140,30 @@ describe('rampPosition', () => { } }); }); + +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]); + }); +}); diff --git a/src/lib/color.ts b/src/lib/color.ts index d041886..ffa46d6 100644 --- a/src/lib/color.ts +++ b/src/lib/color.ts @@ -92,3 +92,17 @@ export function rampPosition(totalXp: number): number { 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]!; +} From ce16f68ecafb377ab8e164c5a17667c1d72d3f4b Mon Sep 17 00:00:00 2001 From: Dave Ambrose Date: Tue, 12 May 2026 20:46:54 -0400 Subject: [PATCH 10/25] feat(color): add applySaturationTint rarity modulation --- src/__tests__/color.test.ts | 30 ++++++++++++++++++++++++++++++ src/lib/color.ts | 9 +++++++++ 2 files changed, 39 insertions(+) diff --git a/src/__tests__/color.test.ts b/src/__tests__/color.test.ts index b7a688d..b3d2ad6 100644 --- a/src/__tests__/color.test.ts +++ b/src/__tests__/color.test.ts @@ -6,6 +6,7 @@ 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 { SPECIES_LIST } from '../lib/species.js'; import { RARITIES } from '../lib/types.js'; import { totalXpForLevel } from '../lib/leveling.js'; @@ -167,3 +168,32 @@ describe('interpolateAnchors', () => { 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]); + }); +}); diff --git a/src/lib/color.ts b/src/lib/color.ts index ffa46d6..32619d4 100644 --- a/src/lib/color.ts +++ b/src/lib/color.ts @@ -106,3 +106,12 @@ export function interpolateAnchors( } 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), + ]; +} From 74d1e2d9c4dff091ebe5eaed1799289bd769f4e3 Mon Sep 17 00:00:00 2001 From: Dave Ambrose Date: Tue, 12 May 2026 20:49:10 -0400 Subject: [PATCH 11/25] feat(color): add computeRGB composition function --- src/__tests__/color.test.ts | 29 +++++++++++++++++++++++++++++ src/lib/color.ts | 16 ++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/__tests__/color.test.ts b/src/__tests__/color.test.ts index b3d2ad6..b1b8ac4 100644 --- a/src/__tests__/color.test.ts +++ b/src/__tests__/color.test.ts @@ -7,6 +7,7 @@ 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 { SPECIES_LIST } from '../lib/species.js'; import { RARITIES } from '../lib/types.js'; import { totalXpForLevel } from '../lib/leveling.js'; @@ -197,3 +198,31 @@ describe('applySaturationTint', () => { 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 + }); +}); diff --git a/src/lib/color.ts b/src/lib/color.ts index 32619d4..90647a6 100644 --- a/src/lib/color.ts +++ b/src/lib/color.ts @@ -115,3 +115,19 @@ export function applySaturationTint(rgb: RGB, factor: number): RGB { 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; + const metalAnchors = RARITY_METALS[rarity]; + + const anchors: RGB[] = [ + speciesAnchors[0], speciesAnchors[1], speciesAnchors[2], speciesAnchors[3], + metalAnchors[0], metalAnchors[1], + ]; + + const interpolated = interpolateAnchors(anchors, [...BREAKPOINTS], p); + return applySaturationTint(interpolated, RARITY_SATURATION[rarity]); +} From 0eb647647eda036802b6405ec9391e10662cf7c1 Mon Sep 17 00:00:00 2001 From: Dave Ambrose Date: Tue, 12 May 2026 20:51:50 -0400 Subject: [PATCH 12/25] test(color): add computeRGB tests for bridge and metal segments --- src/__tests__/color.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/__tests__/color.test.ts b/src/__tests__/color.test.ts index b1b8ac4..5c66bae 100644 --- a/src/__tests__/color.test.ts +++ b/src/__tests__/color.test.ts @@ -225,4 +225,22 @@ describe('computeRGB', () => { const result = computeRGB('Pegasus', 'uncommon', 0); // not a real species expect(result).toEqual([0x66, 0x66, 0x66]); // fallback anchor 0 }); + + 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]); + }); }); From 62bc21b466b7e30da4547948512127d3ba075ca0 Mon Sep 17 00:00:00 2001 From: Dave Ambrose Date: Tue, 12 May 2026 20:53:02 -0400 Subject: [PATCH 13/25] feat(color): add terminal capability detection cascade --- src/__tests__/color.test.ts | 66 +++++++++++++++++++++++++++++++++++++ src/lib/color.ts | 45 +++++++++++++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/src/__tests__/color.test.ts b/src/__tests__/color.test.ts index 5c66bae..3133b3e 100644 --- a/src/__tests__/color.test.ts +++ b/src/__tests__/color.test.ts @@ -8,6 +8,7 @@ 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 { SPECIES_LIST } from '../lib/species.js'; import { RARITIES } from '../lib/types.js'; import { totalXpForLevel } from '../lib/leveling.js'; @@ -244,3 +245,68 @@ describe('computeRGB', () => { 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); + }); +}); diff --git a/src/lib/color.ts b/src/lib/color.ts index 90647a6..e3cdfb7 100644 --- a/src/lib/color.ts +++ b/src/lib/color.ts @@ -131,3 +131,48 @@ export function computeRGB(species: string, rarity: Rarity, totalXp: number): RG const interpolated = interpolateAnchors(anchors, [...BREAKPOINTS], p); return applySaturationTint(interpolated, RARITY_SATURATION[rarity]); } + +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; +} From 5a1b5ffac052cdeb0defc5c2aee1bb1281657752 Mon Sep 17 00:00:00 2001 From: Dave Ambrose Date: Tue, 12 May 2026 20:56:14 -0400 Subject: [PATCH 14/25] feat(color): add rgbTo256 quantization for 256-color terminals --- src/__tests__/color.test.ts | 31 +++++++++++++++++++++++++++++++ src/lib/color.ts | 10 ++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/__tests__/color.test.ts b/src/__tests__/color.test.ts index 3133b3e..155f266 100644 --- a/src/__tests__/color.test.ts +++ b/src/__tests__/color.test.ts @@ -9,6 +9,7 @@ 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 { SPECIES_LIST } from '../lib/species.js'; import { RARITIES } from '../lib/types.js'; import { totalXpForLevel } from '../lib/leveling.js'; @@ -310,3 +311,33 @@ describe('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); + } + }); +}); diff --git a/src/lib/color.ts b/src/lib/color.ts index e3cdfb7..fe8cba3 100644 --- a/src/lib/color.ts +++ b/src/lib/color.ts @@ -176,3 +176,13 @@ export function detectCapabilities(env: NodeJS.ProcessEnv = process.env): Termin 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; +} From 4298217705fa1fc0feac57612ea079ac28bf1d89 Mon Sep 17 00:00:00 2001 From: Dave Ambrose Date: Wed, 13 May 2026 10:17:53 -0400 Subject: [PATCH 15/25] feat(color): add rgbToAnsi16 for 16-color terminal fallback --- src/__tests__/color.test.ts | 28 ++++++++++++++++++++++++++++ src/lib/color.ts | 24 ++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/src/__tests__/color.test.ts b/src/__tests__/color.test.ts index 155f266..a0eacc1 100644 --- a/src/__tests__/color.test.ts +++ b/src/__tests__/color.test.ts @@ -10,6 +10,7 @@ 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 { SPECIES_LIST } from '../lib/species.js'; import { RARITIES } from '../lib/types.js'; import { totalXpForLevel } from '../lib/leveling.js'; @@ -341,3 +342,30 @@ describe('rgbTo256', () => { } }); }); + +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'); + }); +}); diff --git a/src/lib/color.ts b/src/lib/color.ts index fe8cba3..d037623 100644 --- a/src/lib/color.ts +++ b/src/lib/color.ts @@ -186,3 +186,27 @@ export function rgbTo256(rgb: RGB): number { 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) +} From fc7751e9433dfddae5d76170abef04b003b1cbde Mon Sep 17 00:00:00 2001 From: Dave Ambrose Date: Wed, 13 May 2026 10:20:07 -0400 Subject: [PATCH 16/25] feat(color): add colorFor public API with capability-driven output --- src/__tests__/color.test.ts | 45 +++++++++++++++++++++++++++++++++++++ src/lib/color.ts | 29 ++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/src/__tests__/color.test.ts b/src/__tests__/color.test.ts index a0eacc1..dfdd226 100644 --- a/src/__tests__/color.test.ts +++ b/src/__tests__/color.test.ts @@ -11,6 +11,7 @@ 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'; @@ -369,3 +370,47 @@ describe('rgbToAnsi16', () => { 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$/); + }); +}); diff --git a/src/lib/color.ts b/src/lib/color.ts index d037623..529da3e 100644 --- a/src/lib/color.ts +++ b/src/lib/color.ts @@ -210,3 +210,32 @@ export function rgbToAnsi16(rgb: RGB): string { 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; +} + +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`; + } + return `${boldPrefix}${rgbToAnsi16(rgb)}`; +} From 3b4c813f2ba261f73fa9883ecfa77c9a34820c76 Mon Sep 17 00:00:00 2001 From: Dave Ambrose Date: Wed, 13 May 2026 10:22:02 -0400 Subject: [PATCH 17/25] test(color): snapshot fixtures lock visual contract for 15 cases --- .../__snapshots__/color-snapshot.test.ts.snap | 31 +++++++++++++ src/__tests__/color-snapshot.test.ts | 46 +++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 src/__tests__/__snapshots__/color-snapshot.test.ts.snap create mode 100644 src/__tests__/color-snapshot.test.ts 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__/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(); + }); + } +}); From 7a8df7f0d7020603622bcceb9b98fcb1196839a5 Mon Sep 17 00:00:00 2001 From: Dave Ambrose Date: Wed, 13 May 2026 10:27:53 -0400 Subject: [PATCH 18/25] feat(statusline): use colorFor for sprite art in normal mode --- src/statusline-wrapper.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/statusline-wrapper.ts b/src/statusline-wrapper.ts index 01526b5..cb26280 100644 --- a/src/statusline-wrapper.ts +++ b/src/statusline-wrapper.ts @@ -5,6 +5,7 @@ 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 { 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"; @@ -277,8 +278,9 @@ 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, buddy.xp); for (let i = 0; i < asciiLines.length; i++) { - const artPart = `${MAGENTA}${(asciiLines[i] || "").padEnd(artWidth)}${RESET}`; + const artPart = `${spriteColor}${(asciiLines[i] || "").padEnd(artWidth)}${RESET}`; if (i === 0) { buddyRight.push(`${artPart} ${nameInfo}`); } else if (i === 1) { From 974847189cfef72570a7342928175da3befafd1c Mon Sep 17 00:00:00 2001 From: Dave Ambrose Date: Wed, 13 May 2026 10:29:49 -0400 Subject: [PATCH 19/25] feat(statusline): use colorFor for sprite art in bubble mode --- src/statusline-wrapper.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/statusline-wrapper.ts b/src/statusline-wrapper.ts index cb26280..4fbe29f 100644 --- a/src/statusline-wrapper.ts +++ b/src/statusline-wrapper.ts @@ -4,7 +4,7 @@ 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"; @@ -167,6 +167,7 @@ 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, buddy.xp); for (const line of bubbleLines) { // Lines with " - " connector or " " gutter have sprite art on the right const connectorMatch = line.match(/^(.+?)( - | )(.+)$/); @@ -176,7 +177,7 @@ try { const isName = right.trim() === buddy.name; const coloredRight = isName ? `${CYAN}${right}${RESET}` - : `${MAGENTA}${right}${RESET}`; + : `${bubbleSpriteColor}${right}${RESET}`; const fadedLeft = isFading ? `${DIM}${DIM}${left}${RESET}` : `${DIM}${left}${RESET}`; buddyRight.push(`${fadedLeft}${DIM}${sep}${RESET}${coloredRight}`); } else { From ceaf14406d0bbf3d44511e1253e43fea9e422d4c Mon Sep 17 00:00:00 2001 From: Dave Ambrose Date: Wed, 13 May 2026 10:33:24 -0400 Subject: [PATCH 20/25] feat(card): colorize sprite lines via colorFor in renderCard --- src/__tests__/card-color.test.ts | 44 ++++++++++++++++++++++++++++++++ src/lib/card.ts | 13 +++++++++- 2 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/card-color.test.ts diff --git a/src/__tests__/card-color.test.ts b/src/__tests__/card-color.test.ts new file mode 100644 index 0000000..bb50cf1 --- /dev/null +++ b/src/__tests__/card-color.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { renderCard } from '../lib/card.js'; +import { colorFor } 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); + 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); + }); +}); diff --git a/src/lib/card.ts b/src/lib/card.ts index 322e0a4..3ece591 100644 --- a/src/lib/card.ts +++ b/src/lib/card.ts @@ -4,6 +4,8 @@ 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 } from './color.js'; +import { RESET } from './ansi.js'; /** * Render a bordered ASCII stat card for a companion. @@ -41,11 +43,20 @@ export function renderCard(companion: Companion): string { if (cur) bioLines.push(ln(' ' + cur)); } + const spriteColor = colorFor(companion.species, companion.rarity, companion.xp); + const coloredArt = art.map(l => { + const padded = ln(l); + const prefix = '| '; + const suffix = ' |'; + const inner = padded.slice(prefix.length, padded.length - suffix.length); + return `${prefix}${spriteColor}${inner}${RESET}${suffix}`; + }); + return [ topBorder, headerLine, emptyLine, - ...art.map(l => ln(l)), + ...coloredArt, emptyLine, ln(companion.name), ...(bioLines.length > 0 ? [emptyLine, ...bioLines] : []), From f31a3181861afe790b08c7c979e39426b9c1c088 Mon Sep 17 00:00:00 2001 From: Dave Ambrose Date: Wed, 13 May 2026 10:58:54 -0400 Subject: [PATCH 21/25] feat(share): colorize sprite PNG via computeRGB --- src/__tests__/share-color.test.ts | 64 +++++++++++++++++++++++++++++++ src/lib/color.ts | 2 +- src/lib/share.ts | 6 ++- 3 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/share-color.test.ts 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/color.ts b/src/lib/color.ts index 529da3e..849da14 100644 --- a/src/lib/color.ts +++ b/src/lib/color.ts @@ -218,7 +218,7 @@ function getDefaultCapabilities(): TerminalCapabilities { return cachedCaps; } -const BOLD_RARITIES: ReadonlySet = new Set(['rare', 'epic', 'legendary']); +export const BOLD_RARITIES: ReadonlySet = new Set(['rare', 'epic', 'legendary']); export function colorFor( species: string, 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)}

From 1b6c3d587b7894b2473aa80dc38ee71fe8e4d143 Mon Sep 17 00:00:00 2001 From: Dave Ambrose Date: Wed, 13 May 2026 11:46:31 -0400 Subject: [PATCH 22/25] chore: remove transient spec and plan docs Remove the design spec and implementation plan files that were used as working artifacts during development. They've served their purpose and the code is now the source of truth. Also strip the now-stale spec reference comment from src/lib/color.ts. --- .../2026-05-12-buddy-color-progression.md | 1654 ----------------- ...26-05-12-buddy-color-progression-design.md | 306 --- src/lib/color.ts | 2 - 3 files changed, 1962 deletions(-) delete mode 100644 docs/superpowers/plans/2026-05-12-buddy-color-progression.md delete mode 100644 docs/superpowers/specs/2026-05-12-buddy-color-progression-design.md diff --git a/docs/superpowers/plans/2026-05-12-buddy-color-progression.md b/docs/superpowers/plans/2026-05-12-buddy-color-progression.md deleted file mode 100644 index c07c780..0000000 --- a/docs/superpowers/plans/2026-05-12-buddy-color-progression.md +++ /dev/null @@ -1,1654 +0,0 @@ -# Buddy Color Progression Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace the single MAGENTA buddy sprite color with a three-input gradient (species × rarity × XP) so every buddy reads as a distinct, evolving color identity. - -**Architecture:** New `src/lib/color.ts` module exports a pure `colorFor(species, rarity, totalXp) → ansiEscape` function. It interpolates linearly across 6 RGB anchors (4 species + 2 rarity metal) along the level curve, applies a rarity-specific saturation tint, prepends ANSI bold for Rare+, and emits truecolor / 256-color / 16-color / NO_COLOR output based on terminal capability detection. `statusline-wrapper.ts` and `card.ts` swap their hard-coded MAGENTA for this function. - -**Tech Stack:** TypeScript (ESM, `.js` suffix imports), vitest for tests, Node.js 18+. - -**Spec:** `docs/superpowers/specs/2026-05-12-buddy-color-progression-design.md` - ---- - -### Task 0: Create the feature branch - -**Files:** (none — git operation) - -- [ ] **Step 1: Verify clean working tree on master** - -```bash -git status -``` - -Expected: on master, optional uncommitted changes from session work (`.claude/settings.local.json`, `package-lock.json`, `.npm-install.log`, `.superpowers/`) are OK but should not be staged. Spec commits are already on master. - -- [ ] **Step 2: Create and check out the feature branch** - -```bash -git checkout -b feature/color-progression -git branch --show-current -``` - -Expected output: `feature/color-progression` - -- [ ] **Step 3: No commit yet — branch created from current master tip** - ---- - -### Task 1: Scaffold `src/lib/color.ts` with types and stub exports - -**Files:** -- Create: `src/lib/color.ts` -- Create: `src/__tests__/color.test.ts` - -- [ ] **Step 1: Write the failing test for module structure** - -Create `src/__tests__/color.test.ts`: - -```typescript -import { describe, it, expect } from 'vitest'; -import type { RGB, TerminalCapabilities } from '../lib/color.js'; -import { NEUTRAL_GRAY } from '../lib/color.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); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `npx vitest run src/__tests__/color.test.ts` -Expected: FAIL — module `../lib/color.js` does not exist. - -- [ ] **Step 3: Create the module with types and `NEUTRAL_GRAY` constant** - -Create `src/lib/color.ts`: - -```typescript -// src/lib/color.ts — buddy color progression (species × rarity × XP → ANSI escape) -// -// See docs/superpowers/specs/2026-05-12-buddy-color-progression-design.md for the design. - -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]; -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `npx vitest run src/__tests__/color.test.ts` -Expected: PASS — 3 tests. - -- [ ] **Step 5: Commit** - -```bash -git add src/lib/color.ts src/__tests__/color.test.ts -git commit -m "feat(color): scaffold color module with types and NEUTRAL_GRAY" -``` - ---- - -### Task 2: Add `SPECIES_PALETTES` constant (21 species × 4 RGB anchors) - -**Files:** -- Modify: `src/lib/color.ts` -- Modify: `src/__tests__/color.test.ts` - -- [ ] **Step 1: Write the failing test for the palette table** - -Append to `src/__tests__/color.test.ts`: - -```typescript -import { SPECIES_PALETTES, FALLBACK_SPECIES_PALETTE } from '../lib/color.js'; -import { SPECIES_LIST } from '../lib/species.js'; - -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); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `npx vitest run src/__tests__/color.test.ts -t "SPECIES_PALETTES"` -Expected: FAIL — `SPECIES_PALETTES` not exported. - -- [ ] **Step 3: Add the palette table (transcribe RGB values from spec section "Species palettes")** - -Append to `src/lib/color.ts`: - -```typescript -// 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], -]; -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `npx vitest run src/__tests__/color.test.ts -t "SPECIES_PALETTES"` -Expected: PASS — 4 tests. - -- [ ] **Step 5: Commit** - -```bash -git add src/lib/color.ts src/__tests__/color.test.ts -git commit -m "feat(color): add 21-species palette table" -``` - ---- - -### Task 3: Add `RARITY_METALS` and `RARITY_SATURATION` constants - -**Files:** -- Modify: `src/lib/color.ts` -- Modify: `src/__tests__/color.test.ts` - -- [ ] **Step 1: Write the failing test** - -Append to `src/__tests__/color.test.ts`: - -```typescript -import { RARITY_METALS, RARITY_SATURATION } from '../lib/color.js'; -import { RARITIES } from '../lib/types.js'; - -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); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `npx vitest run src/__tests__/color.test.ts -t "RARITY_METALS"` -Expected: FAIL — `RARITY_METALS` / `RARITY_SATURATION` not exported. - -- [ ] **Step 3: Add the constants** - -Append to `src/lib/color.ts`: - -```typescript -import type { Rarity } from './types.js'; - -// 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, -}; -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `npx vitest run src/__tests__/color.test.ts -t "RARITY_METALS"` -Expected: PASS — 3 tests. - -- [ ] **Step 5: Commit** - -```bash -git add src/lib/color.ts src/__tests__/color.test.ts -git commit -m "feat(color): add rarity metal anchors and saturation tints" -``` - ---- - -### Task 4: Implement `clamp` and `lerpRGB` helpers - -**Files:** -- Modify: `src/lib/color.ts` -- Modify: `src/__tests__/color.test.ts` - -- [ ] **Step 1: Write the failing tests** - -Append to `src/__tests__/color.test.ts`: - -```typescript -import { clamp, lerpRGB } from '../lib/color.js'; - -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); - }); -}); -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `npx vitest run src/__tests__/color.test.ts -t "clamp"` -Expected: FAIL — `clamp` not exported. - -- [ ] **Step 3: Implement the helpers** - -Append to `src/lib/color.ts`: - -```typescript -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), - ]; -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `npx vitest run src/__tests__/color.test.ts -t "clamp\\|lerpRGB"` -Expected: PASS — 7 tests. - -- [ ] **Step 5: Commit** - -```bash -git add src/lib/color.ts src/__tests__/color.test.ts -git commit -m "feat(color): add clamp and lerpRGB helpers" -``` - ---- - -### Task 5: Implement `rampPosition` (XP → 0..1 curve position) - -**Files:** -- Modify: `src/lib/color.ts` -- Modify: `src/__tests__/color.test.ts` - -- [ ] **Step 1: Write the failing tests** - -Append to `src/__tests__/color.test.ts`: - -```typescript -import { rampPosition } from '../lib/color.js'; -import { totalXpForLevel } from '../lib/leveling.js'; - -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; - } - }); -}); -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `npx vitest run src/__tests__/color.test.ts -t "rampPosition"` -Expected: FAIL — `rampPosition` not exported. - -- [ ] **Step 3: Implement `rampPosition`** - -Append to `src/lib/color.ts`: - -```typescript -import { levelProgress } from './leveling.js'; - -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); -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `npx vitest run src/__tests__/color.test.ts -t "rampPosition"` -Expected: PASS — 5 tests. - -- [ ] **Step 5: Commit** - -```bash -git add src/lib/color.ts src/__tests__/color.test.ts -git commit -m "feat(color): add rampPosition to map XP onto [0, 1] curve" -``` - ---- - -### Task 6: Implement `interpolateAnchors` (multi-anchor piecewise lerp) - -**Files:** -- Modify: `src/lib/color.ts` -- Modify: `src/__tests__/color.test.ts` - -- [ ] **Step 1: Write the failing tests** - -Append to `src/__tests__/color.test.ts`: - -```typescript -import { interpolateAnchors } from '../lib/color.js'; - -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]); - }); -}); -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `npx vitest run src/__tests__/color.test.ts -t "interpolateAnchors"` -Expected: FAIL — `interpolateAnchors` not exported. - -- [ ] **Step 3: Implement `interpolateAnchors`** - -Append to `src/lib/color.ts`: - -```typescript -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]!; -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `npx vitest run src/__tests__/color.test.ts -t "interpolateAnchors"` -Expected: PASS — 4 tests. - -- [ ] **Step 5: Commit** - -```bash -git add src/lib/color.ts src/__tests__/color.test.ts -git commit -m "feat(color): add interpolateAnchors for piecewise RGB lerp" -``` - ---- - -### Task 7: Implement `applySaturationTint` - -**Files:** -- Modify: `src/lib/color.ts` -- Modify: `src/__tests__/color.test.ts` - -- [ ] **Step 1: Write the failing tests** - -Append to `src/__tests__/color.test.ts`: - -```typescript -import { applySaturationTint } from '../lib/color.js'; - -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]); - }); -}); -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `npx vitest run src/__tests__/color.test.ts -t "applySaturationTint"` -Expected: FAIL. - -- [ ] **Step 3: Implement `applySaturationTint`** - -Append to `src/lib/color.ts`: - -```typescript -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), - ]; -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `npx vitest run src/__tests__/color.test.ts -t "applySaturationTint"` -Expected: PASS — 5 tests. - -- [ ] **Step 5: Commit** - -```bash -git add src/lib/color.ts src/__tests__/color.test.ts -git commit -m "feat(color): add applySaturationTint rarity modulation" -``` - ---- - -### Task 8: Implement `computeRGB` (composition of all the math) - -**Files:** -- Modify: `src/lib/color.ts` -- Modify: `src/__tests__/color.test.ts` - -- [ ] **Step 1: Write the failing tests** - -Append to `src/__tests__/color.test.ts`: - -```typescript -import { computeRGB } from '../lib/color.js'; - -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 - }); -}); -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `npx vitest run src/__tests__/color.test.ts -t "computeRGB"` -Expected: FAIL. - -- [ ] **Step 3: Implement `computeRGB`** - -Append to `src/lib/color.ts`: - -```typescript -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; - const metalAnchors = RARITY_METALS[rarity]; - - const anchors: RGB[] = [ - speciesAnchors[0], speciesAnchors[1], speciesAnchors[2], speciesAnchors[3], - metalAnchors[0], metalAnchors[1], - ]; - - const interpolated = interpolateAnchors(anchors, [...BREAKPOINTS], p); - return applySaturationTint(interpolated, RARITY_SATURATION[rarity]); -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `npx vitest run src/__tests__/color.test.ts -t "computeRGB"` -Expected: PASS — 4 tests. - -- [ ] **Step 5: Commit** - -```bash -git add src/lib/color.ts src/__tests__/color.test.ts -git commit -m "feat(color): add computeRGB composition function" -``` - ---- - -### Task 9: Implement `detectCapabilities` (terminal capability cascade) - -**Files:** -- Modify: `src/lib/color.ts` -- Modify: `src/__tests__/color.test.ts` - -- [ ] **Step 1: Write the failing tests** - -Append to `src/__tests__/color.test.ts`: - -```typescript -import { detectCapabilities } from '../lib/color.js'; - -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); - }); -}); -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `npx vitest run src/__tests__/color.test.ts -t "detectCapabilities"` -Expected: FAIL. - -- [ ] **Step 3: Implement `detectCapabilities`** - -Append to `src/lib/color.ts`: - -```typescript -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; -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `npx vitest run src/__tests__/color.test.ts -t "detectCapabilities"` -Expected: PASS — 12 tests. - -- [ ] **Step 5: Commit** - -```bash -git add src/lib/color.ts src/__tests__/color.test.ts -git commit -m "feat(color): add terminal capability detection cascade" -``` - ---- - -### Task 10: Implement `rgbTo256` (truecolor → 256-color quantization) - -**Files:** -- Modify: `src/lib/color.ts` -- Modify: `src/__tests__/color.test.ts` - -- [ ] **Step 1: Write the failing tests** - -Append to `src/__tests__/color.test.ts`: - -```typescript -import { rgbTo256 } from '../lib/color.js'; - -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); - } - }); -}); -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `npx vitest run src/__tests__/color.test.ts -t "rgbTo256"` -Expected: FAIL. - -- [ ] **Step 3: Implement `rgbTo256`** - -Append to `src/lib/color.ts`: - -```typescript -// 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; -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `npx vitest run src/__tests__/color.test.ts -t "rgbTo256"` -Expected: PASS — 6 tests. - -- [ ] **Step 5: Commit** - -```bash -git add src/lib/color.ts src/__tests__/color.test.ts -git commit -m "feat(color): add rgbTo256 quantization for 256-color terminals" -``` - ---- - -### Task 11: Implement `rgbToAnsi16` (truecolor → 8-base-hue fallback) - -**Files:** -- Modify: `src/lib/color.ts` -- Modify: `src/__tests__/color.test.ts` - -- [ ] **Step 1: Write the failing tests** - -Append to `src/__tests__/color.test.ts`: - -```typescript -import { rgbToAnsi16 } from '../lib/color.js'; - -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'); - }); -}); -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `npx vitest run src/__tests__/color.test.ts -t "rgbToAnsi16"` -Expected: FAIL. - -- [ ] **Step 3: Implement `rgbToAnsi16`** - -Append to `src/lib/color.ts`: - -```typescript -// 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) -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `npx vitest run src/__tests__/color.test.ts -t "rgbToAnsi16"` -Expected: PASS — 8 tests. - -- [ ] **Step 5: Commit** - -```bash -git add src/lib/color.ts src/__tests__/color.test.ts -git commit -m "feat(color): add rgbToAnsi16 for 16-color terminal fallback" -``` - ---- - -### Task 12: Implement `colorFor` (public API) - -**Files:** -- Modify: `src/lib/color.ts` -- Modify: `src/__tests__/color.test.ts` - -- [ ] **Step 1: Write the failing tests** - -Append to `src/__tests__/color.test.ts`: - -```typescript -import { colorFor } from '../lib/color.js'; - -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$/); - }); -}); -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `npx vitest run src/__tests__/color.test.ts -t "colorFor"` -Expected: FAIL. - -- [ ] **Step 3: Implement `colorFor` and a cached default detector** - -Append to `src/lib/color.ts`: - -```typescript -let cachedCaps: TerminalCapabilities | null = null; - -function getDefaultCapabilities(): TerminalCapabilities { - if (cachedCaps === null) cachedCaps = detectCapabilities(); - return cachedCaps; -} - -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`; - } - return `${boldPrefix}${rgbToAnsi16(rgb)}`; -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `npx vitest run src/__tests__/color.test.ts -t "colorFor"` -Expected: PASS — 9 tests. - -- [ ] **Step 5: Run the full color test file to verify nothing broke** - -Run: `npx vitest run src/__tests__/color.test.ts` -Expected: PASS — all 60+ tests. - -- [ ] **Step 6: Commit** - -```bash -git add src/lib/color.ts src/__tests__/color.test.ts -git commit -m "feat(color): add colorFor public API with capability-driven output" -``` - ---- - -### Task 13: Snapshot fixture tests (locked visual contract) - -**Files:** -- Create: `src/__tests__/color-snapshot.test.ts` - -- [ ] **Step 1: Write the snapshot fixture test** - -Create `src/__tests__/color-snapshot.test.ts`: - -```typescript -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[] = [ - // Each rarity at Lv 1 (start of ramp, species color tinted) - { 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' }, - - // Mid-ramp (species color 2 territory) - { 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' }, - - // Bridge zone (species → metal handoff) - { species: 'Penguin', rarity: 'rare', level: 35, label: 'rare-penguin-lv35-bridge' }, - - // Metal zone (Lv 40-50) per rarity - { 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' }, - - // Defensive: unknown species falls back gracefully - { 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(); - }); - } -}); -``` - -- [ ] **Step 2: Run the fixture test to GENERATE snapshots** - -Run: `npx vitest run src/__tests__/color-snapshot.test.ts -u` -Expected: PASS — snapshots written. A `__snapshots__` folder appears next to the test file with `color-snapshot.test.ts.snap`. - -- [ ] **Step 3: Run again WITHOUT `-u` to verify snapshots are stable** - -Run: `npx vitest run src/__tests__/color-snapshot.test.ts` -Expected: PASS — all 15 fixtures. - -- [ ] **Step 4: Spot-check the snapshot file** - -Run: `cat src/__tests__/__snapshots__/color-snapshot.test.ts.snap | head -30` -Expected: see entries like `exports['color fixtures (snapshot contract) > common-cactus-lv1 ... 1'] = '';` - -- [ ] **Step 5: Commit** - -```bash -git add src/__tests__/color-snapshot.test.ts src/__tests__/__snapshots__/ -git commit -m "test(color): snapshot fixtures lock visual contract for 15 cases" -``` - ---- - -### Task 14: Integrate `colorFor` into `statusline-wrapper.ts` (normal sprite mode, line 281) - -**Files:** -- Modify: `src/statusline-wrapper.ts` - -- [ ] **Step 1: Read current context around line 281** - -Run: `head -290 src/statusline-wrapper.ts | tail -20` -Expected: see the `for (let i = 0; i < asciiLines.length; i++) { const artPart = \`${MAGENTA}...\` ... }` block at lines ~279-291. - -- [ ] **Step 2: Add color.ts import at the top of the file** - -Locate the existing import line (around line 7): - -```typescript -import { RESET, DIM, CYAN, YELLOW, GREEN, MAGENTA, stripAnsi } from "./lib/ansi.js"; -``` - -Add immediately after it: - -```typescript -import { colorFor } from "./lib/color.js"; -``` - -- [ ] **Step 3: Replace the `MAGENTA` wrapping on line 281** - -Find the existing line 281: - -```typescript -const artPart = `${MAGENTA}${(asciiLines[i] || "").padEnd(artWidth)}${RESET}`; -``` - -Replace with: - -```typescript -const spriteColor = colorFor(buddy.species, buddy.rarity, buddy.xp); -const artPart = `${spriteColor}${(asciiLines[i] || "").padEnd(artWidth)}${RESET}`; -``` - -(Hoist `spriteColor` outside the per-line loop — compute once per sprite render to avoid re-running capability detection on every line. See refactor in Step 4.) - -- [ ] **Step 4: Hoist `spriteColor` outside the loop** - -Find the block at line 279: - -```typescript -const artWidth = Math.max(...asciiLines.map((l: string) => l.length)); -for (let i = 0; i < asciiLines.length; i++) { - const artPart = `${MAGENTA}${(asciiLines[i] || "").padEnd(artWidth)}${RESET}`; - ... -} -``` - -Replace the whole block with: - -```typescript -const artWidth = Math.max(...asciiLines.map((l: string) => l.length)); -const spriteColor = colorFor(buddy.species, buddy.rarity, buddy.xp); -for (let i = 0; i < asciiLines.length; i++) { - const artPart = `${spriteColor}${(asciiLines[i] || "").padEnd(artWidth)}${RESET}`; - if (i === 0) { - buddyRight.push(`${artPart} ${nameInfo}`); - } else if (i === 1) { - buddyRight.push(`${artPart} ${moodInfo}`); - } else if (i === 2 && ambientText) { - buddyRight.push(`${artPart} ${ambientText}`); - } else { - buddyRight.push(artPart); - } -} -``` - -- [ ] **Step 5: Build the project to verify TypeScript compiles** - -Run: `npm run build` -Expected: PASS — `tsc` exits 0, no type errors. - -- [ ] **Step 6: Run the full test suite (no regressions)** - -Run: `npm test` -Expected: PASS — all existing tests still pass; new color tests pass. - -- [ ] **Step 7: Commit** - -```bash -git add src/statusline-wrapper.ts -git commit -m "feat(statusline): use colorFor for sprite art in normal mode" -``` - ---- - -### Task 15: Integrate `colorFor` into `statusline-wrapper.ts` (bubble mode, line 178) - -**Files:** -- Modify: `src/statusline-wrapper.ts` - -- [ ] **Step 1: Locate the bubble sprite block (line ~169-185)** - -Run: `sed -n '167,185p' src/statusline-wrapper.ts` - -Expected: see the bubble line loop with `${MAGENTA}${right}${RESET}` at line 178. - -- [ ] **Step 2: Compute `spriteColor` once before the bubble loop** - -Find this block (around line 167): - -```typescript -// Colorize bubble lines — the bubble is plain text from renderSpeechBubble(). -// Left side = text bubble (borders + content), right side = sprite art after connector. -for (const line of bubbleLines) { -``` - -Insert before the `for` loop: - -```typescript -const bubbleSpriteColor = colorFor(buddy.species, buddy.rarity, buddy.xp); -for (const line of bubbleLines) { -``` - -- [ ] **Step 3: Replace the `MAGENTA` on line 178** - -Find this existing block (line ~176-180): - -```typescript -const coloredRight = isName - ? `${CYAN}${right}${RESET}` - : `${MAGENTA}${right}${RESET}`; -``` - -Replace with: - -```typescript -const coloredRight = isName - ? `${CYAN}${right}${RESET}` - : `${bubbleSpriteColor}${right}${RESET}`; -``` - -- [ ] **Step 4: Build to verify TypeScript compiles** - -Run: `npm run build` -Expected: PASS. - -- [ ] **Step 5: Run the full test suite** - -Run: `npm test` -Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -git add src/statusline-wrapper.ts -git commit -m "feat(statusline): use colorFor for sprite art in bubble mode" -``` - ---- - -### Task 16: Integrate `colorFor` into `card.ts` (renderCard, hatch, rescue sprite reveals) - -**Files:** -- Modify: `src/lib/card.ts` -- Create: `src/__tests__/card-color.test.ts` - -- [ ] **Step 1: Write the failing integration test** - -Create `src/__tests__/card-color.test.ts`: - -```typescript -import { describe, it, expect } from 'vitest'; -import { renderCard } from '../lib/card.js'; -import { colorFor } 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); - 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); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `npx vitest run src/__tests__/card-color.test.ts` -Expected: FAIL — card output does not contain colorFor escape codes (still plain text). - -- [ ] **Step 3: Wrap sprite lines in renderCard with colorFor** - -Open `src/lib/card.ts`. Find the existing imports at the top: - -```typescript -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'; -``` - -Add: - -```typescript -import { colorFor } from './color.js'; -import { RESET } from './ansi.js'; -``` - -Then find the `renderCard` function. Locate this line (around line 48): - -```typescript -...art.map(l => ln(l)), -``` - -Replace with: - -```typescript -...art.map(l => ln(`${colorFor(companion.species, companion.rarity, companion.xp)}${l}${RESET}`)), -``` - -Wait — `ln()` pads its argument to fit the card width. If we add ANSI escapes inside `ln`, the padding will be wrong (the visible width is shorter than the string length). The escape sequences must wrap the *result* of `ln`, not the input. - -Use this instead: - -```typescript -const spriteColor = colorFor(companion.species, companion.rarity, companion.xp); -const coloredArt = art.map(l => { - const padded = ln(l); // produces e.g. '| ...art... |' - // Wrap only the art portion (between the borders) in color. - // ln output structure: '| ' + padded(inner) + ' |' - const prefix = '| '; - const suffix = ' |'; - const inner = padded.slice(prefix.length, padded.length - suffix.length); - return `${prefix}${spriteColor}${inner}${RESET}${suffix}`; -}); -``` - -Then change the `return` block. Replace this existing line: - -```typescript -...art.map(l => ln(l)), -``` - -With: - -```typescript -...coloredArt, -``` - -And insert the `coloredArt` declaration just before the `return` block (after the `bioLines` setup, before `return [`). - -- [ ] **Step 4: Run the integration test to verify it passes** - -Run: `npx vitest run src/__tests__/card-color.test.ts` -Expected: PASS — 3 tests. - -- [ ] **Step 5: Run existing card-related tests to verify no regressions** - -Run: `npx vitest run src/__tests__/animation-stability.test.ts` -Expected: PASS — animation stability test still works (it uses `stripAnsi` so embedded ANSI codes are handled). - -- [ ] **Step 6: Run the full test suite** - -Run: `npm test` -Expected: PASS — all tests. - -- [ ] **Step 7: Commit** - -```bash -git add src/lib/card.ts src/__tests__/card-color.test.ts -git commit -m "feat(card): colorize sprite lines via colorFor in renderCard" -``` - ---- - -### Task 17: Manual verification across terminal modes - -**Files:** (none — verification only) - -- [ ] **Step 1: Build the latest** - -Run: `npm run build` -Expected: PASS. - -- [ ] **Step 2: Restart Claude Code and hatch a fresh buddy** (or use existing `Deltaspark`) - -The MCP server was installed earlier with the dev install. Restart Claude Code so it loads the rebuilt `dist/`. Then in a new session: - -- Observe the statusline. Should show the buddy's sprite in a color computed from (species, rarity, xp), not plain MAGENTA. -- Call `buddy_status` and observe the returned card. Sprite lines should be colorized. - -- [ ] **Step 3: Verify each rarity produces a visibly different metal at Lv 50** (manual) - -If you can rapidly hatch / observe with rigged XP, check that Common → Iron-tinted, Legendary → Aurum + bold. Otherwise, eyeball at lower levels. - -- [ ] **Step 4: Verify NO_COLOR strips color** - -In a shell with `NO_COLOR=1` set: - -```bash -NO_COLOR=1 node dist/statusline-wrapper.js < some-status-input.json -``` - -Expected: sprite art appears with no ANSI escapes. - -- [ ] **Step 5: Verify cmd.exe (16-color) fallback works** - -Open plain `cmd.exe` (not Windows Terminal). Trigger statusline by running buddy via Claude Code from cmd.exe. Expected: sprite renders with one of the 8 base ANSI colors per buddy. Not pretty, but not broken. - -- [ ] **Step 6: Verify Windows Terminal (truecolor) renders the full gradient** - -Open Windows Terminal. Hatch several buddies of different species/rarities. Expected: each has a distinctly different colored sprite that shifts as XP accrues. - -- [ ] **Step 7: No commit — manual verification step** - -If verification reveals issues, fix them and commit the fix as a new task. Otherwise proceed. - ---- - -### Task 18: Push the branch and open the PR - -**Files:** (none — git/gh operations) - -- [ ] **Step 1: Confirm all changes are committed** - -Run: `git status` -Expected: working tree clean (or only pre-existing session-state files like `.npm-install.log`). - -- [ ] **Step 2: Review the commit log on the branch** - -Run: `git log master..HEAD --oneline` -Expected: ~17 commits, one per implementation task. - -- [ ] **Step 3: Push the branch** - -Run: `git push -u origin feature/color-progression` -Expected: branch published. - -- [ ] **Step 4: Open the PR with `gh`** - -Run: - -```bash -gh pr create --title "feat: buddy color progression (species × rarity × XP)" --body "$(cat <<'EOF' -## Summary -- Replace single MAGENTA sprite color with a 3-input gradient (species × rarity × XP) -- New `src/lib/color.ts` module: 21 species palettes (84 RGB anchors), 5 rarity metal tiers (10 anchors), saturation tint, bold weight at Rare+, truecolor/256-color/16-color/NO_COLOR fallbacks -- Integration: statusline-wrapper.ts (normal + bubble sprite modes) and card.ts (renderCard sprite lines) - -Spec: `docs/superpowers/specs/2026-05-12-buddy-color-progression-design.md` - -## Test plan -- [x] Unit tests for color math (lerp, interpolate, ramp, tint) — `src/__tests__/color.test.ts` -- [x] Capability detection tests for every env-var path -- [x] Snapshot fixtures for 15 representative (species, rarity, level) cases — `src/__tests__/color-snapshot.test.ts` -- [x] Card integration tests — `src/__tests__/card-color.test.ts` -- [x] Manual verification on Windows Terminal (truecolor), cmd.exe (16-color), NO_COLOR=1 - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - -Expected: PR URL returned. - -- [ ] **Step 5: Done.** - ---- - -## Self-Review - -**Spec coverage check** (each spec section → task): - -- WHY (sprite is monochrome, rarity invisible) → motivation captured in plan goal. -- Goal (3-input function replacing MAGENTA) → Tasks 1, 12, 14, 15, 16. -- Model & Math (6 anchors, breakpoints, lerp) → Tasks 5, 6 (rampPosition, interpolateAnchors). -- Saturation tint → Task 7. -- Bold weight at Rare+ → Task 12 (`BOLD_RARITIES` set, prefix logic). -- Palette tables (21 species × 4, 5 × 2 metals) → Tasks 2, 3. -- Algorithm pseudocode → Tasks 4–8, 12. -- Terminal capability cascade (NO_COLOR → COLORTERM → WT_SESSION → TERM_PROGRAM → TERM suffix → 16-color fallback) → Task 9. -- rgbTo256, rgbToAnsi16 → Tasks 10, 11. -- Code architecture (new src/lib/color.ts, modify statusline-wrapper.ts, modify card.ts) → Tasks 1, 14, 15, 16. -- Tests (unit + snapshot + manual) → Tasks 1–13, 17. -- In-scope (all 21 species, all 5 rarities, capability fallbacks, bold, tint) → all tasks. -- Out-of-scope (shimmer, mood, RARITY_ANSI star recolor, README) → not present in plan ✓ matches. -- Risks (color blindness, terminal compat, bridge muddiness) → manual verification Task 17 covers terminal compat. Bridge muddiness left for post-launch tuning. - -**Placeholder scan:** No "TBD", "TODO", "implement later", "appropriate error handling", or "similar to task N." Every step has exact code or exact commands. ✓ - -**Type consistency:** -- `RGB` type — declared in Task 1, used in Tasks 2–8, 10, 11. -- `TerminalCapabilities` — declared in Task 1, used in Tasks 9, 12. -- `Rarity` — imported from `./types.js` consistently. -- Function names match across tasks: `clamp`, `lerpRGB`, `rampPosition`, `interpolateAnchors`, `applySaturationTint`, `computeRGB`, `detectCapabilities`, `rgbTo256`, `rgbToAnsi16`, `colorFor`. -- Constant names match: `SPECIES_PALETTES`, `FALLBACK_SPECIES_PALETTE`, `RARITY_METALS`, `RARITY_SATURATION`, `NEUTRAL_GRAY`, `BREAKPOINTS`, `BOLD_RARITIES`. ✓ - -**No gaps spotted.** Plan is ready for execution. diff --git a/docs/superpowers/specs/2026-05-12-buddy-color-progression-design.md b/docs/superpowers/specs/2026-05-12-buddy-color-progression-design.md deleted file mode 100644 index 441eabb..0000000 --- a/docs/superpowers/specs/2026-05-12-buddy-color-progression-design.md +++ /dev/null @@ -1,306 +0,0 @@ -# Buddy Color Progression — Design - -**Date:** 2026-05-12 -**Status:** Brainstormed, awaiting review - -## Why - -Today every buddy in the statusline is rendered in the same MAGENTA color (`src/lib/ansi.ts:9`, applied at `src/statusline-wrapper.ts:281` for sprite art and `src/statusline-wrapper.ts:178` for the reaction-bubble sprite). Rarity barely shows: a single colored star at the end of the mood line via `RARITY_ANSI` (`src/lib/types.ts:63`). - -Two things suffer: - -1. **Species feels invisible.** A Void Cat looks the same color as a Cactus. Species identity exists in the ASCII shape but not in the eye-grabbing color signal. -2. **Rarity barely matters.** Rolling a Legendary (1% drop rate per `RARITY_WEIGHTS` in `src/lib/types.ts:39`) gets you one more star and a slightly different star color. The visceral "I got something rare" moment is muted. - -This design replaces the single MAGENTA with a three-input pure function `colorFor(species, rarity, totalXp) → RGB` whose output drifts continuously as the buddy levels up. 21 species × 5 rarities = 105 distinct (species, rarity) color journeys; every XP point nudges the displayed hue. - -## Goal - -Replace MAGENTA in the statusline sprite (and the stat card returned by hatch / status / rescue) with a color computed from three inputs: - -- **species** — defines a 4-color thematic ramp riding Lv 1 through Lv ~40 (the journey) -- **rarity** — defines the 2-color metal anchor at Lv 40–50 (the destination) and a saturation tint applied across the entire ramp -- **total XP** — drives a continuous position along the curve, every observe shifts the color - -## Model - -### The math - -Let `p ∈ [0, 1]` be the buddy's position on the level curve: - -``` -p = clamp((level - 1 + progressInLevel) / 49, 0, 1) -``` - -where `level` and `progressInLevel` come from existing `levelProgress(totalXp)` in `src/lib/leveling.ts`. At Lv 1 with 0 XP, `p = 0`. At Lv 50, `p = 1`. - -There are **6 color anchors** distributed along `p`: - -| Index | Source | Position p | Level (approx) | -|------:|--------|-----------:|---------------:| -| 0 | species color 1 | 0.0 | 1 | -| 1 | species color 2 | 0.2 | 10 | -| 2 | species color 3 | 0.4 | 20 | -| 3 | species color 4 | 0.6 | 30 | -| 4 | rarity metal 1 | 0.8 | 40 | -| 5 | rarity metal 2 | 1.0 | 50 | - -The function interpolates linearly in RGB between adjacent anchors. The transition from species color 4 → metal 1 (Lv 30 → 40) is a smooth bridge — no plateau, no hard cut. - -### Saturation tint by rarity - -After interpolation, the color is modulated by a rarity-specific saturation factor (applied as a "mix toward neutral gray" — simple linear blend, no HSL conversion needed). The tint applies **uniformly** across the entire ramp — both species and metal segments. This means a Legendary's metal anchors get the +20% boost (visibly extra-saturated metal) while a Common's metal anchors get -15% (visibly muted iron). This is intentional: rarity reads consistently from Lv 1 through Lv 50. - -``` -final = mix(neutralGray, interpolated, satFactor) -``` - -where: - -| Rarity | `satFactor` | Effect | -|--------|------------:|--------| -| Common | 0.85 | -15% toward gray (muted) | -| Uncommon | 1.00 | unchanged | -| Rare | 1.05 | +5% boost | -| Epic | 1.12 | +12% boost | -| Legendary | 1.20 | +20% boost (visual "glow") | - -`neutralGray` is `rgb(128, 128, 128)`. Multiplying away from gray brightens, toward gray mutes. A satFactor > 1 extrapolates beyond the original (clamped to `[0, 255]` per channel). - -### Bold weight at Rare+ - -Rare, Epic, and Legendary buddies also have the ANSI bold attribute (`\x1b[1m`) prepended to their color escape. Common and Uncommon render in normal weight. This adds a second visual axis for rarity, works universally (every terminal supports bold), and costs nothing. - -## Palette tables - -All 94 anchor colors as 24-bit RGB hex. These are **first-cut values** intended to be tunable during implementation without re-spec — the model is the contract; specific shades are advisory. - -### Species palettes — 21 species × 4 anchors = 84 colors - -| # | Species | Anchor 0 (Lv 1) | Anchor 1 (Lv 10) | Anchor 2 (Lv 20) | Anchor 3 (Lv 30) | Theme | -|--:|---------|-----------------|------------------|------------------|------------------|-------| -| 01 | Void Cat | `#1a1a2a` | `#4a3a6e` | `#c33a8e` | `#d6d6f0` | void → cosmic → nebula → starfield | -| 02 | Rust Hound | `#a04a2a` | `#d44a2e` | `#d68a3e` | `#b87a4a` | rust → ember → copper → iron | -| 03 | Data Drake | `#5fbb33` | `#4ad6c2` | `#e83a9c` | `#9c3aff` | terminal → cyan → laser → neon violet | -| 04 | Log Golem | `#5e4836` | `#5a7a3a` | `#7a7a7a` | `#8a9a6e` | bark → moss → stone → lichen | -| 05 | Cache Crow | `#2a2a2a` | `#6a6a76` | `#4a5aa8` | `#d6d6e6` | obsidian → silver → indigo → starlight | -| 06 | Shell Turtle | `#6e5236` | `#5a7a3a` | `#2e7a5a` | `#d68a3e` | shell → moss → emerald → amber | -| 07 | Duck | `#5a7a4a` | `#4a8a9a` | `#d68a3a` | `#f4c948` | pond → mallard → sunset → soft yellow | -| 08 | Goose | `#aaa9a3` | `#6a8aa8` | `#4a8a99` | `#7ec9c6` | pale gray → dusty blue → twilight → moonlight | -| 09 | Blob | `#5fbb33` | `#f4c948` | `#e83a9c` | `#9c3aff` | slime → toxic → neon pink → electric purple | -| 10 | Octopus | `#3d2a5a` | `#5d4cad` | `#3d8ad6` | `#3ed6c2` | abyss → tide → reef → shallows teal | -| 11 | Owl | `#5d4cad` | `#2a3a6e` | `#d6d4a6` | `#e8b04a` | twilight → midnight → moonglow → amber | -| 12 | Penguin | `#d4e4eb` | `#5d9cd6` | `#4ec5b9` | `#6cd99a` | ice → arctic → glacier → aurora | -| 13 | Snail | `#aaa9a3` | `#5a7a4a` | `#d4a6b9` | `#cfd9d4` | trail silver → pond → pearl pink → opal | -| 14 | Ghost | `#aaa9a3` | `#6a8aa8` | `#c4e4e6` | `#f0f0f0` | pale → faded blue → ethereal cyan → white glow | -| 15 | Axolotl | `#d68a8a` | `#e96a5a` | `#f4b6c2` | `#b6e4c2` | salmon → coral → blush → mint | -| 16 | Capybara | `#8a6a4a` | `#d68a4a` | `#e8c46a` | `#8aa66e` | warm brown → sunset → mellow → calm green | -| 17 | Cactus | `#9b8757` | `#5a8a3a` | `#c75d8a` | `#e8b04a` | desert sand → cactus green → bloom → desert gold | -| 18 | Robot | `#5a5a66` | `#3a8aa4` | `#5fbb33` | `#e8443e` | brushed steel → circuit cyan → terminal green → warning red | -| 19 | Rabbit | `#f4b6c2` | `#f4e6c4` | `#e8b06f` | `#f6f6f4` | pastel pink → cream → ear-tip orange → fluff white | -| 20 | Mushroom | `#5e4836` | `#8b6d4b` | `#c33a2e` | `#e8b06f` | forest floor → stem tan → red cap → spore glow | -| 21 | Chonk | `#e6d6b4` | `#d68a4a` | `#c4843e` | `#6e4a2a` | warm cream → tabby → sleepy amber → cozy brown | - -### Rarity metals — 5 rarities × 2 anchors = 10 colors - -Tier-break ladder ("rare should mean rare" — the visible jump is Uncommon → Rare): - -| Rarity | Metal 1 (Lv 40) | Metal 2 (Lv 50) | Material | -|--------|-----------------|-----------------|----------| -| Common ★ | `#6a6a6e` | `#8a8a8e` | Iron → Polished Iron | -| Uncommon ★★ | `#a86a3a` | `#b88a5e` | Copper → Patina Copper | -| **Rare ★★★** | `#c89a2e` | `#f4c948` | **Gold I → Gold II** *(the jump)* | -| Epic ★★★★ | `#8acdd9` | `#dceef4` | Diamond → Iridescent | -| Legendary ★★★★★ | `#cabc94` | `#f4eedc` | Aurum → Aurum Sheen | - -Common and Uncommon get utilitarian metals (Iron, Copper). The visible break to *precious* materials happens at Rare. This honors the actual drop rate (`RARITY_WEIGHTS` in `src/lib/types.ts`): Common 60%, Uncommon 25%, Rare 10%, Epic 4%, Legendary 1%. - -## Algorithm - -```typescript -// src/lib/color.ts - -type RGB = readonly [number, number, number]; - -interface TerminalCapabilities { - truecolor: boolean; - ansi256: boolean; - ansi16: boolean; - noColor: boolean; -} - -function colorFor( - species: string, - rarity: Rarity, - totalXp: number, - caps: TerminalCapabilities = detectCapabilities() -): string { - if (caps.noColor) return ''; - - const rgb = computeRGB(species, rarity, totalXp); - const boldPrefix = (rarity === 'rare' || rarity === 'epic' || rarity === 'legendary') - ? '\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`; - } - // ansi16 fallback — nearest of 8 base hues - return `${boldPrefix}${rgbToAnsi16(rgb)}`; -} - -function computeRGB(species: string, rarity: Rarity, totalXp: number): RGB { - const p = rampPosition(totalXp); - const speciesAnchors = SPECIES_PALETTES[species] ?? FALLBACK_SPECIES_PALETTE; - const metalAnchors = RARITY_METALS[rarity]; - - const anchors: RGB[] = [...speciesAnchors, ...metalAnchors]; - const breakpoints = [0, 0.2, 0.4, 0.6, 0.8, 1.0]; - - const interpolated = interpolateAnchors(anchors, breakpoints, p); - return applySaturationTint(interpolated, RARITY_SATURATION[rarity]); -} - -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); -} - -function interpolateAnchors(anchors: RGB[], breakpoints: number[], p: number): RGB { - for (let i = 1; i < breakpoints.length; i++) { - if (p <= breakpoints[i]) { - const t = (p - breakpoints[i - 1]) / (breakpoints[i] - breakpoints[i - 1]); - return lerpRGB(anchors[i - 1], anchors[i], t); - } - } - return anchors[anchors.length - 1]; -} - -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), - ]; -} - -function applySaturationTint(rgb: RGB, factor: number): RGB { - const GRAY = 128; - return [ - clamp(Math.round(GRAY + (rgb[0] - GRAY) * factor), 0, 255), - clamp(Math.round(GRAY + (rgb[1] - GRAY) * factor), 0, 255), - clamp(Math.round(GRAY + (rgb[2] - GRAY) * factor), 0, 255), - ]; -} -``` - -## Terminal capability & fallbacks - -Detect in this order (first match wins): - -1. **`process.env.NO_COLOR` defined** (any value, including empty) → `noColor: true`. Return empty string for every call. -2. **`process.env.COLORTERM === 'truecolor'` or `'24bit'`** → `truecolor: true`. Emit `\x1b[38;2;R;G;Bm`. -3. **`process.env.WT_SESSION` defined** (Windows Terminal) → `truecolor: true`. -4. **`process.env.TERM_PROGRAM` is `'iTerm.app'` or `'vscode'`** → `truecolor: true`. -5. **`process.env.TERM` ends in `-truecolor` or `-direct`** → `truecolor: true`. -6. **`process.env.TERM` ends in `-256color`** → `ansi256: true`. Emit `\x1b[38;5;Nm` where `N = rgbTo256(rgb)`. -7. **Otherwise** → `ansi16: true`. Emit one of `\x1b[3{0-7}m` by nearest-hue match. - -`rgbTo256`: standard 6×6×6 color cube formula — `16 + 36*r6 + 6*g6 + b6` where `r6, g6, b6 ∈ {0..5}`. Or use grayscale ramp `232..255` for near-gray colors. - -`rgbToAnsi16`: classify by dominant channel and brightness; map to one of the 8 base ANSI hues (red, green, yellow, blue, magenta, cyan, white, black). Coarse but functional. - -## Code architecture - -### New file: `src/lib/color.ts` - -Exports: -- `colorFor(species, rarity, totalXp, caps?) → string` — primary public API -- `detectCapabilities() → TerminalCapabilities` — cached on first call -- `SPECIES_PALETTES: Record` — the 21-species table -- `RARITY_METALS: Record` — the 5-rarity table -- `RARITY_SATURATION: Record` — the tint factors -- `FALLBACK_SPECIES_PALETTE: [RGB, RGB, RGB, RGB]` — generic ramp for unknown species (defensive) - -Internal helpers: `computeRGB`, `rampPosition`, `interpolateAnchors`, `lerpRGB`, `applySaturationTint`, `rgbTo256`, `rgbToAnsi16`, `clamp`. - -### Modify: `src/statusline-wrapper.ts` - -- **Line 178** (reaction-bubble sprite right-side): replace `${MAGENTA}${right}${RESET}` with `${colorFor(buddy.species, buddy.rarity, buddy.xp)}${right}${RESET}`. -- **Line 281** (sprite art in normal mode): replace `${MAGENTA}${(asciiLines[i] || "").padEnd(artWidth)}${RESET}` with `${colorFor(...)}${...}${RESET}`. -- The buddy's user-given *name* on line 154 (the `CYAN`-wrapped `buddy.name`) **stays CYAN** — names are identity text the user picks/reads, not sprite art. Coloring them with the gradient would obscure readability. -- The species name in parens (`${DIM}(${buddy.species})${RESET}`) **stays DIM**. -- `MAGENTA` import stays in the imports — other code may still reference it. - -### Modify: `src/lib/card.ts` - -- `renderCard()`: wrap each sprite line (line 48: `...art.map(l => ln(l))`) in `colorFor(companion.species, companion.rarity, companion.xp)` ... `RESET`. The card header (rarity stars + species label) keeps its current treatment. -- `hatchAnimation()` and `rescueAnimation()`: the `hatched` / `rescued` reveal block (egg-cracked sprite with sparkles) gets the same wrapping around the `...art` lines. - -### Untouched - -- `src/lib/ansi.ts` — keep `MAGENTA` exported; other consumers may still use it. -- `src/lib/types.ts` — `RARITY_ANSI` (star colors) stays as-is. -- `src/lib/species.ts` — `renderSprite()` continues to return plain ASCII; coloring happens at the integration sites. - -## Testing - -### Unit tests (`src/__tests__/color.test.ts` — new) - -- **Ramp position math:** `rampPosition(0) === 0`, `rampPosition(xpForLv50) === 1`, monotonically increasing. -- **Anchor interpolation:** at exact breakpoints, returns the anchor; at midpoints, returns the linear midpoint. -- **Saturation tint:** `factor=1.0` is identity; `factor=0` returns `(128, 128, 128)`; `factor>1` extrapolates and clamps. -- **`colorFor` end-to-end:** known (species, rarity, xp) → expected ANSI escape (snapshot table of 10–15 representative cases covering each rarity and a mix of species and levels). -- **Terminal capability detection:** mock `process.env` for each branch — NO_COLOR, COLORTERM=truecolor, WT_SESSION, TERM=-256color, TERM=xterm. Each maps to expected mode. -- **Bold weight:** Rare/Epic/Legendary include `\x1b[1m`; Common/Uncommon do not. -- **NO_COLOR:** returns empty string regardless of other inputs. -- **Unknown species:** falls back to `FALLBACK_SPECIES_PALETTE` without throwing. - -### Snapshot tests - -- A fixture of 20 (species, rarity, xp) combos rendered to ANSI strings; committed and snapshot-asserted to lock the visual contract. - -### Manual verification - -- Hatch a Common Cactus at Lv 1; observe color = Iron-tinted desert sand (muted). -- Grind to Lv 10; observe color drift toward cactus green. -- Hatch a Legendary Octopus at Lv 1; observe color = abyss with +20% saturation glow + bold. -- Force `NO_COLOR=1` and re-run statusline; observe plain ASCII. -- Run on plain `cmd.exe` (no `WT_SESSION`) and observe 16-color fallback. - -## In scope (this PR) - -- All 21 species palettes (4 RGB anchors each). -- All 5 rarity metal anchors (2 RGB each). -- The `colorFor` function with truecolor / 256-color / 16-color / NO_COLOR paths. -- Bold weight at Rare+. -- Saturation tint by rarity. -- Integration at the three call sites (statusline normal, statusline bubble, card / hatch / rescue). -- Unit + snapshot tests as above. - -## Out of scope (deferred) - -- **Shimmer / prismatic animation** at Epic+ and Legendary. Statusline refreshes at 2s, which would produce a slow pulse rather than a shimmer. Revisit if refresh rate becomes faster. -- **Mood-driven color shifts.** Composable later — `applyMoodModulation(rgb, mood)` could slot in. -- **Updating `RARITY_ANSI` star colors** to match the new metal palette. Separable polish. -- **README screenshots** showing the progression. Doc task. -- **Hatch animation egg coloring** (the cracked-egg frames before the buddy reveals). -- **Rarity-drop balance changes.** Drop rates stay where they are. - -## Risks & mitigations - -- **Color blindness.** Color is supplementary — the level number (`Lv.5`, `Lv.50 MAX`) and rarity star count remain visible plain-text. `NO_COLOR` env strips everything for users who prefer it. -- **Terminal compatibility surprises.** Detection cascades through five env vars; defaults to 16-color fallback (safe). Manual verification on Windows Terminal, cmd.exe, plain bash, and a `NO_COLOR=1` run is in the test plan. -- **First-cut palette shades may not feel right in practice.** RGB values are not load-bearing on the design — they are tunable during implementation. The model (3 inputs, 6 anchors, saturation tint, bold weight) is the contract. -- **Adding color to the stat card changes a returned MCP tool result.** The card is shown verbatim by the host LLM in a code block; ANSI escapes may render as raw text in some hosts. Mitigation: the stat card already lives in Claude Code which strips/displays ANSI correctly; for other hosts the worst case is visible escape codes (functional but ugly). Acceptable for v1. -- **Species color 4 → Metal 1 bridge (Lv 30–40) may look muddy for some (species, rarity) combinations.** E.g., a Common Penguin transitions from `aurora green` to `iron gray` over 10 levels — the midpoint will be a desaturated greenish-gray. This is expected; the per-species palettes can be tuned during implementation so the final species color foreshadows the rarity's metals (e.g., shift Penguin's Anchor 3 cooler to ease the iron bridge). 105 bridges to hand-tune is overkill — accept some muddiness in v1 and refine the worst offenders post-launch. - -## Followups - -- Build the actual feature on a branch `feature/color-progression`. (Branch creation is part of the implementation phase, not the spec.) -- After landing, evaluate whether to revisit out-of-scope items (shimmer, mood, star recolor). diff --git a/src/lib/color.ts b/src/lib/color.ts index 849da14..c880b44 100644 --- a/src/lib/color.ts +++ b/src/lib/color.ts @@ -1,6 +1,4 @@ // src/lib/color.ts — buddy color progression (species × rarity × XP → ANSI escape) -// -// See docs/superpowers/specs/2026-05-12-buddy-color-progression-design.md for the design. import type { Rarity } from './types.js'; import { levelProgress } from './leveling.js'; From a41bd347b4701e65079008dd5930540458669cde Mon Sep 17 00:00:00 2001 From: Dave Ambrose Date: Wed, 13 May 2026 13:20:02 -0400 Subject: [PATCH 23/25] fix(color): address Copilot review on PR #126 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three robustness fixes from the PR review: 1. colorFor respects caps.ansi16 — previously fell back to ANSI-16 escapes even when the flag was false, making TerminalCapabilities lie. Now returns '' if no capability flag is set. 2. card.ts skips RESET when spriteColor is empty — NO_COLOR mode no longer leaks a stray \x1b[0m on every sprite line. 3. statusline-wrapper.ts defaults buddy.rarity to 'common' and buddy.xp to 0 at both colorFor call sites — buddy state is parsed from JSON and rarity can be missing/invalid; without the default, RARITY_METALS lookup would throw. Mirrors the existing buddy.rarity || 'common' pattern on line 86. Also makes the RESET conditional in both bubble and normal modes, same as card.ts. --- src/__tests__/color.test.ts | 5 +++++ src/lib/card.ts | 3 ++- src/lib/color.ts | 5 ++++- src/statusline-wrapper.ts | 10 ++++++---- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/__tests__/color.test.ts b/src/__tests__/color.test.ts index dfdd226..c41cdf9 100644 --- a/src/__tests__/color.test.ts +++ b/src/__tests__/color.test.ts @@ -413,4 +413,9 @@ describe('colorFor (public API)', () => { 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/lib/card.ts b/src/lib/card.ts index 3ece591..bbff231 100644 --- a/src/lib/card.ts +++ b/src/lib/card.ts @@ -44,12 +44,13 @@ export function renderCard(companion: Companion): string { } const spriteColor = colorFor(companion.species, companion.rarity, companion.xp); + const spriteReset = spriteColor ? RESET : ''; const coloredArt = art.map(l => { const padded = ln(l); const prefix = '| '; const suffix = ' |'; const inner = padded.slice(prefix.length, padded.length - suffix.length); - return `${prefix}${spriteColor}${inner}${RESET}${suffix}`; + return `${prefix}${spriteColor}${inner}${spriteReset}${suffix}`; }); return [ diff --git a/src/lib/color.ts b/src/lib/color.ts index c880b44..ca9561d 100644 --- a/src/lib/color.ts +++ b/src/lib/color.ts @@ -235,5 +235,8 @@ export function colorFor( if (caps.ansi256) { return `${boldPrefix}\x1b[38;5;${rgbTo256(rgb)}m`; } - return `${boldPrefix}${rgbToAnsi16(rgb)}`; + if (caps.ansi16) { + return `${boldPrefix}${rgbToAnsi16(rgb)}`; + } + return ''; } diff --git a/src/statusline-wrapper.ts b/src/statusline-wrapper.ts index 4fbe29f..104c9f1 100644 --- a/src/statusline-wrapper.ts +++ b/src/statusline-wrapper.ts @@ -167,7 +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, buddy.xp); + 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(/^(.+?)( - | )(.+)$/); @@ -177,7 +178,7 @@ try { const isName = right.trim() === buddy.name; const coloredRight = isName ? `${CYAN}${right}${RESET}` - : `${bubbleSpriteColor}${right}${RESET}`; + : `${bubbleSpriteColor}${right}${bubbleSpriteReset}`; const fadedLeft = isFading ? `${DIM}${DIM}${left}${RESET}` : `${DIM}${left}${RESET}`; buddyRight.push(`${fadedLeft}${DIM}${sep}${RESET}${coloredRight}`); } else { @@ -279,9 +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, buddy.xp); + 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 = `${spriteColor}${(asciiLines[i] || "").padEnd(artWidth)}${RESET}`; + const artPart = `${spriteColor}${(asciiLines[i] || "").padEnd(artWidth)}${spriteReset}`; if (i === 0) { buddyRight.push(`${artPart} ${nameInfo}`); } else if (i === 1) { From 6cf9891de73848ce03a699e2fcd17718824c3c74 Mon Sep 17 00:00:00 2001 From: Dave Ambrose Date: Wed, 13 May 2026 13:38:03 -0400 Subject: [PATCH 24/25] fix(color): address Copilot review round 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings: 1. computeRGB now falls back to common metals/saturation for invalid rarities (e.g., stale state file post schema migration). Better to render a muted sprite than crash the whole statusline. This defends both the buddy.rarity || 'common' seam in statusline-wrapper and any future JSON callers — TS still enforces typed callers. 2. card.ts renames the shadowed `inner` to `artInner` to avoid collision with the outer card-inner-width binding. 3. card-color.test.ts adds a precondition that expectedColor is not empty, so the toContain assertion isn't vacuous under NO_COLOR. 4. New unit test covers computeRGB's invalid-rarity fallback path, asserting it produces the same RGB as the common-rarity baseline. --- src/__tests__/card-color.test.ts | 3 +++ src/__tests__/color.test.ts | 8 ++++++++ src/lib/card.ts | 4 ++-- src/lib/color.ts | 8 ++++++-- 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/__tests__/card-color.test.ts b/src/__tests__/card-color.test.ts index bb50cf1..8912a8e 100644 --- a/src/__tests__/card-color.test.ts +++ b/src/__tests__/card-color.test.ts @@ -27,6 +27,9 @@ describe('renderCard color integration', () => { 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); }); diff --git a/src/__tests__/color.test.ts b/src/__tests__/color.test.ts index c41cdf9..4af3ae1 100644 --- a/src/__tests__/color.test.ts +++ b/src/__tests__/color.test.ts @@ -230,6 +230,14 @@ describe('computeRGB', () => { 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. diff --git a/src/lib/card.ts b/src/lib/card.ts index bbff231..f0e053d 100644 --- a/src/lib/card.ts +++ b/src/lib/card.ts @@ -49,8 +49,8 @@ export function renderCard(companion: Companion): string { const padded = ln(l); const prefix = '| '; const suffix = ' |'; - const inner = padded.slice(prefix.length, padded.length - suffix.length); - return `${prefix}${spriteColor}${inner}${spriteReset}${suffix}`; + const artInner = padded.slice(prefix.length, padded.length - suffix.length); + return `${prefix}${spriteColor}${artInner}${spriteReset}${suffix}`; }); return [ diff --git a/src/lib/color.ts b/src/lib/color.ts index ca9561d..dc1e766 100644 --- a/src/lib/color.ts +++ b/src/lib/color.ts @@ -119,7 +119,11 @@ 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; - const metalAnchors = RARITY_METALS[rarity]; + // 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], @@ -127,7 +131,7 @@ export function computeRGB(species: string, rarity: Rarity, totalXp: number): RG ]; const interpolated = interpolateAnchors(anchors, [...BREAKPOINTS], p); - return applySaturationTint(interpolated, RARITY_SATURATION[rarity]); + return applySaturationTint(interpolated, saturation); } export function detectCapabilities(env: NodeJS.ProcessEnv = process.env): TerminalCapabilities { From d8aa26322316cb1e034b794755b46b9ba2bbbd5b Mon Sep 17 00:00:00 2001 From: Dave Ambrose Date: Thu, 14 May 2026 09:17:27 -0400 Subject: [PATCH 25/25] test(color): end-to-end capability tier fallback verification Closes the loop on Steven's PR #126 review ask to verify NO_COLOR / ANSI-16 / ANSI-256 / truecolor before merge. The unit tests in color.test.ts assert colorFor's return value per tier; these new tests assert the *shape* of escapes in actual renderCard output. Threads an optional TerminalCapabilities arg through renderCard so tests can drive deterministic output regardless of the host env (the existing ansi256 assertion was passing vacuously because vitest defaulted to 256-color). Production callers omit it. Adds scripts/verify-color-tiers.mjs for visual eyeball verification. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/verify-color-tiers.mjs | 58 ++++++++++++++++++++++++++++++++ src/__tests__/card-color.test.ts | 38 ++++++++++++++++++++- src/lib/card.ts | 10 ++++-- 3 files changed, 102 insertions(+), 4 deletions(-) create mode 100644 scripts/verify-color-tiers.mjs 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__/card-color.test.ts b/src/__tests__/card-color.test.ts index 8912a8e..eec305b 100644 --- a/src/__tests__/card-color.test.ts +++ b/src/__tests__/card-color.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { renderCard } from '../lib/card.js'; -import { colorFor } from '../lib/color.js'; +import { colorFor, type TerminalCapabilities } from '../lib/color.js'; import type { Companion } from '../lib/types.js'; function makeCompanion(overrides: Partial = {}): Companion { @@ -45,3 +45,39 @@ describe('renderCard color integration', () => { 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/lib/card.ts b/src/lib/card.ts index f0e053d..863a989 100644 --- a/src/lib/card.ts +++ b/src/lib/card.ts @@ -4,13 +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 } from './color.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])); @@ -43,7 +47,7 @@ export function renderCard(companion: Companion): string { if (cur) bioLines.push(ln(' ' + cur)); } - const spriteColor = colorFor(companion.species, companion.rarity, companion.xp); + const spriteColor = colorFor(companion.species, companion.rarity, companion.xp, caps); const spriteReset = spriteColor ? RESET : ''; const coloredArt = art.map(l => { const padded = ln(l);