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
103 changes: 88 additions & 15 deletions src/components/streak/KapBuddy.tsx
Original file line number Diff line number Diff line change
@@ -1,44 +1,117 @@
import { useEffect, useState } from "react";
import { X } from "lucide-react";
import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { GripHorizontal, RotateCcw, X } from "lucide-react";
import { useKapLook } from "@/hooks/useKapLook";
import { useStreak } from "@/hooks/useStreak";
import { BUDDY_EVENT, BUDDY_KEY, readBuddy, writeBuddy } from "@/lib/kap-buddy";
import { BUDDY_EVENT, BUDDY_KEY, placeBuddy, readBuddy, readBuddyPos, writeBuddy, writeBuddyPos, type BuddyPos } from "@/lib/kap-buddy";
import { SYNC_EVENT } from "@/lib/account-sync";
import { FLAME_TIERS } from "@/lib/streak";
import { cn } from "@/lib/utils";
import { Kap3D } from "./Kap3D";

/** Kap in the corner while you practice: nods along, stokes his flame, jumps at typos. */
const BOX = { width: 128, height: 162 };
const viewport = () => ({ width: window.innerWidth, height: window.innerHeight });
/** Hands the keyboard back to the practice screen, so typing carries on after a move. */
const backToTyping = (from: Element) => requestAnimationFrame(() => from.closest<HTMLElement>(".workspace-shell")?.focus({ preventScroll: true }));

/**
* Kap beside the editor while you practice: nods along, stokes his flame,
* jumps at typos. The grip on top moves him anywhere; dragging Kap himself
* still spins him.
*/
export function KapBuddy() {
const [on, setOn] = useState(readBuddy);
const [pos, setPos] = useState<BuddyPos | null>(readBuddyPos);
const [view, setView] = useState(viewport);
const [dragging, setDragging] = useState(false);
const drag = useRef<{ dx: number; dy: number } | null>(null);
const look = useKapLook();
const status = useStreak();

useEffect(() => {
const refresh = () => setOn(readBuddy());
const onStorage = (event: StorageEvent) => event.key === BUDDY_KEY && refresh();
const onResize = () => setView(viewport());
window.addEventListener(BUDDY_EVENT, refresh);
window.addEventListener(SYNC_EVENT, refresh);
window.addEventListener("storage", onStorage);
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener(BUDDY_EVENT, refresh);
window.removeEventListener(SYNC_EVENT, refresh);
window.removeEventListener("storage", onStorage);
window.removeEventListener("resize", onResize);
};
}, []);

const place = placeBuddy(pos, BOX, view);

const startDrag = (event: ReactPointerEvent<HTMLButtonElement>) => {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
drag.current = { dx: event.clientX - place.left, dy: event.clientY - place.top };
setDragging(true);
};

const moveDrag = (event: ReactPointerEvent<HTMLButtonElement>) => {
if (!drag.current) return;
const next = placeBuddy({ x: (event.clientX - drag.current.dx) / view.width, y: (event.clientY - drag.current.dy) / view.height }, BOX, view);
setPos({ x: next.left / view.width, y: next.top / view.height });
};

const endDrag = useCallback((event: ReactPointerEvent<HTMLButtonElement>) => {
backToTyping(event.currentTarget);
if (!drag.current) return;
drag.current = null;
setDragging(false);
setPos((current) => {
writeBuddyPos(current);
return current;
});
}, []);

