Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, CircleHelp, Copy, CornerDownLeft, Delete, Lightbulb, Pause, Play, Shuffle, Volume2, VolumeX, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { RANKS, dayNumber, findPath, getDictionary, hashSeed, makePuzzle, maxPoints, rankFor, wordPoints, type Board } from "./engine";
import { HexBoard, type Thread } from "./hex-board";

export interface WordWeaverTheme {
  /** Colours cycled for woven threads */
  threads?: string[];
  /** Hint ring colour */
  hint?: string;
}

export interface WordWeaverProps {
  /** 1 = Classic 19-tile board (default), 2 = Grand 37-tile board. */
  initialLevel?: number;
  /** Replace the built-in dictionary (lowercase words, 3–8 letters). */
  words?: string[];
  /** localStorage key; `null` disables persistence. */
  storageKey?: string | null;
  /** Hints available per puzzle. */
  hints?: number;
  /** Called when every tile is woven, with the final score. */
  onGameOver?: (score: number) => void;
  onWord?: (word: string, points: number) => void;
  theme?: WordWeaverTheme;
  className?: string;
}

const DEFAULT_THREADS = ["#f06c5b", "#e9a23b", "#6fae45", "#1fa39a", "#3b8fe0", "#8b6cf0", "#e0629a", "#b98a57"];

interface Found {
  word: string;
  path: number[];
  points: number;
  color: string;
}

interface DailySave {
  words: string[];
  hints: number;
  elapsed: number;
  done: boolean;
}

interface SaveFile {
  daily: Record<string, DailySave>;
  streak: number;
  lastDone: number;
  best: number;
  solved: number;
}

const EMPTY_SAVE: SaveFile = { daily: {}, streak: 0, lastDone: 0, best: 0, solved: 0 };

function load(key: string | null): SaveFile {
  if (!key) return EMPTY_SAVE;
  try {
    const raw = window.localStorage.getItem(key);
    if (!raw) return EMPTY_SAVE;
    const v = JSON.parse(raw) as Partial<SaveFile>;
    return { ...EMPTY_SAVE, ...v, daily: v.daily ?? {} };
  } catch {
    return EMPTY_SAVE;
  }
}

function store(key: string | null, v: SaveFile) {
  if (!key) return;
  try {
    // keep the last 30 daily entries
    const keys = Object.keys(v.daily).map(Number).sort((a, b) => b - a);
    const daily: Record<string, DailySave> = {};
    keys.slice(0, 30).forEach((k) => (daily[k] = v.daily[k]));
    window.localStorage.setItem(key, JSON.stringify({ ...v, daily }));
  } catch {
    /* ignore */
  }
}

