diff --git a/src/components/streak/StreakPanel.tsx b/src/components/streak/StreakPanel.tsx
index 8ee3aa8..57a2985 100644
--- a/src/components/streak/StreakPanel.tsx
+++ b/src/components/streak/StreakPanel.tsx
@@ -1,5 +1,7 @@
+import { useKapLook } from "@/hooks/useKapLook";
+import { openKapWardrobe } from "./KapWardrobe";
import { Link } from "react-router-dom";
-import { Check, Crown, Play, Snowflake } from "lucide-react";
+import { Check, Crown, Play, Shirt, Snowflake } from "lucide-react";
import { cn } from "@/lib/utils";
import { FLAME_TIERS, streakMessage, type StreakStatus } from "@/lib/streak";
import { KapMascot } from "./KapMascot";
@@ -18,6 +20,7 @@ function FlameGlyph({ color, core, className }: { color: string; core: string; c
export function StreakPanel({ status, onNavigate }: { status: StreakStatus; onNavigate?: () => void }) {
const message = streakMessage(status);
const accent = status.tier?.flame ?? "#a1a1aa";
+ const look = useKapLook();
return (
diff --git a/src/components/streak/kap-skins.tsx b/src/components/streak/kap-skins.tsx
new file mode 100644
index 0000000..bec583c
--- /dev/null
+++ b/src/components/streak/kap-skins.tsx
@@ -0,0 +1,126 @@
+/**
+ * Kap's wardrobe: keycap colourways plus one item per slot (head, eyes, wear).
+ * Items are drawn in the 240×258 Kap artwork space; the flame sits at x 120
+ * on the top face, the eyes at y 156, so nothing here covers either.
+ */
+
+export type KapColorId = "amber" | "retro" | "ocean" | "sakura" | "matcha" | "graphite" | "obsidian";
+export type KapHeadId = "headphones" | "party" | "halo";
+export type KapEyesId = "shades" | "nerd" | "monocle";
+export type KapWearId = "scarf" | "headband" | "cape";
+
+export interface KapLook {
+ color?: KapColorId;
+ head?: KapHeadId | null;
+ eyes?: KapEyesId | null;
+ wear?: KapWearId | null;
+}
+
+export interface KapColorway {
+ id: KapColorId;
+ name: string;
+ /** Top face, front skirt, sides and arms; undefined keeps the flame tier's. */
+ cap?: [string, string, string];
+ /** Face lines, for dark caps. */
+ ink?: string;
+}
+
+export const KAP_COLORS: KapColorway[] = [
+ { id: "amber", name: "Amber" },
+ { id: "retro", name: "Retro beige", cap: ["#f5f0e1", "#e3dac3", "#b3a78b"] },
+ { id: "ocean", name: "Ocean", cap: ["#cffafe", "#67e8f9", "#0891b2"] },
+ { id: "sakura", name: "Sakura", cap: ["#fce7f3", "#f9a8d4", "#db2777"] },
+ { id: "matcha", name: "Matcha", cap: ["#ecfccb", "#bef264", "#65a30d"] },
+ { id: "graphite", name: "Graphite", cap: ["#e4e4e7", "#a1a1aa", "#52525b"] },
+ { id: "obsidian", name: "Obsidian", cap: ["#52525b", "#3f3f46", "#18181b"], ink: "#fafafa" },
+];
+
+export function colorway(id: KapColorId | undefined): KapColorway {
+ return KAP_COLORS.find((color) => color.id === id) ?? KAP_COLORS[0];
+}
+
+/** Drawn before the body: only the parts outside the keycap show. */
+export function KapBehind({ look }: { look: KapLook }) {
+ return (
+ <>
+ {look.wear === "cape" && (
+
+
+
+
+
+ )}
+ {look.head === "halo" && (
+
+
+
+
+ )}
+ >
+ );
+}
+
+/** Drawn over the face and flame. */
+export function KapFront({ look, ink }: { look: KapLook; ink: string }) {
+ return (
+ <>
+ {look.wear === "scarf" && (
+
+
+
+
+
+
+ )}
+ {look.wear === "headband" && (
+
+
+
+
+
+
+
+ )}
+ {look.eyes === "shades" && (
+
+ {/* Pixel shades, drawn on an 4px grid. */}
+
+
+
+
+ )}
+ {look.eyes === "nerd" && (
+
+
+
+
+
+
+ )}
+ {look.eyes === "monocle" && (
+
+
+
+
+ )}
+ {look.head === "headphones" && (
+
+
+
+
+
+
+
+
+ )}
+ {look.head === "party" && (
+
+
+
+
+
+
+ )}
+ >
+ );
+}
diff --git a/src/hooks/useKapLook.ts b/src/hooks/useKapLook.ts
new file mode 100644
index 0000000..8a08c83
--- /dev/null
+++ b/src/hooks/useKapLook.ts
@@ -0,0 +1,29 @@
+import { useEffect, useState } from "react";
+import { SYNC_EVENT } from "@/lib/account-sync";
+import { ACHIEVEMENT_EVENT } from "@/lib/achievements";
+import { LOOK_EVENT, LOOK_KEY, readLook } from "@/lib/kap-wardrobe";
+import type { KapLook } from "@/components/streak/kap-skins";
+
+/** The look Kap wears everywhere, kept current across tabs, devices and new badges. */
+export function useKapLook(): KapLook {
+ const [look, setLook] = useState
(() => readLook());
+
+ useEffect(() => {
+ const refresh = () => setLook(readLook());
+ const onStorage = (event: StorageEvent) => {
+ if (event.key === LOOK_KEY || event.key === null) refresh();
+ };
+ window.addEventListener(LOOK_EVENT, refresh);
+ window.addEventListener(ACHIEVEMENT_EVENT, refresh);
+ window.addEventListener(SYNC_EVENT, refresh);
+ window.addEventListener("storage", onStorage);
+ return () => {
+ window.removeEventListener(LOOK_EVENT, refresh);
+ window.removeEventListener(ACHIEVEMENT_EVENT, refresh);
+ window.removeEventListener(SYNC_EVENT, refresh);
+ window.removeEventListener("storage", onStorage);
+ };
+ }, []);
+
+ return look;
+}
diff --git a/src/lib/account-sync.ts b/src/lib/account-sync.ts
index 95403ca..ccb34f8 100644
--- a/src/lib/account-sync.ts
+++ b/src/lib/account-sync.ts
@@ -57,6 +57,7 @@ export const SLICES: Slice[] = [
single("freezes", "codey_streak_freezes"),
single("textLanguage", "codey_text_language"),
single("reminder", "codey_streak_reminder"),
+ single("kapLook", "codey_kap_look"),
{
id: "sound",
keys: ["codey_soundpack_id_v1", "codey_sound_volume_v1"],
diff --git a/src/lib/kap-wardrobe.test.ts b/src/lib/kap-wardrobe.test.ts
new file mode 100644
index 0000000..3f6c6b9
--- /dev/null
+++ b/src/lib/kap-wardrobe.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from "vitest";
+import { itemsUnlockedBy, isUnlocked, sanitizeLook, toggleItem, unlockHint, WARDROBE } from "./kap-wardrobe";
+
+const item = (id: string) => WARDROBE.find((candidate) => candidate.id === id)!;
+
+describe("kap wardrobe", () => {
+ it("unlocks items by achievement, amber is free", () => {
+ expect(isUnlocked(item("amber"), new Set())).toBe(true);
+ expect(isUnlocked(item("shades"), new Set())).toBe(false);
+ expect(isUnlocked(item("shades"), new Set(["speed-3"]))).toBe(true);
+ });
+
+ it("every requirement is a real achievement with a goal", () => {
+ for (const entry of WARDROBE) if (entry.requires) expect(unlockHint(entry)).not.toBe("Keep practicing");
+ expect(unlockHint(item("shades"))).toBe("Hit 100 WPM in a run");
+ });
+
+ it("drops unknown and locked items from a stored look", () => {
+ const look = sanitizeLook({ color: "ocean", head: "halo", eyes: "laser", wear: "cape" }, new Set(["streak-2", "speed-4"]));
+ expect(look).toEqual({ color: "ocean", wear: "cape" });
+ expect(sanitizeLook("junk", new Set())).toEqual({});
+ });
+
+ it("toggles accessories but always keeps a colour", () => {
+ const on = toggleItem({}, item("party"));
+ expect(on.head).toBe("party");
+ expect(toggleItem(on, item("party")).head).toBeNull();
+ expect(toggleItem({ color: "ocean" }, item("ocean")).color).toBe("ocean");
+ });
+
+ it("names what a badge unlocks", () => {
+ expect(itemsUnlockedBy("streak-4").map((entry) => entry.name)).toEqual(["Headband"]);
+ });
+});
diff --git a/src/lib/kap-wardrobe.ts b/src/lib/kap-wardrobe.ts
new file mode 100644
index 0000000..2d909b0
--- /dev/null
+++ b/src/lib/kap-wardrobe.ts
@@ -0,0 +1,101 @@
+import { describe as describeBadge, readStore } from "@/lib/achievements";
+import { KAP_COLORS, type KapColorId, type KapEyesId, type KapHeadId, type KapLook, type KapWearId } from "@/components/streak/kap-skins";
+
+/**
+ * Kap's wardrobe: every item is unlocked by an achievement, so progress,
+ * device sync and "you earned it" moments all come from the badge system.
+ */
+
+export type WardrobeSlot = "color" | "head" | "eyes" | "wear";
+
+export interface WardrobeItem {
+ slot: WardrobeSlot;
+ id: KapColorId | KapHeadId | KapEyesId | KapWearId;
+ name: string;
+ /** Achievement id that unlocks it; null is free. */
+ requires: string | null;
+}
+
+const COLOR_UNLOCKS: Record = {
+ amber: null,
+ retro: "runs-1",
+ ocean: "streak-2",
+ sakura: "streak-3",
+ matcha: "runs-2",
+ graphite: "ranked-2",
+ obsidian: "time-3",
+};
+
+export const WARDROBE: WardrobeItem[] = [
+ ...KAP_COLORS.map((color) => ({ slot: "color" as const, id: color.id, name: color.name, requires: COLOR_UNLOCKS[color.id] })),
+ { slot: "head", id: "headphones", name: "Headphones", requires: "time-2" },
+ { slot: "head", id: "party", name: "Party hat", requires: "duel-1" },
+ { slot: "head", id: "halo", name: "Halo", requires: "flawless" },
+ { slot: "eyes", id: "shades", name: "Pixel shades", requires: "speed-3" },
+ { slot: "eyes", id: "nerd", name: "Nerd glasses", requires: "polyglot-3" },
+ { slot: "eyes", id: "monocle", name: "Monocle", requires: "daily-2" },
+ { slot: "wear", id: "scarf", name: "Scarf", requires: "streak-1" },
+ { slot: "wear", id: "headband", name: "Headband", requires: "streak-4" },
+ { slot: "wear", id: "cape", name: "Cape", requires: "speed-4" },
+];
+
+export const SLOT_NAMES: Record = { color: "Keycap", head: "Head", eyes: "Eyes", wear: "Wear" };
+
+export const isUnlocked = (item: WardrobeItem, earned: ReadonlySet) => item.requires === null || earned.has(item.requires);
+
+/** What it takes, from the badge's own goal text, e.g. "Hit 100 WPM in a run". */
+export function unlockHint(item: WardrobeItem): string {
+ if (!item.requires) return "Free";
+ return describeBadge(item.requires)?.goal ?? "Keep practicing";
+}
+
+export function earnedBadges(): Set {
+ return new Set(Object.keys(readStore().unlockedAt));
+}
+
+/** Items a newly earned badge unlocks, for the achievement toast. */
+export function itemsUnlockedBy(badgeId: string): WardrobeItem[] {
+ return WARDROBE.filter((item) => item.requires === badgeId);
+}
+
+/* ---- The chosen look ---- */
+
+export const LOOK_KEY = "codey_kap_look";
+export const LOOK_EVENT = "codey:kap-look";
+
+const SLOTS: WardrobeSlot[] = ["color", "head", "eyes", "wear"];
+
+/** Keeps only known items that are unlocked, so a stale or synced look never shows locked gear. */
+export function sanitizeLook(value: unknown, earned: ReadonlySet): KapLook {
+ const raw = (value && typeof value === "object" ? value : {}) as Record;
+ const look: KapLook = {};
+ for (const slot of SLOTS) {
+ const item = WARDROBE.find((candidate) => candidate.slot === slot && candidate.id === raw[slot]);
+ if (item && isUnlocked(item, earned)) (look as Record)[slot] = item.id;
+ }
+ return look;
+}
+
+export function readLook(earned: ReadonlySet = earnedBadges()): KapLook {
+ try {
+ return sanitizeLook(JSON.parse(localStorage.getItem(LOOK_KEY) ?? "null"), earned);
+ } catch {
+ return {};
+ }
+}
+
+export function writeLook(look: KapLook) {
+ try {
+ localStorage.setItem(LOOK_KEY, JSON.stringify(look));
+ } catch {
+ // Kept for this tab only.
+ }
+ window.dispatchEvent(new CustomEvent(LOOK_EVENT));
+}
+
+/** Puts an item on, or takes it off when it is already worn (colour always stays on). */
+export function toggleItem(look: KapLook, item: WardrobeItem): KapLook {
+ const current = (look as Record)[item.slot];
+ if (item.slot === "color") return { ...look, color: item.id as KapColorId };
+ return { ...look, [item.slot]: current === item.id ? null : item.id };
+}
diff --git a/src/main.tsx b/src/main.tsx
index 4a2f360..e962124 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -16,6 +16,7 @@ import { AchievementToaster } from './components/achievements/AchievementToaster
import { StreakCelebration } from './components/streak/StreakCelebration';
import { StreakReminder } from './components/streak/StreakReminder';
import { PwaPrompt } from './components/PwaPrompt';
+import { KapWardrobe } from './components/streak/KapWardrobe';
import './index.css';
const { Settings, History, Leaderboard, Profile, Donate, Contributors, Duel, Arcade, Daily, KeyboardAnalytics, Achievements } = Pages;
@@ -55,6 +56,7 @@ createRoot(document.getElementById('root')!).render(
+