if (!on) return null;
return (
<div className="group fixed bottom-3 left-3 z-30 hidden w-32 select-none xl:block" aria-hidden>
<Kap3D mood="lit" tier={status.tier ?? FLAME_TIERS[1]} look={look} height={138} listen fallback={null} />
<button
type="button"
onClick={() => writeBuddy(false)}
className="absolute right-1 top-1 rounded-full border bg-background/80 p-1 text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100 cursor-pointer"
aria-label="Hide Kap"
title="Hide Kap (turn him back on in the Wardrobe)"
>
<X className="size-3" />
</button>
<div
className={cn("group fixed z-30 hidden select-none xl:block", dragging && "cursor-grabbing")}
style={{ left: place.left, top: place.top, width: BOX.width, height: BOX.height }}
>
<div className={cn("absolute inset-x-0 top-0 z-10 flex h-6 items-center justify-center gap-1 transition-opacity", dragging ? "opacity-100" : "opacity-0 group-hover:opacity-100 focus-within:opacity-100")}>
<button
type="button"
onPointerDown={startDrag}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={endDrag}
className="flex h-5 cursor-grab items-center rounded-full border bg-background/85 px-2 text-muted-foreground backdrop-blur hover:text-foreground active:cursor-grabbing touch-none"
aria-label="Move Kap"
title="Drag to move Kap"
>
<GripHorizontal className="size-3.5" />
</button>
{pos && (
<button
type="button"
onClick={(event) => { setPos(null); writeBuddyPos(null); backToTyping(event.currentTarget); }}
className="grid size-5 place-items-center rounded-full border bg-background/85 text-muted-foreground backdrop-blur hover:text-foreground cursor-pointer"
aria-label="Put Kap back in the corner"
title="Back to the corner"
>
<RotateCcw className="size-3" />
</button>
)}
<button
type="button"
onClick={(event) => { backToTyping(event.currentTarget); writeBuddy(false); }}
className="grid size-5 place-items-center rounded-full border bg-background/85 text-muted-foreground backdrop-blur hover:text-foreground cursor-pointer"
aria-label="Hide Kap"
title="Hide Kap (turn him back on in the Wardrobe)"
>
<X className="size-3" />
</button>
</div>
<div className="absolute inset-x-0 bottom-0" style={{ height: BOX.height - 24 }} aria-hidden>
<Kap3D mood="lit" tier={status.tier ?? FLAME_TIERS[1]} look={look} height={BOX.height - 24} listen fallback={null} />
</div>
</div>
);
}
39 changes: 39 additions & 0 deletions src/lib/kap-buddy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,42 @@ export function writeBuddy(on: boolean) {
}
window.dispatchEvent(new CustomEvent(BUDDY_EVENT));
}

/* ---- Where he stands: dragged anywhere, kept on screen ---- */

export const BUDDY_POS_KEY = "codey_kap_buddy_pos";
const MARGIN = 8;

/** Top-left corner as fractions of the viewport, so it survives a resize. */
export interface BuddyPos {
x: number;
y: number;
}

/** Pixel position of the box, clamped so the whole of Kap stays visible. */
export function placeBuddy(pos: BuddyPos | null, box: { width: number; height: number }, view: { width: number; height: number }) {
const maxX = Math.max(MARGIN, view.width - box.width - MARGIN);
const maxY = Math.max(MARGIN, view.height - box.height - MARGIN);
// Default: bottom-left, beside the editor.
const left = pos ? pos.x * view.width : 12;
const top = pos ? pos.y * view.height : view.height - box.height - 12;
return { left: Math.min(maxX, Math.max(MARGIN, left)), top: Math.min(maxY, Math.max(MARGIN, top)) };
}

export function readBuddyPos(): BuddyPos | null {
try {
const value = JSON.parse(localStorage.getItem(BUDDY_POS_KEY) ?? "null") as BuddyPos | null;
return value && Number.isFinite(value.x) && Number.isFinite(value.y) ? { x: value.x, y: value.y } : null;
} catch {
return null;
}
}

export function writeBuddyPos(pos: BuddyPos | null) {
try {
if (pos) localStorage.setItem(BUDDY_POS_KEY, JSON.stringify(pos));
else localStorage.removeItem(BUDDY_POS_KEY);
} catch {
// This tab only.
}
}
15 changes: 15 additions & 0 deletions src/lib/kap-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,18 @@ describe("kap events", () => {
expect((listener.mock.calls[0][0] as CustomEvent).detail).toEqual({ type: "key", error: true });
});
});

describe("kap buddy position", async () => {
const { placeBuddy } = await import("./kap-buddy");
const box = { width: 128, height: 162 };
const view = { width: 1440, height: 900 };

it("starts bottom-left", () => {
expect(placeBuddy(null, box, view)).toEqual({ left: 12, top: 900 - 162 - 12 });
});

it("keeps Kap on screen when dragged off or the window shrinks", () => {
expect(placeBuddy({ x: -1, y: 2 }, box, view)).toEqual({ left: 8, top: 900 - 162 - 8 });
expect(placeBuddy({ x: 0.95, y: 0.5 }, box, { width: 1280, height: 700 })).toEqual({ left: 1280 - 128 - 8, top: 350 });
});
});
Loading