From f32e253d87792f4ca21b3e6386777ee4f1fb7eb5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 09:24:52 +0000 Subject: [PATCH 1/2] Kap wardrobe: colourways and head, eyes and wear items (not yet selectable) KapMascot takes an optional look; without one Kap renders as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018xNjY7412Rp5bFuw5h69yu --- src/components/streak/KapMascot.tsx | 23 +++-- src/components/streak/kap-skins.tsx | 126 ++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 8 deletions(-) create mode 100644 src/components/streak/kap-skins.tsx diff --git a/src/components/streak/KapMascot.tsx b/src/components/streak/KapMascot.tsx index 30e4eb2..d591b0c 100644 --- a/src/components/streak/KapMascot.tsx +++ b/src/components/streak/KapMascot.tsx @@ -1,6 +1,7 @@ import { useId } from "react"; import { cn } from "@/lib/utils"; import type { FlameTier, StreakMood } from "@/lib/streak"; +import { colorway, KapBehind, KapFront, type KapLook } from "./kap-skins"; /** * Kap, the streak mascot: an SA-profile keycap on MX-stem feet with a flame @@ -18,6 +19,8 @@ interface KapMascotProps { jump?: boolean; className?: string; title?: string; + /** Colourway and accessories from Kap's wardrobe. */ + look?: KapLook; } const INK = "#1c1917"; @@ -36,11 +39,11 @@ function flamePaths(cx: number, base: number, scale: number, outer: string, inne ); } -function Star({ x, y }: { x: number; y: number }) { - return ; +function Star({ x, y, ink }: { x: number; y: number; ink: string }) { + return ; } -function Face({ mood, crown }: { mood: StreakMood; crown: boolean }) { +function Face({ mood, crown, ink: INK }: { mood: StreakMood; crown: boolean; ink: string }) { const cx = 120; const y = 156; const smile = ( @@ -64,8 +67,8 @@ function Face({ mood, crown }: { mood: StreakMood; crown: boolean }) { {(mood === "lit" && !crown) || mood === "frozen" ? : null} {mood === "lit" && crown && ( <> - - + + )} @@ -84,9 +87,11 @@ function Face({ mood, crown }: { mood: StreakMood; crown: boolean }) { ); } -export function KapMascot({ mood, tier, size = 120, animate = true, jump = false, className, title }: KapMascotProps) { +export function KapMascot({ mood, tier, size = 120, animate = true, jump = false, className, title, look = {} }: KapMascotProps) { const id = useId().replace(/:/g, ""); - const cap = mood === "sleep" ? SLEEP_CAP : mood === "risk" ? RISK_CAP : mood === "frozen" ? ICE_CAP : tier?.cap ?? SLEEP_CAP; + const paint = colorway(look.color); + const cap = mood === "sleep" ? SLEEP_CAP : mood === "risk" ? RISK_CAP : mood === "frozen" ? ICE_CAP : paint.cap ?? tier?.cap ?? SLEEP_CAP; + const ink = mood === "lit" && paint.ink ? paint.ink : INK; const [top, front, side] = cap; // At risk, the flame shrinks to an ember of its usual colour. const flameScale = mood === "sleep" || !tier ? 0 : mood === "risk" || mood === "frozen" ? Math.min(0.55, tier.scale * 0.6) : tier.scale; @@ -112,6 +117,7 @@ export function KapMascot({ mood, tier, size = 120, animate = true, jump = false + @@ -138,7 +144,8 @@ export function KapMascot({ mood, tier, size = 120, animate = true, jump = false )} {crown && } - + + {mood === "frozen" && ( {/* An ice-blue scarf, tied at the side with a snowflake. */} 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" && ( + + + + + + + )} + + ); +} From 7bbeea91b4e28c5e2d811596e140bf6059f22263 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 09:29:54 +0000 Subject: [PATCH 2/2] Kap's wardrobe: dress Kap with items earned from achievements 16 items across keycap colour, head, eyes and wear, each unlocked by an achievement. The Wardrobe opens from the streak panel, the command palette and the achievement toast (which names what a badge unlocks). The look is stored per device, synced to the account, and Kap wears it in the header, streak panel and streak celebration. Locked or unknown items are dropped from a stored look, so synced data can never show gear not yet earned. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018xNjY7412Rp5bFuw5h69yu --- src/components/CommandPalette.tsx | 3 + .../achievements/AchievementToaster.tsx | 9 +- src/components/streak/KapWardrobe.tsx | 109 ++++++++++++++++++ src/components/streak/StreakButton.tsx | 4 +- src/components/streak/StreakCelebration.tsx | 4 +- src/components/streak/StreakPanel.tsx | 17 ++- src/hooks/useKapLook.ts | 29 +++++ src/lib/account-sync.ts | 1 + src/lib/kap-wardrobe.test.ts | 34 ++++++ src/lib/kap-wardrobe.ts | 101 ++++++++++++++++ src/main.tsx | 2 + 11 files changed, 308 insertions(+), 5 deletions(-) create mode 100644 src/components/streak/KapWardrobe.tsx create mode 100644 src/hooks/useKapLook.ts create mode 100644 src/lib/kap-wardrobe.test.ts create mode 100644 src/lib/kap-wardrobe.ts diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index c8138c8..2cefe08 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { BarChart3, + Shirt, CalendarDays, Check, CornerDownLeft, @@ -31,6 +32,7 @@ import { useFlowRadio } from "./RadioProvider"; import { useTheme } from "./ThemeProvider"; import { THEMES } from "./ThemeStudioModal"; import { openKeycapStudio } from "./KeycapStudioModal"; +import { openKapWardrobe } from "./streak/KapWardrobe"; import { EDITOR_KEYCAPS, KEYCAP_COLORWAYS } from "@/lib/keycaps"; import { cn } from "@/lib/utils"; @@ -165,6 +167,7 @@ export function CommandPalette() { run: () => setPreference("editorTheme", option.id), })), { id: "keycap-studio", group: "Appearance", label: "Keycap Studio", keywords: "customize paint keyboard 3d colors", icon: Keyboard, run: openKeycapStudio }, + { id: "kap-wardrobe", group: "Appearance", label: "Kap's wardrobe", keywords: "mascot skins streak outfit hat glasses", icon: Shirt, run: openKapWardrobe }, { id: "keycaps-editor", group: "Appearance", label: "Keycaps: Editor theme", keywords: "colorway keyboard", icon: Keyboard, state: preferences.keycapTheme === EDITOR_KEYCAPS ? "active" : undefined, run: () => setPreference("keycapTheme", EDITOR_KEYCAPS) }, ...KEYCAP_COLORWAYS.map((option): Command => ({ id: `keycaps-${option.id}`, diff --git a/src/components/achievements/AchievementToaster.tsx b/src/components/achievements/AchievementToaster.tsx index a9ad81a..b942f06 100644 --- a/src/components/achievements/AchievementToaster.tsx +++ b/src/components/achievements/AchievementToaster.tsx @@ -1,7 +1,9 @@ import { useEffect, useRef, useState } from "react"; import { Link } from "react-router-dom"; -import { X } from "lucide-react"; +import { Shirt, X } from "lucide-react"; import { ACHIEVEMENT_EVENT, type UnlockedBadge } from "@/lib/achievements"; +import { itemsUnlockedBy } from "@/lib/kap-wardrobe"; +import { openKapWardrobe } from "@/components/streak/KapWardrobe"; import { AchievementBadge } from "./Badge"; const SHOW_MS = 5200; @@ -47,6 +49,11 @@ export function AchievementToaster() {

Achievement unlocked

{current.name}

{current.goal}

+ {itemsUnlockedBy(current.id).length > 0 && ( + + )} setQueue((items) => items.slice(1))} className="mt-1 inline-block text-xs font-semibold underline-offset-2 hover:underline"> See all achievements diff --git a/src/components/streak/KapWardrobe.tsx b/src/components/streak/KapWardrobe.tsx new file mode 100644 index 0000000..d467226 --- /dev/null +++ b/src/components/streak/KapWardrobe.tsx @@ -0,0 +1,109 @@ +import { useEffect, useMemo, useState } from "react"; +import { Check, Lock, Shirt, X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { FLAME_TIERS } from "@/lib/streak"; +import { useStreak } from "@/hooks/useStreak"; +import { useKapLook } from "@/hooks/useKapLook"; +import { earnedBadges, isUnlocked, SLOT_NAMES, toggleItem, unlockHint, WARDROBE, writeLook, type WardrobeItem, type WardrobeSlot } from "@/lib/kap-wardrobe"; +import { KapMascot } from "./KapMascot"; +import type { KapLook } from "./kap-skins"; + +export const OPEN_WARDROBE_EVENT = "codey:open-wardrobe"; + +export function openKapWardrobe() { + window.dispatchEvent(new Event(OPEN_WARDROBE_EVENT)); +} + +const SLOTS: WardrobeSlot[] = ["color", "head", "eyes", "wear"]; + +function lookWith(item: WardrobeItem): KapLook { + return item.slot === "color" ? { color: item.id as KapLook["color"] } : { [item.slot]: item.id }; +} + +/** Dress Kap: every unlocked item, and what the locked ones take. Mounted once, opened by event. */ +export function KapWardrobe() { + const [open, setOpen] = useState(false); + const look = useKapLook(); + const status = useStreak(); + const [earned, setEarned] = useState>(() => new Set()); + + useEffect(() => { + const show = () => { + setEarned(earnedBadges()); + setOpen(true); + }; + window.addEventListener(OPEN_WARDROBE_EVENT, show); + return () => window.removeEventListener(OPEN_WARDROBE_EVENT, show); + }, []); + + useEffect(() => { + if (!open) return; + const onKey = (event: KeyboardEvent) => event.key === "Escape" && setOpen(false); + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open]); + + const tier = status.tier ?? FLAME_TIERS[1]; + const unlockedCount = useMemo(() => WARDROBE.filter((item) => isUnlocked(item, earned)).length, [earned]); + if (!open) return null; + + const worn = (item: WardrobeItem) => (look as Record)[item.slot] === item.id || (item.slot === "color" && !look.color && item.id === "amber"); + + return ( +
{ if (event.target === event.currentTarget) setOpen(false); }} onKeyDown={(event) => event.stopPropagation()}> +
+
+
+ +

Kap's wardrobe

+

{unlockedCount} of {WARDROBE.length} unlocked

+
+
+
+
+
+
+

Dress Kap

+ +
+ {SLOTS.map((slot) => ( +
+

{SLOT_NAMES[slot]}

+
+ {WARDROBE.filter((item) => item.slot === slot).map((item) => { + const unlocked = isUnlocked(item, earned); + const on = unlocked && worn(item); + return ( + + ); + })} +
+
+ ))} +

Items unlock with achievements. Your look syncs to your account and shows wherever Kap does.

+
+
+
+ ); +} diff --git a/src/components/streak/StreakButton.tsx b/src/components/streak/StreakButton.tsx index 577984a..6d60430 100644 --- a/src/components/streak/StreakButton.tsx +++ b/src/components/streak/StreakButton.tsx @@ -1,3 +1,4 @@ +import { useKapLook } from "@/hooks/useKapLook"; import { useEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; import { useStreak } from "@/hooks/useStreak"; @@ -6,6 +7,7 @@ import { StreakPanel } from "./StreakPanel"; /** Kap in the header: the streak at a glance, the full panel on click. */ export function StreakButton() { + const look = useKapLook(); const status = useStreak(); const [open, setOpen] = useState(false); const rootRef = useRef(null); @@ -44,7 +46,7 @@ export function StreakButton() { status.mood === "lit" ? "text-foreground" : "text-muted-foreground", )} > - + {status.current} {(status.mood === "risk" || status.mood === "frozen") && ( diff --git a/src/components/streak/StreakCelebration.tsx b/src/components/streak/StreakCelebration.tsx index 7db59f4..504db8f 100644 --- a/src/components/streak/StreakCelebration.tsx +++ b/src/components/streak/StreakCelebration.tsx @@ -1,3 +1,4 @@ +import { useKapLook } from "@/hooks/useKapLook"; import { useEffect, useState } from "react"; import { X } from "lucide-react"; import { STREAK_EVENT } from "@/utils/storage"; @@ -19,6 +20,7 @@ interface Celebration { /** Kap hops in when a run extends the streak; milestones get a bigger moment. */ export function StreakCelebration() { + const look = useKapLook(); const [shown, setShown] = useState(null); useEffect(() => { @@ -50,7 +52,7 @@ export function StreakCelebration() { return (
- +

{title}

{body}

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 (
@@ -27,9 +30,19 @@ export function StreakPanel({ status, onNavigate }: { status: StreakStatus; onNa style={{ background: status.mood === "sleep" ? "transparent" : accent }} aria-hidden /> - +

{message.title}

{message.body}

+
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( +