function fmtTime(s: number) {
  const m = Math.floor(s / 60);
  return `${m}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
}

function useBlips(enabled: boolean) {
  const ctxRef = React.useRef<AudioContext | null>(null);
  React.useEffect(() => () => void ctxRef.current?.close().catch(() => {}), []);
  return React.useCallback(
    (kind: "tick" | "good" | "bad" | "win", step = 0) => {
      if (!enabled) return;
      try {
        if (!ctxRef.current) ctxRef.current = new AudioContext();
        const ctx = ctxRef.current;
        void ctx.resume();
        const t = ctx.currentTime;
        const tone = (f: number, at: number, dur: number, type: OscillatorType, g = 0.06) => {
          const o = ctx.createOscillator();
          const gain = ctx.createGain();
          o.type = type;
          o.frequency.value = f;
          gain.gain.setValueAtTime(0.0001, t + at);
          gain.gain.exponentialRampToValueAtTime(g, t + at + 0.01);
          gain.gain.exponentialRampToValueAtTime(0.0001, t + at + dur);
          o.connect(gain).connect(ctx.destination);
          o.start(t + at);
          o.stop(t + at + dur + 0.02);
        };
        const scale = [392, 440, 494, 523, 587, 659, 740, 784, 880];
        if (kind === "tick") tone(scale[Math.min(step, scale.length - 1)], 0, 0.09, "triangle", 0.05);
        else if (kind === "good") [523, 659, 784].forEach((f, i) => tone(f, i * 0.06, 0.22, "sine"));
        else if (kind === "bad") tone(160, 0, 0.18, "square", 0.03);
        else [523, 659, 784, 1047, 1319].forEach((f, i) => tone(f, i * 0.1, 0.5, "sine"));
      } catch {
        /* optional */
      }
    },
    [enabled],
  );
}

export function WordWeaver({ initialLevel = 1, words, storageKey = "fazekit:word-weaver:v1", hints: hintCap = 3, onGameOver, onWord, theme, className }: WordWeaverProps) {
  const reduce = useReducedMotion();
  const rootRef = React.useRef<HTMLDivElement>(null);
  const dict = React.useMemo(() => getDictionary(words), [words]);
  const palette = theme?.threads?.length ? theme.threads : DEFAULT_THREADS;

  const [today, setToday] = React.useState<number | null>(null);
  const [mode, setMode] = React.useState<"daily" | "practice">("daily");
  const [practiceSeed, setPracticeSeed] = React.useState(1);
  const [radius, setRadius] = React.useState(initialLevel >= 2 ? 3 : 2);
  const [save, setSave] = React.useState<SaveFile>(EMPTY_SAVE);

  const [found, setFound] = React.useState<Found[]>([]);
  const [selection, setSelection] = React.useState<number[]>([]);
  const [typed, setTyped] = React.useState("");
  const [msg, setMsg] = React.useState<{ text: string; tone: "good" | "bad" | "info"; key: number } | null>(null);
  const [flash, setFlash] = React.useState<{ key: number; tone: "good" | "bad"; path: number[] } | null>(null);
  const [hintsUsed, setHintsUsed] = React.useState(0);
  const [hint, setHint] = React.useState<{ cell: number; text: string } | null>(null);
  const [elapsed, setElapsed] = React.useState(0);
  const [done, setDone] = React.useState(false);
  const [paused, setPaused] = React.useState(false);
  const [modal, setModal] = React.useState<"help" | "win" | null>(null);
  const [copied, setCopied] = React.useState(false);
  const [sound, setSound] = React.useState(false);
  const [listOpen, setListOpen] = React.useState(false);
  const blip = useBlips(sound);

  // client-only: today's number + saved progress
  React.useEffect(() => {
    const n = dayNumber(new Date());
    const sv = load(storageKey);
    setToday(n);
    setSave(sv);
    setPracticeSeed((Date.now() % 100000) + 7);
    if (!Object.keys(sv.daily).length) setModal("help");
  }, [storageKey]);

  const board: Board | null = React.useMemo(() => {
    if (today === null) return null;
    const seed = mode === "daily" ? hashSeed(`daily-${today}`) : hashSeed(`practice-${practiceSeed}`);
    return makePuzzle(seed, mode === "daily" ? 2 : radius, dict);
  }, [today, mode, practiceSeed, radius, dict]);

  const total = React.useMemo(() => (board ? maxPoints(board) : 0), [board]);

  // restore or reset progress whenever the board changes
  const boardKey = board ? `${mode}-${board.seed}-${board.radius}` : "";
  const [loadedKey, setLoadedKey] = React.useState("");
  if (board && loadedKey !== boardKey) {
    setLoadedKey(boardKey);
    const entry = mode === "daily" && today !== null ? save.daily[today] : undefined;
    const restored: Found[] = [];
    entry?.words.forEach((w) => {
      const path = board.solutions.get(w) ?? findPath(board, w, new Set());
      if (path) restored.push({ word: w, path, points: wordPoints(board, path), color: palette[restored.length % palette.length] });
    });
    setFound(restored);
    setHintsUsed(entry?.hints ?? 0);
    setElapsed(entry?.elapsed ?? 0);
    setDone(entry?.done ?? false);
    setSelection([]);
    setTyped("");
    setHint(null);
    setMsg(null);
    setPaused(false);
  }

  const woven = React.useMemo(() => {
    const m = new Map<number, string>();
    for (const f of found) for (const id of f.path) if (!m.has(id)) m.set(id, f.color);
    return m;
  }, [found]);
  const wovenSet = React.useMemo(() => new Set(woven.keys()), [woven]);
  const score = found.reduce((n, f) => n + f.points, 0) + (done ? 10 : 0);
  const rank = rankFor(score, total);
  const cellCount = board?.cells.length ?? 0;

  // persist daily progress
  const elapsedBucket = Math.floor(elapsed / 5);
  React.useEffect(() => {
    if (mode !== "daily" || today === null || !board || loadedKey !== boardKey) return;
    setSave((prev) => {
      const next = { ...prev, daily: { ...prev.daily, [today]: { words: found.map((f) => f.word), hints: hintsUsed, elapsed: Math.floor(elapsed), done } } };
      store(storageKey, next);
      return next;
    });
    // elapsed is saved on a coarse 5s cadence (elapsedBucket)
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [found, hintsUsed, done, mode, today, boardKey, storageKey, elapsedBucket]);

  const running = !!board && !paused && !modal && !done;

  // timer
  React.useEffect(() => {
    if (!running) return;
    const id = window.setInterval(() => setElapsed((e) => e + 1), 1000);
    return () => window.clearInterval(id);
  }, [running]);

  // pause on tab blur / hide
  React.useEffect(() => {
    const onHide = () => {
      if (document.hidden) setPaused(true);
    };
    const onBlur = () => setPaused(true);
    document.addEventListener("visibilitychange", onHide);
    window.addEventListener("blur", onBlur);
    return () => {
      document.removeEventListener("visibilitychange", onHide);
      window.removeEventListener("blur", onBlur);
    };
  }, []);

  const say = (text: string, tone: "good" | "bad" | "info") => setMsg({ text, tone, key: Date.now() });

  const submit = (path: number[]) => {
    if (!board || !running) return;
    const word = path.map((id) => board.cells[id].letter).join("");
    setSelection([]);
    setTyped("");
    if (word.length < 3) {
      if (word.length > 1) {
        say("Too short — 3 letters or more", "bad");
        setFlash({ key: Date.now(), tone: "bad", path });
        blip("bad");
      }
      return;
    }
    if (found.some((f) => f.word === word)) {
      say(`“${word.toUpperCase()}” already woven`, "info");
      setFlash({ key: Date.now(), tone: "bad", path });
      return;
    }
    if (!dict.all.has(word)) {
      say(`“${word.toUpperCase()}” isn’t in the word list`, "bad");
      setFlash({ key: Date.now(), tone: "bad", path });
      blip("bad");
      return;
    }
    const points = wordPoints(board, path);
    const fresh = path.filter((id) => !wovenSet.has(id)).length;
    const color = palette[found.length % palette.length];
    const next = [...found, { word, path, points: points + fresh, color }];
    setFound(next);
    setFlash({ key: Date.now(), tone: "good", path });
    const golds = path.filter((id) => board.cells[id].gold).length;
    say(`${word.toUpperCase()} +${points + fresh}${golds ? ` · gold ×${2 ** golds}` : ""}${fresh ? ` · ${fresh} new` : ""}`, "good");
    blip("good");
    onWord?.(word, points + fresh);
    if (hint && path.includes(hint.cell)) setHint(null);
    const nowWoven = new Set([...wovenSet, ...path]);
    if (!done && nowWoven.size >= board.cells.length) {
      setDone(true);
      const finalScore = next.reduce((n, f) => n + f.points, 0) + 10;
      onGameOver?.(finalScore);
      window.setTimeout(() => {
        setModal("win");
        blip("win");
      }, reduce ? 200 : 900);
      if (mode === "daily" && today !== null) {
        setSave((prev) => {
          const streak = prev.lastDone === today - 1 ? prev.streak + 1 : prev.lastDone === today ? prev.streak : 1;
          const v = { ...prev, streak, lastDone: today, best: Math.max(prev.best, finalScore), solved: prev.solved + (prev.lastDone === today ? 0 : 1) };
          store(storageKey, v);
          return v;
        });
      } else {
        setSave((prev) => {
          const v = { ...prev, best: Math.max(prev.best, finalScore) };
          store(storageKey, v);
          return v;
        });
      }
    }
  };

  const takeHint = () => {
    if (!board || hintsUsed >= hintCap || !running) return;
    const foundSet = new Set(found.map((f) => f.word));
    const candidates = [...board.solutions.entries()].filter(([w]) => !foundSet.has(w));
    if (!candidates.length) return say("You found every word!", "info");
    const plantedWords = new Set(board.planted.map((p) => p.word));
    const scored = candidates
      .map(([w, p]) => ({ w, p, s: p.filter((id) => !wovenSet.has(id)).length * 10 + (plantedWords.has(w) ? 5 : 0) + w.length }))
      .sort((a, b) => b.s - a.s);
    const pick = scored[0];
    setHintsUsed((h) => h + 1);
    setHint({ cell: pick.p[0], text: `Starts with “${pick.w.slice(0, 2).toUpperCase()}…”, ${pick.w.length} letters` });
    say(`Hint: starts with “${pick.w.slice(0, 2).toUpperCase()}…”, ${pick.w.length} letters`, "info");
  };

  // keyboard: type to trace, Enter to weave, Backspace, Escape
  React.useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      const root = rootRef.current;
      const active = document.activeElement;
      if (!root || (active && active !== document.body && !root.contains(active))) return;
      if (e.metaKey || e.ctrlKey || e.altKey) return;
      if (e.key === "Escape") {
        if (modal) setModal(null);
        else if (paused) setPaused(false);
        else if (selection.length || typed) {
          setSelection([]);
          setTyped("");
        } else setPaused(true);
        return;
      }
      if (!running || !board) return;
      if (/^[a-zA-Z]$/.test(e.key)) {
        e.preventDefault();
        const t = (typed + e.key.toLowerCase()).slice(0, 8);
        setTyped(t);
        setSelection(findPath(board, t, wovenSet) ?? []);
        blip("tick", t.length - 1);
      } else if (e.key === "Backspace") {
        e.preventDefault();
        const t = typed.slice(0, -1);
        setTyped(t);
        setSelection(t ? (findPath(board, t, wovenSet) ?? []) : selection.slice(0, -1));
      } else if (e.key === "Enter") {
        if (!typed && !selection.length) return; // let focused buttons handle Enter
        e.preventDefault();
        if (typed && !selection.length) {
          say(`“${typed.toUpperCase()}” can’t be traced on the board`, "bad");
          setTyped("");
          blip("bad");
        } else if (selection.length) submit(selection);
      } else if (e.key === "?") setModal("help");
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  });

  const invalidTyped = !!typed && !selection.length;
  const currentLetters = selection.length ? selection.map((id) => board?.cells[id].letter ?? "") : invalidTyped ? typed.split("") : [];
  const currentWord = currentLetters.join("");
  const currentFound = !!currentWord && found.some((f) => f.word === currentWord);

  const shareText = () => {
    if (!board) return "";
    const rows: string[] = [];
    for (let r = -board.radius; r <= board.radius; r++) {
      const row = board.cells
        .filter((c) => c.r === r)
        .map((c) => (c.gold && woven.has(c.id) ? "🟨" : woven.has(c.id) ? "🟪" : "⬜"))
        .join("");
      rows.push(" ".repeat(Math.abs(r)) + row);
    }
    const title = mode === "daily" ? `Word Weaver #${today}` : `Word Weaver · practice`;
    return `${title} · ${RANKS[rank].name}\n⬢ ${woven.size}/${cellCount} woven in ${fmtTime(elapsed)}\n🧵 ${found.length} words · ${score} pts · ${hintsUsed} hint${hintsUsed === 1 ? "" : "s"}\n${rows.join("\n")}`;
  };

  const share = async () => {
    const text = shareText();
    try {
      await navigator.clipboard.writeText(text);
      setCopied(true);
      window.setTimeout(() => setCopied(false), 1800);
    } catch {
      say("Couldn’t reach the clipboard — select the text to copy", "info");
    }
  };

  const newPractice = (r = radius) => {
    setRadius(r);
    setMode("practice");
    setPracticeSeed((s) => s + 1 + Math.floor(Math.random() * 1000));
    setModal(null);
  };

  const style = { "--ww-hint": theme?.hint ?? "#0ea5e9" } as React.CSSProperties;
  const pct = total ? Math.min(1, score / total) : 0;
  const nextRank = RANKS[rank + 1];

  return (
    <div ref={rootRef} style={style} className={cn("@container/ww relative flex h-[700px] w-full flex-col overflow-hidden bg-background text-foreground", className)}>
      <div className="pointer-events-none absolute inset-0 bg-[radial-gradient(70%_50%_at_30%_0%,color-mix(in_oklab,var(--primary)_10%,transparent),transparent_70%)]" aria-hidden />

      {/* header */}
      <header className="relative z-10 flex h-14 shrink-0 items-center gap-2 border-b px-3 @3xl/ww:px-5">
        <Logo />
        <div className="min-w-0">
          <p className="truncate text-sm font-bold leading-tight tracking-tight">Word Weaver</p>
          <p className="text-[11px] leading-tight text-muted-foreground">{mode === "daily" ? (today ? `Daily #${today}` : "Loading…") : `Practice · ${radius === 3 ? "Grand" : "Classic"}`}</p>
        </div>
        <div className="ml-auto flex items-center gap-1.5">
          <div className="hidden rounded-full border p-0.5 @md/ww:flex" role="radiogroup" aria-label="Mode">
            {(["daily", "practice"] as const).map((m) => (
              <button
                key={m}
                type="button"
                role="radio"
                aria-checked={mode === m}
                onClick={() => (m === "practice" ? mode !== "practice" && newPractice() : setMode("daily"))}
                className={cn("relative h-7 rounded-full px-3 text-xs font-semibold capitalize outline-none transition focus-visible:ring-2 focus-visible:ring-ring", mode === m ? "text-background" : "text-muted-foreground hover:text-foreground")}
              >
                {mode === m && <motion.span layoutId="ww-mode" className="absolute inset-0 rounded-full bg-foreground" transition={{ type: "spring", stiffness: 500, damping: 36 }} />}
                <span className="relative">{m}</span>
              </button>
            ))}
          </div>
          <span className="flex h-8 min-w-14 items-center justify-center rounded-full border px-2.5 text-xs font-semibold tabular-nums" aria-label={`Time ${fmtTime(elapsed)}`}>
            {fmtTime(elapsed)}
          </span>
          <HeaderButton label={sound ? "Mute" : "Unmute"} onClick={() => setSound((v) => !v)}>
            {sound ? <Volume2 className="size-4" /> : <VolumeX className="size-4" />}
          </HeaderButton>
          <HeaderButton label="How to play" onClick={() => setModal("help")}>
            <CircleHelp className="size-4" />
          </HeaderButton>
          <HeaderButton label="Pause" onClick={() => setPaused(true)} disabled={!running}>
            <Pause className="size-4" />
          </HeaderButton>
        </div>
      </header>

      <div className="relative z-0 flex min-h-0 flex-1 flex-col @4xl/ww:flex-row">
        {/* play area */}
        <section className="flex min-h-0 flex-1 flex-col items-center px-3 pb-2 pt-2 @3xl/ww:px-6" aria-label="Board">
          {/* compact progress for narrow */}
          <div className="flex w-full max-w-md items-center gap-3 @4xl/ww:hidden">
            <WovenRing woven={woven.size} total={cellCount} small />
            <div className="min-w-0 flex-1">
              <div className="flex items-baseline justify-between text-xs">
                <span className="font-semibold">{RANKS[rank].name}</span>
                <span className="tabular-nums text-muted-foreground">
                  {score} pts · {found.length} words
                </span>
              </div>
              <RankBar pct={pct} />
            </div>
          </div>

          {/* current word */}
          <div className="flex h-14 w-full shrink-0 flex-col items-center justify-center" aria-live="polite">
            <AnimatePresence mode="popLayout" initial={false}>
              {currentLetters.length ? (
                <motion.div key="word" className="flex items-center gap-1" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, pointerEvents: "none" }}>
                  {currentLetters.map((l, i) => (
                    <motion.span
                      key={`${i}-${l}`}
                      initial={reduce ? false : { y: 8, opacity: 0, scale: 0.7 }}
                      animate={{ y: 0, opacity: 1, scale: 1 }}
                      className={cn(
                        "grid h-9 w-7 place-items-center rounded-md text-lg font-extrabold uppercase",
                        invalidTyped ? "bg-destructive/15 text-destructive" : currentFound ? "bg-muted text-muted-foreground" : "bg-primary/12 text-primary",
                      )}
                    >
                      {l}
                    </motion.span>
                  ))}
                </motion.div>
              ) : msg ? (
                <motion.p
                  key={msg.key}
                  initial={reduce ? false : { y: 6, opacity: 0, scale: 0.96 }}
                  animate={{ y: 0, opacity: 1, scale: 1, x: msg.tone === "bad" && !reduce ? [0, -6, 6, -3, 0] : 0 }}
                  exit={{ opacity: 0, pointerEvents: "none" }}
                  className={cn(
                    "rounded-full px-3 py-1.5 text-sm font-semibold",
                    msg.tone === "good" ? "bg-[color-mix(in_oklab,var(--success)_16%,transparent)] text-[color-mix(in_oklab,var(--success)_85%,var(--foreground))]" : msg.tone === "bad" ? "bg-destructive/10 text-destructive" : "bg-muted text-muted-foreground",
                  )}
                >
                  {msg.text}
                </motion.p>
              ) : (
                <motion.p key="idle" className="text-sm text-muted-foreground" initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
                  {done ? "Board woven — keep hunting for more words" : "Drag across touching tiles, or type"}
                </motion.p>
              )}
            </AnimatePresence>
          </div>

          {/* board */}
          <div className={cn("relative flex min-h-0 w-full max-w-[560px] flex-1 items-center justify-center transition-[filter] duration-300", paused && "blur-md")}>
            {board ? (
              <HexBoard
                board={board}
                woven={woven}
                threads={found.map((f) => ({ word: f.word, path: f.path, color: f.color }) satisfies Thread)}
                selection={selection}
                invalid={currentFound}
                hintCell={hint?.cell ?? null}
                flash={flash}
                disabled={!running}
                onSelectionChange={(p) => {
                  setTyped("");
                  setSelection(p);
                  if (p.length > selection.length) blip("tick", p.length - 1);
                }}
                onSubmit={submit}
              />
            ) : (
              <div className="size-8 animate-spin rounded-full border-2 border-muted border-t-primary" aria-label="Loading board" />
            )}
          </div>

          {/* actions */}
          <div className="mt-1 flex w-full max-w-md shrink-0 items-center justify-center gap-2">
            <ActionButton onClick={takeHint} disabled={!running || hintsUsed >= hintCap} label={`Hint (${hintCap - hintsUsed} left)`}>
              <Lightbulb className="size-4" />
              <span className="tabular-nums">{hintCap - hintsUsed}</span>
            </ActionButton>
            <ActionButton
              onClick={() => {
                setSelection([]);
                setTyped("");
              }}
              disabled={!selection.length && !typed}
              label="Clear"
            >
              <Delete className="size-4" />
              <span className="hidden @sm/ww:inline">Clear</span>
            </ActionButton>
            <button
              type="button"
              onClick={() => selection.length && submit(selection)}
              disabled={selection.length < 3 || !running}
              className="inline-flex h-11 min-w-32 items-center justify-center gap-2 rounded-full bg-primary px-5 text-sm font-semibold text-primary-foreground shadow-sm outline-none transition hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-40"
            >
              Weave <CornerDownLeft className="size-4" aria-hidden />
            </button>
          </div>

          {/* found words strip (narrow) */}
          <button
            type="button"
            onClick={() => setListOpen(true)}
            className="mt-2 flex h-10 w-full max-w-md shrink-0 items-center gap-2 overflow-hidden rounded-xl border bg-card/70 px-3 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring @4xl/ww:hidden"
            aria-label={`Show ${found.length} found words`}
          >
            <span className="shrink-0 text-xs font-semibold">{found.length} words</span>
            <span className="flex min-w-0 flex-1 gap-1 overflow-hidden">
              {[...found].reverse().map((f) => (
                <span key={f.word} className="shrink-0 rounded-md px-1.5 py-0.5 text-[11px] font-semibold uppercase" style={{ background: `color-mix(in oklab, ${f.color} 18%, transparent)` }}>
                  {f.word}
                </span>
              ))}
              {!found.length && <span className="text-xs text-muted-foreground">Words you weave appear here</span>}
            </span>
          </button>
        </section>

        {/* sidebar (wide) */}
        <aside className="hidden w-[340px] shrink-0 flex-col gap-3 border-l bg-muted/20 p-4 @4xl/ww:flex" aria-label="Progress">
          <div className="flex items-center gap-4 rounded-2xl border bg-card p-4">
            <WovenRing woven={woven.size} total={cellCount} />
            <div className="min-w-0 flex-1">
              <p className="text-xs text-muted-foreground">Rank</p>
              <p className="text-lg font-extrabold tracking-tight">{RANKS[rank].name}</p>
              <p className="text-xs tabular-nums text-muted-foreground">
                {score} pts{nextRank ? ` · ${Math.max(0, Math.ceil(nextRank.at * total) - score)} to ${nextRank.name}` : " · top rank"}
              </p>
            </div>
          </div>
          <RankBar pct={pct} labels />
          <FoundList found={found} total={board?.solutions.size ?? 0} className="min-h-0 flex-1" />
          <div className="grid grid-cols-2 gap-2">
            <button type="button" onClick={share} disabled={!found.length} className="inline-flex h-10 items-center justify-center gap-1.5 rounded-xl border bg-card text-xs font-semibold outline-none transition hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40">
              {copied ? <Check className="size-4 text-[var(--success)]" /> : <Copy className="size-4" />} {copied ? "Copied" : "Share result"}
            </button>
            <button type="button" onClick={() => newPractice()} className="inline-flex h-10 items-center justify-center gap-1.5 rounded-xl border bg-card text-xs font-semibold outline-none transition hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring">
              <Shuffle className="size-4" /> New practice
            </button>
          </div>
          {mode === "practice" && <SizeToggle radius={radius} onPick={(r) => newPractice(r)} />}
          <p className="text-center text-[11px] text-muted-foreground">
            Streak <b className="tabular-nums text-foreground">{save.streak}</b> · Solved <b className="tabular-nums text-foreground">{save.solved}</b> · Best <b className="tabular-nums text-foreground">{save.best}</b>
          </p>
        </aside>
      </div>

      {/* found list drawer (narrow) */}
      <AnimatePresence>
        {listOpen && (
          <motion.div className="absolute inset-0 z-40 flex flex-col justify-end bg-background/50 backdrop-blur-sm @4xl/ww:hidden" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, pointerEvents: "none" }} onClick={() => setListOpen(false)}>
            <motion.div
              role="dialog"
              aria-label="Found words"
              className="flex max-h-[75%] flex-col gap-3 rounded-t-3xl border-t bg-card p-4 shadow-2xl"
              initial={reduce ? false : { y: 80 }}
              animate={{ y: 0 }}
              exit={reduce ? undefined : { y: 80 }}
              transition={{ type: "spring", stiffness: 400, damping: 36 }}
              onClick={(e) => e.stopPropagation()}
            >
              <div className="flex items-center justify-between">
                <p className="text-sm font-semibold">Your words</p>
                <button type="button" autoFocus onClick={() => setListOpen(false)} aria-label="Close" className="grid size-8 place-items-center rounded-full outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring">
                  <X className="size-4" />
                </button>
              </div>
              <FoundList found={found} total={board?.solutions.size ?? 0} className="min-h-0 flex-1 border-0 bg-transparent p-0" />
              <div className="grid grid-cols-2 gap-2">
                <button type="button" onClick={share} disabled={!found.length} className="inline-flex h-10 items-center justify-center gap-1.5 rounded-xl border text-xs font-semibold outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40">
                  {copied ? <Check className="size-4" /> : <Copy className="size-4" />} {copied ? "Copied" : "Share result"}
                </button>
                {mode === "daily" ? (
                  <button type="button" onClick={() => { setListOpen(false); newPractice(); }} className="inline-flex h-10 items-center justify-center gap-1.5 rounded-xl border text-xs font-semibold outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring">
                    <Shuffle className="size-4" /> Practice
                  </button>
                ) : (
                  <button type="button" onClick={() => { setListOpen(false); setMode("daily"); }} className="inline-flex h-10 items-center justify-center gap-1.5 rounded-xl border text-xs font-semibold outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring">
                    Back to daily
                  </button>
                )}
              </div>
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>

      {/* pause */}
      <AnimatePresence>
        {paused && !modal && (
          <motion.div className="absolute inset-0 z-40 grid place-items-center bg-background/40" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, pointerEvents: "none" }}>
            <div className="w-[min(88%,320px)] rounded-3xl border bg-card p-6 text-center shadow-2xl" role="dialog" aria-label="Paused">
              <p className="text-xl font-extrabold tracking-tight">Paused</p>
              <p className="mt-1 text-sm text-muted-foreground">Timer stopped at {fmtTime(elapsed)}. The board is hidden while paused.</p>
              <button type="button" autoFocus onClick={() => setPaused(false)} className="mt-5 inline-flex h-11 w-full items-center justify-center gap-2 rounded-xl bg-foreground text-sm font-semibold text-background outline-none hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
                <Play className="size-4" /> Resume
              </button>
              {mode === "practice" && (
                <div className="mt-2 @md/ww:hidden">
                  <SizeToggle radius={radius} onPick={(r) => newPractice(r)} />
                </div>
              )}
              <div className="mt-2 grid grid-cols-2 gap-2 @md/ww:hidden">
                <button type="button" onClick={() => setMode("daily")} className={cn("h-9 rounded-lg border text-xs font-semibold outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring", mode === "daily" && "bg-accent")}>
                  Daily
                </button>
                <button type="button" onClick={() => newPractice()} className="h-9 rounded-lg border text-xs font-semibold outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring">
                  New practice
                </button>
              </div>
            </div>
          </motion.div>
        )}
      </AnimatePresence>

      {/* modals */}
      <AnimatePresence>
        {modal && (
          <motion.div className="absolute inset-0 z-50 grid place-items-center bg-background/60 p-4 backdrop-blur-md" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, pointerEvents: "none" }}>
            <motion.div
              role="dialog"
              aria-modal="true"
              aria-label={modal === "help" ? "How to play" : "Board woven"}
              className="relative max-h-full w-full max-w-sm overflow-y-auto rounded-3xl border bg-card p-6 shadow-2xl"
              initial={reduce ? false : { scale: 0.92, y: 16 }}
              animate={{ scale: 1, y: 0 }}
              exit={reduce ? undefined : { scale: 0.95, y: 8 }}
              transition={{ type: "spring", stiffness: 380, damping: 30 }}
            >
              <button type="button" aria-label="Close" onClick={() => setModal(null)} className="absolute right-3 top-3 grid size-8 place-items-center rounded-full text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring">
                <X className="size-4" />
              </button>
              {modal === "help" ? (
                <>
                  <Logo className="size-10" />
                  <h2 className="mt-3 text-xl font-extrabold tracking-tight">How to weave</h2>
                  <ul className="mt-3 space-y-2.5 text-sm text-muted-foreground">
                    <li>
                      <b className="text-foreground">Trace words</b> of 3+ letters by dragging across touching hexes — or tap tiles one by one, or just type.
                    </li>
                    <li>
                      Every tile you use gets <b className="text-foreground">woven</b> with a coloured thread. Weave <b className="text-foreground">all {cellCount || 19} tiles</b> to finish the board.
                    </li>
                    <li>
                      Longer words score more. Words through a <span className="font-semibold text-[#b7791f]">gold tile</span> score double, and each newly woven tile adds +1.
                    </li>
                    <li>A new daily board arrives every day. Practice boards are unlimited.</li>
                  </ul>
                  <button type="button" autoFocus onClick={() => setModal(null)} className="mt-5 inline-flex h-11 w-full items-center justify-center rounded-xl bg-primary text-sm font-semibold text-primary-foreground outline-none hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
                    Start weaving
                  </button>
                </>
              ) : (
                <>
                  <WinBurst reduce={!!reduce} />
                  <h2 className="text-center text-2xl font-extrabold tracking-tight">Board woven!</h2>
                  <p className="mt-1 text-center text-sm text-muted-foreground">
                    {RANKS[rank].name} · {score} pts in {fmtTime(elapsed)}
                  </p>
                  <pre className="mt-4 whitespace-pre-wrap rounded-xl bg-muted/70 p-3 text-center font-sans text-xs leading-relaxed">{shareText()}</pre>
                  <div className="mt-4 grid grid-cols-2 gap-2">
                    <button type="button" autoFocus onClick={share} className="inline-flex h-11 items-center justify-center gap-1.5 rounded-xl bg-primary text-sm font-semibold text-primary-foreground outline-none hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
                      {copied ? <Check className="size-4" /> : <Copy className="size-4" />} {copied ? "Copied" : "Share"}
                    </button>
                    <button type="button" onClick={() => setModal(null)} className="inline-flex h-11 items-center justify-center rounded-xl border text-sm font-semibold outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring">
                      Keep hunting
                    </button>
                  </div>
                  <div className="mt-2 grid grid-cols-2 gap-2">
                    <button type="button" onClick={() => newPractice(2)} className="h-9 rounded-lg text-xs font-semibold text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring">
                      Practice · Classic
                    </button>
                    <button type="button" onClick={() => newPractice(3)} className="h-9 rounded-lg text-xs font-semibold text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring">
                      Practice · Grand
                    </button>
                  </div>
                </>
              )}
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

