Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
BarChart3,
Shirt,
CalendarDays,
Check,
CornerDownLeft,
Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -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}`,
Expand Down
9 changes: 8 additions & 1 deletion src/components/achievements/AchievementToaster.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -47,6 +49,11 @@ export function AchievementToaster() {
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-amber-600 dark:text-amber-400">Achievement unlocked</p>
<p className="truncate text-base font-black tracking-tight">{current.name}</p>
<p className="text-xs text-muted-foreground">{current.goal}</p>
{itemsUnlockedBy(current.id).length > 0 && (
<button type="button" onClick={() => { setQueue((items) => items.slice(1)); openKapWardrobe(); }} className="mt-1 flex items-center gap-1 text-xs font-semibold text-amber-600 hover:underline dark:text-amber-400 cursor-pointer">
<Shirt className="size-3.5" /> New for Kap: {itemsUnlockedBy(current.id).map((item) => item.name).join(", ")}
</button>
)}
<Link to="/achievements" onClick={() => setQueue((items) => items.slice(1))} className="mt-1 inline-block text-xs font-semibold underline-offset-2 hover:underline">
See all achievements
</Link>
Expand Down
23 changes: 15 additions & 8 deletions src/components/streak/KapMascot.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -18,6 +19,8 @@ interface KapMascotProps {
jump?: boolean;
className?: string;
title?: string;
/** Colourway and accessories from Kap's wardrobe. */
look?: KapLook;
}

const INK = "#1c1917";
Expand All @@ -36,11 +39,11 @@ function flamePaths(cx: number, base: number, scale: number, outer: string, inne
);
}

function Star({ x, y }: { x: number; y: number }) {
return <path d={`M${x} ${y - 9} l3 6 7 1 -5 5 1 7 -6 -3 -6 3 1 -7 -5 -5 7 -1z`} fill={INK} />;
function Star({ x, y, ink }: { x: number; y: number; ink: string }) {
return <path d={`M${x} ${y - 9} l3 6 7 1 -5 5 1 7 -6 -3 -6 3 1 -7 -5 -5 7 -1z`} fill={ink} />;
}

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 = (
Expand All @@ -64,8 +67,8 @@ function Face({ mood, crown }: { mood: StreakMood; crown: boolean }) {
{(mood === "lit" && !crown) || mood === "frozen" ? <path d={`M${cx - 31} ${y + 4} q9 -13 18 0 M${cx + 13} ${y + 4} q9 -13 18 0`} stroke={INK} strokeWidth="4.5" fill="none" strokeLinecap="round" /> : null}
{mood === "lit" && crown && (
<>
<Star x={cx - 22} y={y} />
<Star x={cx + 22} y={y} />
<Star x={cx - 22} y={y} ink={INK} />
<Star x={cx + 22} y={y} ink={INK} />
</>
)}
</g>
Expand All @@ -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;
Expand All @@ -112,6 +117,7 @@ export function KapMascot({ mood, tier, size = 120, animate = true, jump = false
</defs>
<ellipse className="kap-shadow" cx="120" cy="222" rx="74" ry="8" fill="#000" opacity=".3" />
<g className="kap-body">
<KapBehind look={look} />
<rect x="92" y="196" width="14" height="22" rx="5" fill="#3f3f46" />
<rect x="134" y="196" width="14" height="22" rx="5" fill="#3f3f46" />
<rect x="86" y="212" width="24" height="9" rx="4.5" fill="#27272a" />
Expand All @@ -138,7 +144,8 @@ export function KapMascot({ mood, tier, size = 120, animate = true, jump = false
</g>
)}
{crown && <path d={`M96 ${108 - 78 * flameScale + 6} l6 -22 12 12 6 -18 6 18 12 -12 6 22z`} fill="#facc15" stroke="#a16207" strokeWidth="2" strokeLinejoin="round" />}
<Face mood={mood} crown={crown} />
<Face mood={mood} crown={crown} ink={ink} />
<KapFront look={{ ...look, wear: mood === "frozen" ? null : look.wear }} ink={ink} />
{mood === "frozen" && (
<g className="kap-scarf">
{/* An ice-blue scarf, tied at the side with a snowflake. */}
Expand Down
109 changes: 109 additions & 0 deletions src/components/streak/KapWardrobe.tsx
Original file line number Diff line number Diff line change
@@ -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<Set<string>>(() => 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<string, string | null | undefined>)[item.slot] === item.id || (item.slot === "color" && !look.color && item.id === "amber");

return (
<div className="fixed inset-0 z-[60] grid place-items-center bg-black/70 p-4 backdrop-blur-md" role="dialog" aria-modal="true" aria-label="Kap's wardrobe" onMouseDown={(event) => { if (event.target === event.currentTarget) setOpen(false); }} onKeyDown={(event) => event.stopPropagation()}>
<div className="flex max-h-[calc(100dvh-2rem)] w-full max-w-3xl flex-col overflow-hidden rounded-2xl border bg-background shadow-2xl animate-scale-in sm:flex-row">
<div className="relative flex shrink-0 flex-col items-center justify-center gap-2 border-b bg-gradient-to-b from-amber-500/15 to-transparent px-6 py-5 sm:w-64 sm:border-b-0 sm:border-r">
<div className="pointer-events-none absolute left-1/2 top-10 size-40 -translate-x-1/2 rounded-full opacity-30 blur-3xl" style={{ background: tier.flame }} aria-hidden />
<KapMascot mood="lit" tier={tier} size={150} look={look} className="relative" title="Kap in your look" />
<p className="relative text-sm font-black tracking-tight">Kap's wardrobe</p>
<p className="relative text-xs text-muted-foreground">{unlockedCount} of {WARDROBE.length} unlocked</p>
<div className="relative h-1.5 w-40 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-amber-500" style={{ width: `${(unlockedCount / WARDROBE.length) * 100}%` }} />
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-5">
<div className="mb-3 flex items-center justify-between">
<h2 className="flex items-center gap-2 text-sm font-bold"><Shirt className="size-4 text-amber-500" /> Dress Kap</h2>
<button type="button" onClick={() => setOpen(false)} className="rounded-lg p-2 text-muted-foreground hover:bg-muted hover:text-foreground cursor-pointer" aria-label="Close">
<X className="size-4" />
</button>
</div>
{SLOTS.map((slot) => (
<section key={slot} className="mb-4 last:mb-0">
<p className="mb-2 text-[10px] font-bold uppercase tracking-[0.16em] text-muted-foreground">{SLOT_NAMES[slot]}</p>
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
{WARDROBE.filter((item) => item.slot === slot).map((item) => {
const unlocked = isUnlocked(item, earned);
const on = unlocked && worn(item);
return (
<button
key={item.id}
type="button"
disabled={!unlocked}
onClick={() => writeLook(toggleItem(look, item))}
title={unlocked ? (on && slot !== "color" ? `Take off ${item.name}` : `Wear ${item.name}`) : `Locked: ${unlockHint(item)}`}
className={cn(
"relative flex flex-col items-center rounded-xl border px-1.5 pb-2 pt-1.5 text-center transition-colors",
unlocked ? "cursor-pointer hover:border-amber-500/60 hover:bg-amber-500/5" : "cursor-not-allowed bg-muted/30",
on && "border-amber-500 bg-amber-500/10 ring-1 ring-amber-500/40",
)}
>
<span className={cn(!unlocked && "opacity-35 grayscale")}>
<KapMascot mood="lit" tier={tier} size={64} animate={false} look={lookWith(item)} />
</span>
<span className="mt-0.5 text-[11px] font-semibold leading-tight">{item.name}</span>
{!unlocked && <span className="mt-0.5 line-clamp-2 text-[10px] leading-tight text-muted-foreground">{unlockHint(item)}</span>}
{on && <Check className="absolute right-1.5 top-1.5 size-3.5 text-amber-500" strokeWidth={3} />}
{!unlocked && <Lock className="absolute right-1.5 top-1.5 size-3 text-muted-foreground" />}
</button>
);
})}
</div>
</section>
))}
<p className="mt-2 text-[11px] text-muted-foreground">Items unlock with achievements. Your look syncs to your account and shows wherever Kap does.</p>
</div>
</div>
</div>
);
}
4 changes: 3 additions & 1 deletion src/components/streak/StreakButton.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<HTMLDivElement>(null);
Expand Down Expand Up @@ -44,7 +46,7 @@ export function StreakButton() {
status.mood === "lit" ? "text-foreground" : "text-muted-foreground",
)}
>
<KapMascot mood={status.mood} tier={status.tier} size={26} animate={status.mood === "risk" || status.mood === "frozen"} className="-mt-1" />
<KapMascot mood={status.mood} tier={status.tier} look={look} size={26} animate={status.mood === "risk" || status.mood === "frozen"} className="-mt-1" />
<span>{status.current}</span>
{(status.mood === "risk" || status.mood === "frozen") && (
<span className="absolute right-1 top-1 flex size-2" aria-hidden>
Expand Down
4 changes: 3 additions & 1 deletion src/components/streak/StreakCelebration.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<Celebration | null>(null);

useEffect(() => {
Expand Down Expand Up @@ -50,7 +52,7 @@ export function StreakCelebration() {
return (
<div className="pointer-events-none fixed inset-x-0 top-20 z-[60] flex justify-center px-4" aria-live="polite">
<div className="streak-toast pointer-events-auto relative flex items-center gap-3 rounded-2xl border bg-popover/95 py-2 pl-2 pr-9 text-popover-foreground shadow-2xl backdrop-blur-md">
<KapMascot mood="lit" tier={shown.tier} size={64} jump className="shrink-0" />
<KapMascot mood="lit" tier={shown.tier} look={look} size={64} jump className="shrink-0" />
<div className="min-w-0">
<p className="text-sm font-black tracking-tight" style={{ color: shown.tier?.flame }}>{title}</p>
<p className="max-w-60 text-xs text-muted-foreground">{body}</p>
Expand Down
17 changes: 15 additions & 2 deletions src/components/streak/StreakPanel.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 (
<div className="w-full sm:w-[22rem]">
Expand All @@ -27,9 +30,19 @@ export function StreakPanel({ status, onNavigate }: { status: StreakStatus; onNa
style={{ background: status.mood === "sleep" ? "transparent" : accent }}
aria-hidden
/>
<KapMascot mood={status.mood} tier={status.tier} size={124} className="relative mx-auto" title={`Kap: ${message.title}`} />
<KapMascot mood={status.mood} tier={status.tier} look={look} size={124} className="relative mx-auto" title={`Kap: ${message.title}`} />
<p className="relative mt-1 text-base font-black tracking-tight">{message.title}</p>
<p className="relative mx-auto mt-0.5 max-w-64 text-xs leading-relaxed text-muted-foreground">{message.body}</p>
<button
type="button"
onClick={() => {
onNavigate?.();
openKapWardrobe();
}}
className="absolute right-2.5 top-2.5 flex items-center gap-1 rounded-lg border bg-background/70 px-2 py-1 text-[11px] font-semibold text-muted-foreground backdrop-blur transition-colors hover:border-amber-500/60 hover:text-foreground cursor-pointer"
>
<Shirt className="size-3.5" /> Wardrobe
</button>
</div>

<div className="mt-3 grid grid-cols-3 gap-2 text-center">
Expand Down
Loading
Loading