function SizeToggle({ radius, onPick }: { radius: number; onPick: (r: number) => void }) {
  return (
    <div className="grid grid-cols-2 gap-1 rounded-xl border bg-card p-1" role="radiogroup" aria-label="Practice board size">
      {[
        [2, "Classic · 19"],
        [3, "Grand · 37"],
      ].map(([r, label]) => (
        <button
          key={r}
          type="button"
          role="radio"
          aria-checked={radius === r}
          onClick={() => onPick(r as number)}
          className={cn("h-8 rounded-lg text-xs font-semibold outline-none transition focus-visible:ring-2 focus-visible:ring-ring", radius === r ? "bg-foreground text-background" : "text-muted-foreground hover:text-foreground")}
        >
          {label}
        </button>
      ))}
    </div>
  );
}

function Logo({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 32 32" className={cn("size-8 shrink-0", className)} aria-hidden>
      <path d="M16 2 L28 9 V23 L16 30 L4 23 V9 Z" className="fill-primary" />
      <path d="M9 12 C13 16 19 10 23 14 M9 18 C13 22 19 16 23 20" className="stroke-primary-foreground" strokeWidth="2" fill="none" strokeLinecap="round" />
    </svg>
  );
}

function HeaderButton({ label, onClick, disabled, children }: { label: string; onClick: () => void; disabled?: boolean; children: React.ReactNode }) {
  return (
    <button type="button" aria-label={label} title={label} onClick={onClick} disabled={disabled} className="grid size-8 place-items-center rounded-full border text-muted-foreground outline-none transition hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40">
      {children}
    </button>
  );
}

function ActionButton({ label, onClick, disabled, children }: { label: string; onClick: () => void; disabled?: boolean; children: React.ReactNode }) {
  return (
    <button type="button" aria-label={label} title={label} onClick={onClick} disabled={disabled} className="inline-flex h-11 min-w-11 items-center justify-center gap-1.5 rounded-full border bg-card px-4 text-sm font-semibold outline-none transition hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40">
      {children}
    </button>
  );
}

function WovenRing({ woven, total, small }: { woven: number; total: number; small?: boolean }) {
  const r = 20;
  const c = 2 * Math.PI * r;
  const pct = total ? woven / total : 0;
  return (
    <div className={cn("relative grid shrink-0 place-items-center", small ? "size-12" : "size-[72px]")} role="img" aria-label={`${woven} of ${total} tiles woven`}>
      <svg viewBox="0 0 48 48" className="absolute inset-0 -rotate-90">
        <circle cx="24" cy="24" r={r} fill="none" className="stroke-muted" strokeWidth="5" />
        <motion.circle cx="24" cy="24" r={r} fill="none" className="stroke-primary" strokeWidth="5" strokeLinecap="round" strokeDasharray={c} initial={false} animate={{ strokeDashoffset: c * (1 - pct) }} transition={{ type: "spring", stiffness: 120, damping: 20 }} />
      </svg>
      <span className={cn("relative text-center font-extrabold leading-none tabular-nums", small ? "text-[11px]" : "text-base")}>
        {woven}
        <span className="text-muted-foreground">/{total}</span>
      </span>
    </div>
  );
}

function RankBar({ pct, labels }: { pct: number; labels?: boolean }) {
  return (
    <div>
      <div className="relative mt-1 h-2 rounded-full bg-muted">
        <motion.div className="absolute inset-y-0 left-0 rounded-full bg-gradient-to-r from-primary/70 to-primary" initial={false} animate={{ width: `${Math.max(2, (pct / RANKS[RANKS.length - 1].at) * 100 > 100 ? 100 : (pct / RANKS[RANKS.length - 1].at) * 100)}%` }} />
        {RANKS.slice(1).map((r) => (
          <span key={r.name} className={cn("absolute top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-card", pct >= r.at ? "bg-primary" : "bg-muted-foreground/30")} style={{ left: `${(r.at / RANKS[RANKS.length - 1].at) * 100}%` }} />
        ))}
      </div>
      {labels && (
        <div className="mt-1.5 flex justify-between text-[10px] text-muted-foreground">
          <span>{RANKS[0].name}</span>
          <span>{RANKS[RANKS.length - 1].name}</span>
        </div>
      )}
    </div>
  );
}

function FoundList({ found, total, className }: { found: Found[]; total: number; className?: string }) {
  const reduce = useReducedMotion();
  return (
    <div className={cn("flex flex-col rounded-2xl border bg-card p-3", className)}>
      <p className="mb-2 flex items-baseline justify-between text-xs">
        <span className="font-semibold">Words</span>
        <span className="tabular-nums text-muted-foreground">
          {found.length} of {total}
        </span>
      </p>
      <ul className="-mr-1 flex min-h-0 flex-1 flex-wrap content-start gap-1.5 overflow-y-auto pr-1">
        <AnimatePresence initial={false}>
          {[...found].reverse().map((f) => (
            <motion.li
              key={f.word}
              layout={!reduce}
              initial={reduce ? false : { scale: 0.6, opacity: 0 }}
              animate={{ scale: 1, opacity: 1 }}
              className="flex h-7 items-center gap-1.5 rounded-lg px-2 text-xs font-semibold uppercase"
              style={{ background: `color-mix(in oklab, ${f.color} 16%, transparent)` }}
            >
              <span className="size-1.5 rounded-full" style={{ background: f.color }} aria-hidden />
              {f.word}
              <span className="font-normal tabular-nums text-muted-foreground">{f.points}</span>
            </motion.li>
          ))}
        </AnimatePresence>
        {!found.length && <li className="text-xs text-muted-foreground">No words yet. Longer words and gold tiles score more.</li>}
      </ul>
    </div>
  );
}

const BURST = Array.from({ length: 14 }, (_, i) => ({ a: (i / 14) * Math.PI * 2, d: 46 + ((i * 17) % 22), c: DEFAULT_THREADS[i % DEFAULT_THREADS.length] }));

function WinBurst({ reduce }: { reduce: boolean }) {
  return (
    <div className="relative mx-auto mb-3 grid size-20 place-items-center">
      {!reduce &&
        BURST.map((b, i) => (
          <motion.span
            key={i}
            className="absolute h-1 w-4 rounded-full"
            style={{ background: b.c, rotate: `${(b.a * 180) / Math.PI}deg` }}
            initial={{ x: 0, y: 0, opacity: 0 }}
            animate={{ x: Math.cos(b.a) * b.d, y: Math.sin(b.a) * b.d, opacity: [0, 1, 0] }}
            transition={{ duration: 1.1, delay: 0.1, ease: "easeOut" }}
          />
        ))}
      <motion.div initial={reduce ? false : { scale: 0.3, rotate: -30 }} animate={{ scale: 1, rotate: 0 }} transition={{ type: "spring", stiffness: 260, damping: 12 }}>
        <Logo className="size-16" />
      </motion.div>
    </div>
  );
}

More in Games

View all →