{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "puzzle-cube",
  "title": "Puzzle cube",
  "description": "The twisty cube as a flat SVG machine: orthographic projection, painter's algorithm and flat shading over the same permutation solver as the WebGL rig.",
  "registryDependencies": [
    "https://robocn.dev/r/robot-style.json",
    "https://robocn.dev/r/robot-kinematics.json",
    "https://robocn.dev/r/use-robot-motion.json",
    "https://robocn.dev/r/robot-style.json",
    "https://robocn.dev/r/robot-color.json",
    "https://robocn.dev/r/cube-geometry.json"
  ],
  "files": [
    {
      "path": "src/components/ui/puzzle-cube.tsx",
      "content": "\"use client\"\n\n/**\n * puzzle-cube — the twisty cube drawn flat, the sibling of `rubiks-cube`.\n *\n * One solver, two renderers: the state, every turn, the scramble, the drag\n * geometry and the solve all come from `src/lib/robocn/cube.ts`, exactly as\n * they do in the WebGL rig. This one owns only the projection and the paint —\n * cubies modelled once in world units, pushed through `robotCamera`, back-face\n * culled by the projected normal and painter-sorted by cubie centre, which is\n * exact for convex, never-interpenetrating boxes under a linear camera,\n * including the middle of a turn.\n *\n * Solved, not illustrated: everything the solver owns. Illustrated: the flat\n * shading (one key light, no rays), the eased travel of a turn, the gap\n * between stickers and the underglow when it comes home. Notes:\n * `docs/puzzle-cube.md`.\n */\n\nimport * as React from \"react\"\n\nimport {\n  applyTurn,\n  createCube,\n  cubeFaces,\n  exposedFaces,\n  faceNormals,\n  formatMove,\n  grabFromDrag,\n  invertMove,\n  isSolved,\n  moveToTurn,\n  parseAlgorithm,\n  parseMove,\n  scrambleMoves,\n  solveCube,\n  stickerFace,\n  turnToMove,\n  inTurn,\n  type CubeAxis,\n  type CubeFace,\n  type CubeMove,\n  type CubeState,\n  type CubeTurn,\n  type Cubie,\n} from \"@/lib/robocn/cube\"\nimport { resolveCssColor, watchCssColors } from \"@/lib/robocn/color\"\nimport {\n  boxCorners,\n  fitFrame,\n  px,\n  prefersReducedMotion,\n  resolveRobotPalette,\n  resolveRobotSize,\n  robotCamera,\n  type RobotCamera,\n  type RobotPaletteProps,\n  type RobotSize,\n  type RobotVariant,\n  type RobotView,\n  robotCameraAt,\n  robotViews,\n} from \"@/lib/robocn/style\"\nimport { cn } from \"@/lib/utils\"\n\n/** What the cube does with nobody driving it. `solve` is the live one. */\nexport type PuzzleCubeBehavior = \"cycle\" | \"scramble\" | \"solve\" | \"static\"\n\n/** Where a turn came from, so a stopwatch can time a person without timing itself. */\nexport type PuzzleCubeSource = \"user\" | \"solver\" | \"scramble\" | \"loop\" | \"undo\" | \"redo\"\n\n/** How far the camera has been turned off the view it started from. */\nexport interface PuzzleCubeOrbit {\n  /** Degrees round the cube. Any angle at all, and it wraps. */\n  azimuth: number\n  /**\n   * Degrees above the view's own elevation. Clamped so the camera runs to\n   * straight overhead and straight underneath — both poles — and no further.\n   */\n  elevation: number\n}\n\n/** The standard scheme, and the last word when nothing else supplies a face. */\nexport const standardCubeFaceColors: Record<CubeFace, string> = {\n  U: \"#f8fafc\",\n  D: \"#facc15\",\n  F: \"#22c55e\",\n  B: \"#2563eb\",\n  R: \"#dc2626\",\n  L: \"#f97316\",\n}\n\n/**\n * Face colours resolve prop → CSS variable → the standard scheme, the same\n * three steps every palette role takes and the same names the WebGL rig\n * reads, so one `--robot-cube-u` retints both renderers.\n */\nconst faceVariables = Object.fromEntries(\n  cubeFaces.map((face) => [\n    face,\n    `var(--robot-cube-${face.toLowerCase()}, ${standardCubeFaceColors[face]})`,\n  ]),\n) as Record<CubeFace, string>\n\n/** The algorithm the `cycle` behaviour plays. Six repeats come home. */\nconst cycleAlgorithm = parseAlgorithm(\"R U R' U'\")\n\n/** The cube's edge in world units; the drawing frame does the rest. */\nconst EDGE = 100\nconst VIEW_WIDTH = 200\nconst VIEW_HEIGHT = 200\nconst NATIVE_VIEW: RobotView = \"iso\"\n\n/** The envelope the frame fits: the cube, its shadow and a little air. */\nconst ENVELOPE = boxCorners(\n  { x: -EDGE * 0.72, y: -EDGE / 2 - 8, z: -EDGE * 0.72 },\n  { x: EDGE * 0.72, y: EDGE / 2 + 10, z: EDGE * 0.72 },\n)\n\nconst viewNames: Record<RobotView, string> = {\n  plan: \"plan view\",\n  front: \"front elevation\",\n  profile: \"side elevation\",\n  iso: \"isometric view\",\n}\n\n/* The camera a hand turns: drag sweeps it round, and the cube can be looked\n * at from anywhere at all — over the top, under the bottom — not only from\n * the four angles the set names. */\n\n/** Shortest way round: a camera turned 370 degrees is turned 10. */\nconst wrapTurn = (degrees: number) =>\n  Number.isFinite(degrees) ? (((degrees % 360) + 360) % 360) : 0\n\nconst finiteNum = (value: number, fallback = 0) =>\n  Number.isFinite(value) ? value : fallback\n\nconst clampNum = (value: number, min: number, max: number) =>\n  Math.min(max, Math.max(min, value))\n\n/**\n * How far the camera stands from looking straight down or straight up. Those\n * are the poles of the projection, not cliffs short of it: the drawing is\n * exact at both (plan view *is* the pole), so the orbit runs all the way to\n * them — it just cannot go past, the same wall every orbit camera has.\n */\nconst POLE = 90\n\n/** How far off its view a hand may turn the camera: to overhead, or underneath. */\nconst elevationBounds = (stanceElevation: number) => ({\n  min: -POLE - stanceElevation,\n  max: POLE - stanceElevation,\n})\n/** Degrees of camera per pixel of drag: a whole cube-width of sweep per half\n * the cube's screen, which is about what a wrist expects. */\nconst ORBIT_SWEEP = 0.9\nconst ORBIT_RISE = 0.5\nconst ORBIT_STEP = 12\n\n/**\n * The key light, in world axes — the whole lighting model. Top brightest,\n * the front face mid, the right face darkest, which is the hierarchy a cube\n * drawn flat needs to read as a cube.\n */\nconst LIGHT = (() => {\n  const x = 0.5\n  const y = 0.82\n  const z = -0.3\n  const length = Math.hypot(x, y, z) || 1\n  return [x / length, y / length, z / length] as const\n})()\n\n/** Floor and ceiling on the flat shading: nothing in full shadow, nothing blown out. */\nconst SHADE_MIN = 0.62\nconst SHADE_SPAN = 0.38\n\n/**\n * The cube's own frame (`x` right, `y` up, `z` out) yawed into the world the\n * camera looks at, so the isometric view shows U, F and R — the same three\n * faces the WebGL rig's default camera shows.\n */\nconst toWorld = (v: readonly number[]): [number, number, number] => [v[2], v[1], -v[0]]\n\n/** The two in-plane axes of each face: how a sticker quad is spanned. */\nconst faceTangents: Record<CubeFace, readonly [readonly [number, number, number], readonly [number, number, number]]> = {\n  U: [[1, 0, 0], [0, 0, 1]],\n  D: [[1, 0, 0], [0, 0, 1]],\n  F: [[1, 0, 0], [0, 1, 0]],\n  B: [[1, 0, 0], [0, 1, 0]],\n  R: [[0, 1, 0], [0, 0, 1]],\n  L: [[0, 1, 0], [0, 0, 1]],\n}\n\n/* ------------------------------------------------------------------ motion */\n\n/**\n * Which move a clock-driven behaviour makes at step `index`. A pure function\n * of the step — and the step is a pure function of the clock — so motion is\n * tested by sampling rather than by faking frames. `solve` is not here: what\n * it plays depends on the cube, not on the time.\n */\nexport function puzzleCubeMove(\n  behavior: PuzzleCubeBehavior,\n  index: number,\n  order = 3,\n  seed = 1,\n): CubeMove | null {\n  if (!Number.isFinite(index) || index < 0) return null\n  const step = Math.floor(index)\n  if (behavior === \"cycle\") return cycleAlgorithm[step % cycleAlgorithm.length]\n  if (behavior === \"scramble\") {\n    // A long deterministic scramble, walked forever: the same seed always\n    // shuffles the same way, so two cubes on a page can be told to agree.\n    const moves = scrambleMoves(order, 60, seed)\n    return moves[step % moves.length] ?? null\n  }\n  return null\n}\n\n/** How many turns a behaviour has started by `clock` seconds. */\nexport function puzzleCubeStep(behavior: PuzzleCubeBehavior, clock: number, speed = 1): number {\n  if (behavior === \"static\" || !Number.isFinite(clock) || clock < 0) return 0\n  const rate = Number.isFinite(speed) && speed > 0 ? speed : 1\n  return Math.floor(clock * rate)\n}\n\n/** 0 → 1 across a turn, eased so it reads as a wrist rather than a servo. */\nexport const puzzleCubeEase = (progress: number) => {\n  if (!Number.isFinite(progress)) return 0\n  const t = progress < 0 ? 0 : progress > 1 ? 1 : progress\n  return t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2\n}\n\n/**\n * The turn a released drag commits to: the quarter turn it is nearest, and\n * the angle it should travel to get there. Wound past a quarter turn, a hand\n * has already done that turn — so the snap is a round, not a threshold, and\n * letting go half way back snaps *back* rather than through.\n */\nexport function puzzleCubeSnap(angle: number): { quarters: number; angle: number } {\n  const wound = Number.isFinite(angle) ? angle : 0\n  const quarters = Math.max(-4, Math.min(4, Math.round(wound / (Math.PI / 2))))\n  return { quarters, angle: (quarters * Math.PI) / 2 }\n}\n\n/* -------------------------------------------------------------- geometry */\n\n/** A slice part way round: what the drawing pass actually needs to know. */\ninterface CubeSpin {\n  axis: CubeAxis\n  slice: number\n  angle: number\n}\n\n/** A turn in flight, whether it was queued or let go of. */\ninterface CubeTravel {\n  turn: CubeTurn\n  move: CubeMove\n  record: boolean\n  source: PuzzleCubeSource\n  from: number\n  to: number\n  elapsed: number\n  duration: number\n}\n\n/** The hand on the cube, from the press to the release. */\ninterface CubeHold {\n  face: CubeFace\n  cubie: Cubie\n  x: number\n  y: number\n  turn: CubeTurn | null\n  tangent: readonly [number, number, number] | null\n  angle: number\n}\n\n/** One face of one cubie, projected and ready to paint. */\ninterface FaceDraw {\n  face: CubeFace\n  plastic: string\n  sticker: string\n  shade: number\n}\n\n/** One cubie's visible faces, at the depth it stands. */\ninterface CubieDraw {\n  index: number\n  depth: number\n  turning: boolean\n  faces: FaceDraw[]\n}\n\ntype Matrix = readonly number[]\n\n/** Right-handed rotation about a world axis by a float angle, matching `rotationMatrix`. */\nfunction axisRotation(axis: CubeAxis, angle: number): Matrix {\n  const c = Math.cos(angle)\n  const s = Math.sin(angle)\n  if (axis === \"x\") return [1, 0, 0, 0, c, -s, 0, s, c]\n  if (axis === \"y\") return [c, 0, s, 0, 1, 0, -s, 0, c]\n  return [c, -s, 0, s, c, 0, 0, 0, 1]\n}\n\n/** The corners of a quad, wound so every face outlines the same way. */\nconst QUAD_CORNERS: readonly (readonly [number, number])[] = [\n  [-1, -1],\n  [1, -1],\n  [1, 1],\n  [-1, 1],\n]\n\nconst applyM = (m: Matrix, v: readonly number[]): [number, number, number] => [\n  m[0] * v[0] + m[1] * v[1] + m[2] * v[2],\n  m[3] * v[0] + m[4] * v[1] + m[5] * v[2],\n  m[6] * v[0] + m[7] * v[1] + m[8] * v[2],\n]\n\n/**\n * Every visible face of every shell cubie, far ones first — painter's\n * algorithm over cubie groups, which is exact for these boxes: convex,\n * never interpenetrating, and the camera is linear. A face whose normal\n * points away from the camera is dropped before it is ever drawn.\n */\nfunction cubieDrawings(\n  state: CubeState,\n  camera: RobotCamera,\n  spin: CubeSpin | null,\n): CubieDraw[] {\n  const n = state.order\n  const cell = EDGE / n\n  const half = (n - 1) / 2\n  const stickerHalf = cell * 0.42\n  const groups: CubieDraw[] = []\n\n  for (let index = 0; index < state.cubies.length; index++) {\n    const cubie = state.cubies[index]\n    const exposed = exposedFaces(cubie, n)\n    if (!exposed.length) continue\n\n    const turning =\n      spin !== null && spin.angle !== 0 && inTurn(cubie, { axis: spin.axis, slice: spin.slice, quarterTurns: 0 })\n    const rotation = turning ? axisRotation(spin!.axis, spin!.angle) : null\n    const centre: [number, number, number] = rotation\n      ? applyM(rotation, [(cubie.i - half) * cell, (cubie.j - half) * cell, (cubie.k - half) * cell])\n      : [(cubie.i - half) * cell, (cubie.j - half) * cell, (cubie.k - half) * cell]\n    const worldCentre = toWorld(centre)\n    const faces: FaceDraw[] = []\n\n    for (const face of exposed) {\n      const normal = faceNormals[face]\n      const rotatedNormal = rotation ? applyM(rotation, normal) : normal\n      const worldNormal = toWorld(rotatedNormal)\n      // Toward the camera, or not drawn at all. The epsilon drops a face that\n      // is exactly edge-on — trigonometry never lands on a clean zero, and an\n      // edge-on face is a zero-width sliver nobody should see.\n      if (camera.depth(worldNormal[0], worldNormal[1], worldNormal[2]) <= 1e-6) continue\n\n      const [u, v] = faceTangents[face]\n      const quad = (halfU: number, halfV: number) => {\n        const points: { x: number; y: number }[] = []\n        for (const [su, sv] of QUAD_CORNERS) {\n          const offset: [number, number, number] = [\n            normal[0] * (cell / 2) + u[0] * su * halfU + v[0] * sv * halfV,\n            normal[1] * (cell / 2) + u[1] * su * halfU + v[1] * sv * halfV,\n            normal[2] * (cell / 2) + u[2] * su * halfU + v[2] * sv * halfV,\n          ]\n          const o = rotation ? applyM(rotation, offset) : offset\n          const w = toWorld([o[0] + centre[0], o[1] + centre[1], o[2] + centre[2]])\n          points.push(camera.project(w[0], w[1], w[2]))\n        }\n        return points\n      }\n\n      // The plastic face is the whole cell; the sticker sits inside it,\n      // showing the colour of the face the cubie carries there.\n      const plastic = quad(cell / 2, cell / 2)\n      const sticker = quad(stickerHalf, stickerHalf)\n      const dot = worldNormal[0] * LIGHT[0] + worldNormal[1] * LIGHT[1] + worldNormal[2] * LIGHT[2]\n      faces.push({\n        face,\n        plastic: quadPath(plastic),\n        sticker: quadPath(sticker),\n        shade: SHADE_MIN + SHADE_SPAN * Math.max(0, dot),\n      })\n    }\n\n    if (faces.length) {\n      groups.push({\n        index,\n        depth: camera.depth(worldCentre[0], worldCentre[1], worldCentre[2]),\n        turning,\n        faces,\n      })\n    }\n  }\n\n  groups.sort((a, b) => a.depth - b.depth)\n  return groups\n}\n\nconst quadPath = (points: { x: number; y: number }[]) =>\n  points.map((p, i) => `${i ? \"L\" : \"M\"} ${px(p.x)} ${px(p.y)}`).join(\" \") + \" Z\"\n\n/** A hex colour under the flat key light; anything unparsable passes through. */\nexport function shadeHex(hex: string, factor: number): string {\n  const match = /^#([0-9a-f]{6})$/i.exec(hex.trim())\n  if (!match) return hex\n  const value = Number.parseInt(match[1], 16)\n  const channel = (shift: number) =>\n    Math.max(0, Math.min(255, Math.round(((value >> shift) & 0xff) * factor)))\n  return `#${((channel(16) << 16) | (channel(8) << 8) | channel(0))\n    .toString(16)\n    .padStart(6, \"0\")}`\n}\n\n/* --------------------------------------------------------------- the cube */\n\n/** The driver a demo, a toolbar or a test uses to work the cube. */\nexport interface PuzzleCubeApi {\n  /** Queue a move: `\"R'\"`, or the parsed shape. */\n  turn: (move: CubeMove | string) => void\n  scramble: (count?: number, seed?: number) => void\n  reset: () => void\n  /** Undo the last applied turn. */\n  undo: () => void\n  /** Redo the last undone turn. */\n  redo: () => void\n  /**\n   * Play a solve. The line is a real layer-by-layer solution, replayed and\n   * checked before a single turn of it moves; on a cube the method does not\n   * cover — anything but a 3×3 — nothing is queued and it returns `null`.\n   */\n  solve: () => CubeMove[] | null\n  /** The next move of that solve, queued and returned. `null` if none. */\n  hint: () => CubeMove | null\n  state: () => CubeState\n  history: () => CubeMove[]\n  solved: () => boolean\n}\n\nexport interface PuzzleCubeProps\n  extends Omit<React.ComponentProps<\"svg\">, \"color\">,\n    RobotPaletteProps {\n  /** Cubies to a side. 2 … 7; 3 is the cube everyone means. */\n  order?: number\n  /**\n   * Controlled: the cube is exactly this algorithm applied to a solved cube.\n   * Supplying it stops the behaviour loop. Moves appended to it are animated;\n   * anything else rebuilds the state.\n   */\n  algorithm?: string | CubeMove[]\n  behavior?: PuzzleCubeBehavior\n  /** Turns per second for the behaviour loop, and turn travel with it. */\n  speed?: number\n  /** Seconds of offset, so a shelf of cubes breaks step. */\n  phase?: number\n  /** Seed for the `scramble` behaviour and for `scrambleOnMount`. */\n  seed?: number\n  /** Start scrambled rather than solved. */\n  scrambleOnMount?: boolean | number\n  animate?: boolean\n  paused?: boolean\n  /** Press a sticker and the layer follows the pointer; type moves at the cube. */\n  interactive?: boolean\n  /** Where the camera stands. Default is the isometric view it was designed in. */\n  view?: RobotView\n  /**\n   * Degrees the camera swings round the cube, on top of `view`. Any angle at\n   * all, and it wraps: the far side is 180 either way. Supplying it (or\n   * `elevation`) takes the camera away from the pointer.\n   */\n  azimuth?: number\n  /**\n   * Degrees the camera rises above the view's own elevation — all the way to\n   * straight underneath, or straight overhead, where it stops. The poles are\n   * the far ends, not a cliff short of them.\n   */\n  elevation?: number\n  onOrbitChange?: (orbit: PuzzleCubeOrbit) => void\n  showGround?: boolean\n  /** Per-face colour overrides. `{ U: \"var(--chart-1)\" }` retints one face. */\n  faces?: Partial<Record<CubeFace, string>>\n  /** Every turn, once it has been applied, and where it came from. */\n  onMove?: (move: CubeMove, state: CubeState, source: PuzzleCubeSource) => void\n  /** Whenever the state changes, including a reset. */\n  onStateChange?: (state: CubeState) => void\n  /** Called the moment the cube comes home. */\n  onSolved?: () => void\n  /** Both edges of solved — the one a stopwatch starts and stops on. */\n  onSolvedChange?: (solved: boolean) => void\n  /** The turns a person has made, as they are made and unmade. */\n  onHistoryChange?: (moves: CubeMove[]) => void\n  /** Handed a driver: `turn`, `scramble`, `solve`, `hint`, `undo`, `redo`. */\n  controls?: (api: PuzzleCubeApi) => void\n  label?: string\n  size?: RobotSize | number\n  /** Paint only — never geometry. */\n  variant?: RobotVariant\n}\n\nfunction PuzzleCube({\n  order = 3,\n  algorithm,\n  behavior = \"cycle\",\n  speed = 0.9,\n  phase = 0,\n  seed = 1,\n  scrambleOnMount = false,\n  animate = true,\n  paused = false,\n  interactive = false,\n  view = NATIVE_VIEW,\n  azimuth,\n  elevation,\n  onOrbitChange,\n  showGround = true,\n  faces,\n  onMove,\n  onStateChange,\n  onSolved,\n  onSolvedChange,\n  onHistoryChange,\n  controls,\n  label,\n  size = \"md\",\n  variant = \"solid\",\n  color,\n  accent,\n  metal,\n  dark,\n  glow,\n  grid,\n  palette: paletteOverride,\n  className,\n  style,\n  role,\n  tabIndex,\n  onKeyDown,\n  \"aria-label\": ariaLabel,\n  ...props\n}: PuzzleCubeProps) {\n  const palette = resolveRobotPalette({ color, accent, metal, dark, glow, grid, palette: paletteOverride })\n  const width = resolveRobotSize(size)\n  const n = Number.isFinite(order) ? Math.max(2, Math.min(7, Math.round(order))) : 3\n\n  const [cube, setCube] = React.useState<CubeState>(() => {\n    const solved = createCube(n)\n    if (!scrambleOnMount) return solved\n    const count = typeof scrambleOnMount === \"number\" ? scrambleOnMount : 20\n    return scrambleMoves(n, count, seed).reduce(\n      (state, move) => applyTurn(state, moveToTurn(move, n)),\n      solved,\n    )\n  })\n  // The loop reads the state without waiting for React, and writes through\n  // `setCube` only when a turn lands.\n  const stateRef = React.useRef(cube)\n\n  // Queued turns carry whether they belong in the history: an undo does not,\n  // or undoing twice would walk forward again instead of back.\n  const queue = React.useRef<{ move: CubeMove; record: boolean; source: PuzzleCubeSource }[]>([])\n  const history = React.useRef<CubeMove[]>([])\n  const future = React.useRef<CubeMove[]>([])\n  const active = React.useRef<CubeTravel | null>(null)\n  const plan = React.useRef<CubeMove[]>([])\n  const clock = React.useRef(Number.isFinite(phase) ? phase : 0)\n  const stepsTaken = React.useRef(0)\n  const wasSolved = React.useRef(isSolved(cube))\n  const celebrate = React.useRef(0)\n  const holdRef = React.useRef<CubeHold | null>(null)\n  const runningRef = React.useRef(false)\n  const wakeRef = React.useRef<() => void>(() => {})\n\n  const controlled = algorithm !== undefined\n  const colors = useCubeColors(palette, faces)\n  const turnSeconds = Math.max(0.08, 0.34 / Math.max(0.1, speed))\n\n  /** The picture this frame: the slice in flight and the solved swell. */\n  const [visual, setVisual] = React.useState<{ spin: CubeSpin | null; swell: number }>({\n    spin: null,\n    swell: 0,\n  })\n  const [holding, setHolding] = React.useState(false)\n  // The move count is read by the drawing, so it is state rather than a dip\n  // into the history ref mid-render.\n  const [moveCount, setMoveCount] = React.useState(0)\n\n  /* The camera: the view's own angles, plus however far a hand has turned it.\n   * A supplied `azimuth` or `elevation` wins and takes the camera away from\n   * the pointer. */\n  const [turned, setTurned] = React.useState<PuzzleCubeOrbit>({ azimuth: 0, elevation: 0 })\n  // A press on the plastic, held: where it started. The trace itself lives in\n  // the effect below, so the drag is one closure and nothing is left behind.\n  const [orbitPress, setOrbitPress] = React.useState<{ x: number; y: number } | null>(null)\n  const stanceLimits = elevationBounds((robotViews[view] ?? robotViews.iso).elevation)\n  const orbit: PuzzleCubeOrbit =\n    azimuth !== undefined || elevation !== undefined\n      ? {\n          azimuth: finiteNum(azimuth ?? 0, 0),\n          elevation: clampNum(finiteNum(elevation ?? 0, 0), stanceLimits.min, stanceLimits.max),\n        }\n      : turned\n\n  const push = React.useCallback(\n    (move: CubeMove | string, source: PuzzleCubeSource = \"user\", record = true) => {\n      const parsed = typeof move === \"string\" ? parseMove(move) : move\n      if (!parsed || !cubeFaces.includes(parsed.face)) return\n      // A turn of the person's own invalidates any line the solver worked out.\n      if (source === \"user\") plan.current = []\n      queue.current.push({ move: parsed, record, source })\n      wakeRef.current()\n    },\n    [],\n  )\n\n  const historyChanged = React.useRef(onHistoryChange)\n  const rebuild = React.useCallback(\n    (moves: CubeMove[]) => {\n      queue.current = []\n      active.current = null\n      plan.current = []\n      holdRef.current = null\n      history.current = [...moves]\n      setMoveCount(moves.length)\n      future.current = []\n      const next = moves.reduce(\n        (state, move) => applyTurn(state, moveToTurn(move, n)),\n        createCube(n),\n      )\n      stateRef.current = next\n      setCube(next)\n      onStateChange?.(next)\n      historyChanged.current?.(history.current)\n      const solved = isSolved(next)\n      if (solved !== wasSolved.current) {\n        wasSolved.current = solved\n        onSolvedChange?.(solved)\n      }\n      if (solved) onSolved?.()\n    },\n    [n, onSolvedChange, onStateChange, onSolved],\n  )\n\n  // Controlled: the prop is the truth. A pure append is animated; anything\n  // else — a shorter list, a different move — is rebuilt from solved.\n  const wanted = React.useMemo(\n    () => (typeof algorithm === \"string\" ? parseAlgorithm(algorithm) : (algorithm ?? [])),\n    [algorithm],\n  )\n  const wantedKey = wanted.map(formatMove).join(\" \")\n  const appliedKey = React.useRef(\"\")\n  React.useEffect(() => {\n    if (!controlled) return\n    const applied = appliedKey.current\n    if (wantedKey === applied) return\n    if (applied && wantedKey.startsWith(applied ? `${applied} ` : \"\")) {\n      for (const move of wanted.slice(applied ? applied.split(\" \").length : 0)) push(move, \"solver\")\n    } else {\n      rebuild(wanted)\n    }\n    appliedKey.current = wantedKey\n  }, [controlled, push, rebuild, wanted, wantedKey])\n\n  // The order changed underneath the cube: start again rather than leave\n  // cubies on a lattice that no longer exists.\n  const orderRef = React.useRef(n)\n  React.useEffect(() => {\n    if (orderRef.current === n) return\n    orderRef.current = n\n    rebuild([])\n  }, [n, rebuild])\n\n  const api = React.useMemo<PuzzleCubeApi>(\n    () => ({\n      turn: (move) => push(move),\n      scramble: (count = 20, scrambleSeed = Math.floor(Math.random() * 1e9)) => {\n        plan.current = []\n        future.current = []\n        for (const move of scrambleMoves(n, count, scrambleSeed)) push(move, \"scramble\")\n      },\n      reset: () => rebuild([]),\n      undo: () => {\n        const last = history.current.at(-1)\n        if (!last) return\n        history.current = history.current.slice(0, -1)\n        setMoveCount(history.current.length)\n        future.current = [...future.current, last]\n        historyChanged.current?.(history.current)\n        push(invertMove(last), \"undo\", false)\n      },\n      redo: () => {\n        const next = future.current.at(-1)\n        if (!next) return\n        future.current = future.current.slice(0, -1)\n        push(next, \"redo\")\n      },\n      solve: () => {\n        const line = solveCube(stateRef.current)\n        if (!line) return null\n        plan.current = line\n        future.current = []\n        for (const move of line) push(move, \"solver\")\n        return line\n      },\n      hint: () => {\n        // The cached line is only a hint if it still starts where we are; any\n        // turn the person made since invalidates it, so it is dropped.\n        const line = plan.current.length ? plan.current : solveCube(stateRef.current)\n        if (!line?.length) return null\n        plan.current = line.slice(1)\n        push(line[0], \"solver\")\n        return line[0]\n      },\n      state: () => stateRef.current,\n      history: () => [...history.current],\n      solved: () => isSolved(stateRef.current),\n    }),\n    [n, push, rebuild],\n  )\n  const controlsRef = React.useRef(controls)\n  React.useEffect(() => {\n    controlsRef.current?.(api)\n  }, [api])\n\n  /* ------------------------------------------------------------- the loop */\n\n  // Everything the frame loop reads from props lands here after the render\n  // rather than during it, so nothing touches a ref while React is drawing.\n  const frameRef = React.useRef<{\n    behavior: PuzzleCubeBehavior\n    speed: number\n    seed: number\n    animate: boolean\n    paused: boolean\n    controlled: boolean\n    turnSeconds: number\n    order: number\n    onMove?: (move: CubeMove, state: CubeState, source: PuzzleCubeSource) => void\n    onStateChange?: (state: CubeState) => void\n    onSolved?: () => void\n    onSolvedChange?: (solved: boolean) => void\n  }>({\n    behavior,\n    speed,\n    seed,\n    animate,\n    paused,\n    controlled,\n    turnSeconds,\n    order: n,\n    onMove,\n    onStateChange,\n    onSolved,\n    onSolvedChange,\n  })\n  React.useEffect(() => {\n    frameRef.current = {\n      behavior,\n      speed,\n      seed,\n      animate,\n      paused,\n      controlled,\n      turnSeconds,\n      order: n,\n      onMove,\n      onStateChange,\n      onSolved,\n      onSolvedChange,\n    }\n  })\n\n  // The loop runs only while something can move: a behaviour, a turn in\n  // flight, a hand on the cube, or a swell decaying. Between moves of a slow\n  // behaviour the frame handler changes nothing and renders nothing.\n  React.useEffect(() => {\n    let raf = 0\n    let last = performance.now()\n    let lastKey = \"\\u0000\"\n    const idle = () => {\n      runningRef.current = false\n      wakeRef.current = start\n    }\n\n    const step = (now: number) => {\n      const config = frameRef.current\n      if (config.paused) return idle()\n      const dt = Math.min(0.05, (now - last) / 1000)\n      last = now\n      const reduced = prefersReducedMotion()\n      clock.current += dt\n      const loopActive = config.animate && !config.controlled && config.behavior !== \"static\"\n\n      // A hand on the cube outranks everything; then the queue; then the\n      // behaviour, which only runs when nothing else is driving.\n      if (!active.current && !holdRef.current) {\n        if (!queue.current.length && loopActive && !reduced) {\n          if (config.behavior === \"solve\") {\n            const stepIndex = puzzleCubeStep(\"cycle\", clock.current, config.speed * 0.55)\n            if (stepIndex > stepsTaken.current) {\n              stepsTaken.current = stepIndex\n              if (!plan.current.length) {\n                // Solved: shuffle it and start again. Scrambled: work out the\n                // line once and then walk it, a turn at a time.\n                plan.current = isSolved(stateRef.current)\n                  ? scrambleMoves(config.order, 14, Math.floor(clock.current) + config.seed)\n                  : (solveCube(stateRef.current) ?? [])\n              }\n              const next = plan.current.shift()\n              if (next) push(next, \"loop\", false)\n            }\n          } else {\n            const stepIndex = puzzleCubeStep(config.behavior, clock.current, config.speed * 0.55)\n            if (stepIndex > stepsTaken.current) {\n              stepsTaken.current = stepIndex\n              const move = puzzleCubeMove(config.behavior, stepIndex - 1, config.order, config.seed)\n              if (move) push(move, \"loop\", false)\n            }\n          }\n        }\n        const next = queue.current.shift()\n        if (next) {\n          const turn = moveToTurn(next.move, config.order)\n          active.current = {\n            turn,\n            move: next.move,\n            record: next.record,\n            source: next.source,\n            from: 0,\n            // Three quarters the short way round: a cube's wrist never takes\n            // 270° to do what 90° the other way does.\n            to: ((turn.quarterTurns === 3 ? -1 : turn.quarterTurns) * Math.PI) / 2,\n            elapsed: 0,\n            duration: config.turnSeconds,\n          }\n        }\n      }\n\n      let live: CubeSpin | null = null\n      const travel = active.current\n      if (holdRef.current?.turn) {\n        // The hand drives; the frame just renders it.\n        live = { axis: holdRef.current.turn.axis, slice: holdRef.current.turn.slice, angle: holdRef.current.angle }\n      } else if (travel) {\n        travel.elapsed += dt\n        const progress = config.animate && !reduced ? travel.elapsed / travel.duration : 1\n        if (progress >= 1) {\n          active.current = null\n          live = null\n          if (travel.turn.quarterTurns) {\n            const next = applyTurn(stateRef.current, travel.turn)\n            stateRef.current = next\n            if (travel.record) {\n              history.current = [...history.current, travel.move]\n              setMoveCount(history.current.length)\n              if (travel.source === \"user\") future.current = []\n              historyChanged.current?.(history.current)\n            }\n            setCube(next)\n            config.onMove?.(travel.move, next, travel.source)\n            config.onStateChange?.(next)\n            const solved = isSolved(next)\n            if (solved !== wasSolved.current) {\n              wasSolved.current = solved\n              config.onSolvedChange?.(solved)\n              if (solved) {\n                celebrate.current = 1\n                config.onSolved?.()\n              }\n            }\n          }\n        } else {\n          live = {\n            axis: travel.turn.axis,\n            slice: travel.turn.slice,\n            angle: travel.from + (travel.to - travel.from) * puzzleCubeEase(progress),\n          }\n        }\n      }\n\n      // A short swell when it comes home, so solving it is felt and not only\n      // reported. It decays to nothing and leaves the scale exactly 1.\n      let swell = 0\n      if (celebrate.current > 0) {\n        celebrate.current = Math.max(0, celebrate.current - dt * 1.6)\n        swell = reduced ? 0 : Math.sin(celebrate.current * Math.PI) * 0.05\n      }\n\n      const key = `${live ? `${live.axis}${live.slice}:${px(live.angle)}` : \"-\"}:${px(swell * 1e4)}`\n      if (key !== lastKey) {\n        lastKey = key\n        setVisual({ spin: live, swell })\n      }\n\n      if (active.current || holdRef.current || celebrate.current > 0 || (loopActive && !reduced)) {\n        raf = requestAnimationFrame(step)\n      } else {\n        idle()\n      }\n    }\n\n    const start = () => {\n      if (runningRef.current) return\n      runningRef.current = true\n      wakeRef.current = start\n      last = performance.now()\n      raf = requestAnimationFrame(step)\n    }\n    start()\n    return () => {\n      cancelAnimationFrame(raf)\n      runningRef.current = false\n    }\n  }, [animate, paused, controlled, behavior, push])\n\n  /* ------------------------------------------------------------ the hand */\n\n  // The camera: the view's own angles, plus however far it has been turned.\n  // Unswung and unrised it is exactly `robotCamera(view)`, so the native view\n  // stays byte-identical however the cube has been looked at.\n  const stance = robotViews[view] ?? robotViews.iso\n  const swung = wrapTurn(orbit.azimuth)\n  const camera =\n    swung === 0 && orbit.elevation === 0\n      ? robotCamera(view)\n      : robotCameraAt(\n          stance.azimuth + swung,\n          clampNum(stance.elevation + orbit.elevation, -POLE, POLE),\n          view,\n        )\n  const frame = fitFrame(ENVELOPE, camera, VIEW_WIDTH, VIEW_HEIGHT)\n  const viewRef = React.useRef({ camera, frame })\n  React.useEffect(() => {\n    viewRef.current = { camera, frame }\n  })\n\n  /**\n   * Pixels to a world direction, exactly. The camera is linear, so the two\n   * in-plane axes of the grabbed face project to a 2×2 basis; solving that\n   * basis for the screen drag yields the same drag in cube coordinates, which\n   * is what `grabFromDrag` takes — no ray casting, no approximation.\n   */\n  const dragDirection = React.useCallback((dx: number, dy: number) => {\n    const grab = holdRef.current\n    const svg = svgRef.current\n    if (!grab || !svg) return null\n    const rect = svg.getBoundingClientRect()\n    if (rect.width < 1) return null\n    const k = VIEW_WIDTH / rect.width / viewRef.current.frame.scale\n    const d = { x: dx * k, y: dy * k }\n    const [u, v] = faceTangents[grab.face]\n    const wu = viewRef.current.camera.project(...(toWorld(u)))\n    const wv = viewRef.current.camera.project(...(toWorld(v)))\n    const det = wu.x * wv.y - wv.x * wu.y\n    if (Math.abs(det) < 1e-6) return null // the face is edge-on\n    const a = (d.x * wv.y - d.y * wv.x) / det\n    const b = (wu.x * d.y - wu.y * d.x) / det\n    return [a * u[0] + b * v[0], a * u[1] + b * v[1], a * u[2] + b * v[2]] as [number, number, number]\n  }, [])\n\n  const onDragMove = React.useCallback(\n    (x: number, y: number) => {\n      const grab = holdRef.current\n      if (!grab) return\n      const direction = dragDirection(x - grab.x, y - grab.y)\n      if (!direction) return\n      if (!grab.turn) {\n        // Which layer is decided once, on the first few pixels, and then held:\n        // a drag cannot wander into another layer half way through.\n        const cell = EDGE / n\n        if (Math.hypot(...direction) < cell * 0.14) return\n        const grabbed = grabFromDrag({\n          face: grab.face,\n          cubie: grab.cubie,\n          direction,\n          order: n,\n        })\n        if (!grabbed) return\n        grab.turn = grabbed.turn\n        grab.tangent = grabbed.tangent\n      }\n      const tangent = grab.tangent\n      if (!tangent) return\n      const along = direction[0] * tangent[0] + direction[1] * tangent[1] + direction[2] * tangent[2]\n      // Half the cube's edge of travel is one quarter turn, which is about\n      // what a hand expects from a cube of that size on a screen.\n      const winding = grab.turn.quarterTurns === 1 ? 1 : -1\n      grab.angle = winding * (along / (EDGE * 0.5)) * (Math.PI / 2)\n    },\n    [dragDirection, n],\n  )\n\n  const onDragEnd = React.useCallback(() => {\n    const grab = holdRef.current\n    holdRef.current = null\n    setHolding(false)\n    if (!grab?.turn) return\n    const snap = puzzleCubeSnap(grab.angle)\n    const quarterTurns = (((snap.quarters % 4) + 4) % 4)\n    const turn: CubeTurn = { ...grab.turn, quarterTurns }\n    // The settle is short and fixed: it is the cube coming to rest under a\n    // hand that has already let go, not a turn being played.\n    active.current = {\n      turn,\n      move: turnToMove(turn, n),\n      record: quarterTurns !== 0,\n      source: \"user\",\n      from: grab.angle,\n      to: snap.angle,\n      elapsed: 0,\n      duration: 0.16,\n    }\n    plan.current = []\n    wakeRef.current()\n  }, [n])\n\n  const svgRef = React.useRef<SVGSVGElement>(null)\n  React.useEffect(() => {\n    if (!holding) return\n    const move = (event: PointerEvent) => onDragMove(event.clientX, event.clientY)\n    const up = () => onDragEnd()\n    // The drag goes on the window rather than on the sticker: a hand that\n    // flings a layer leaves the face it grabbed long before it lets go.\n    window.addEventListener(\"pointermove\", move)\n    window.addEventListener(\"pointerup\", up)\n    window.addEventListener(\"pointercancel\", up)\n    return () => {\n      window.removeEventListener(\"pointermove\", move)\n      window.removeEventListener(\"pointerup\", up)\n      window.removeEventListener(\"pointercancel\", up)\n    }\n  }, [holding, onDragMove, onDragEnd])\n\n  const beginHold = React.useCallback(\n    (event: React.PointerEvent, index: number, face: CubeFace) => {\n      if (!interactive || event.button !== 0) return\n      event.preventDefault()\n      // One press, one hand: taking a layer takes the camera back with it.\n      setOrbitPress(null)\n      // The sticker keeps the press for its layer; the plastic and the\n      // background give it to the camera, below.\n      event.stopPropagation()\n      // A hand on the cube lands whatever was still travelling, rather than\n      // the release throwing that turn away: the next frame finishes it into\n      // the state before the drag begins.\n      if (active.current) active.current.elapsed = active.current.duration\n      holdRef.current = {\n        face,\n        cubie: stateRef.current.cubies[index],\n        x: event.clientX,\n        y: event.clientY,\n        turn: null,\n        tangent: null,\n        angle: 0,\n      }\n      setHolding(true)\n      wakeRef.current()\n    },\n    [interactive],\n  )\n\n  /* A press anywhere but a sticker turns the *cube* rather than a layer: the\n   * camera sweeps round it, any direction at all, over the top and under the\n   * bottom. The turn works on the movement, not the spot — the trace is the\n   * effect's own closure, so nothing is left behind when the hand comes off. */\n  const orbitScene = React.useRef({\n    orbit,\n    limits: elevationBounds((robotViews[view] ?? robotViews.iso).elevation),\n  })\n  React.useEffect(() => {\n    orbitScene.current = {\n      orbit,\n      limits: elevationBounds((robotViews[view] ?? robotViews.iso).elevation),\n    }\n  })\n  const orbitChange = React.useRef(onOrbitChange)\n  React.useEffect(() => {\n    orbitChange.current = onOrbitChange\n  })\n\n  const turnOrbit = React.useCallback(\n    (next: PuzzleCubeOrbit) => {\n      const { limits } = orbitScene.current\n      const bounded = {\n        azimuth: wrapTurn(next.azimuth),\n        elevation: clampNum(finiteNum(next.elevation, 0), limits.min, limits.max),\n      }\n      orbitScene.current = { ...orbitScene.current, orbit: bounded }\n      setTurned(bounded)\n      orbitChange.current?.(bounded)\n    },\n    [],\n  )\n\n  const beginOrbit = React.useCallback(\n    (event: React.PointerEvent) => {\n      if (!interactive || event.button !== 0 || event.defaultPrevented) return\n      // One press, one hand: a layer being wound outranks the camera.\n      if (holdRef.current) return\n      event.preventDefault()\n      setOrbitPress({ x: event.clientX, y: event.clientY })\n    },\n    [interactive],\n  )\n\n  React.useEffect(() => {\n    if (!orbitPress) return\n    let last = orbitPress\n    const move = (event: PointerEvent) => {\n      const from = orbitScene.current.orbit\n      turnOrbit({\n        azimuth: from.azimuth - (event.clientX - last.x) * ORBIT_SWEEP,\n        elevation: from.elevation - (event.clientY - last.y) * ORBIT_RISE,\n      })\n      last = { x: event.clientX, y: event.clientY }\n    }\n    const up = () => setOrbitPress(null)\n    window.addEventListener(\"pointermove\", move)\n    window.addEventListener(\"pointerup\", up)\n    window.addEventListener(\"pointercancel\", up)\n    return () => {\n      window.removeEventListener(\"pointermove\", move)\n      window.removeEventListener(\"pointerup\", up)\n      window.removeEventListener(\"pointercancel\", up)\n    }\n  }, [orbitPress, turnOrbit])\n\n  /* -------------------------------------------------------------- paint */\n\n  const solvedNow = isSolved(cube)\n  const spin = visual.spin\n  const drawings = cubieDrawings(cube, camera, spin)\n  const lineWeight = Math.max(0.5, Math.min(1.1, (EDGE / n) / 33))\n  const bodyShade = (shade: number) => shadeHex(colors.dark, shade)\n  const under = camera.project(...(toWorld([0, -EDGE / 2, 0])))\n  const origin = camera.project(0, 0, 0)\n  const swell = visual.swell\n\n  const ariaParts = [\n    `${n} by ${n} puzzle cube`,\n    solvedNow ? \"solved\" : \"scrambled\",\n    spin ? `turning ${spin.axis}` : null,\n    viewNames[view] ?? viewNames.iso,\n  ]\n\n  // Straight down and straight up are singular for this camera, and a shadow\n  // seen edge-on is a line — the ground and the underglow step out rather\n  // than degenerate.\n  const flat = camera.flatten\n\n  return (\n    <svg\n      ref={svgRef}\n      role={role ?? (interactive ? \"application\" : \"img\")}\n      aria-label={\n        ariaLabel ??\n        `${ariaParts.filter(Boolean).join(\", \")}.` +\n          (interactive\n            ? \" Drag a sticker to turn that layer; drag the plastic to turn the whole cube over. Or type a move: U, D, L, R, F, B, with shift for anticlockwise. Arrow keys turn the cube, S scrambles, H hints, Enter solves, Backspace undoes, Escape resets.\"\n            : \"\")\n      }\n      tabIndex={tabIndex ?? (interactive ? 0 : undefined)}\n      onPointerDown={interactive ? beginOrbit : undefined}\n      onKeyDown={(event) => {\n        onKeyDown?.(event)\n        if (!interactive || event.defaultPrevented) return\n        const letter = event.key.toUpperCase()\n        const step = event.shiftKey ? ORBIT_STEP * 3 : ORBIT_STEP\n        // From the ref, not from this render: two presses in one tick would\n        // otherwise both read the same angle and the second would undo the first.\n        const from = orbitScene.current.orbit\n        if (event.key === \"ArrowLeft\") turnOrbit({ ...from, azimuth: from.azimuth + step })\n        else if (event.key === \"ArrowRight\") turnOrbit({ ...from, azimuth: from.azimuth - step })\n        else if (event.key === \"ArrowUp\") turnOrbit({ ...from, elevation: from.elevation + step })\n        else if (event.key === \"ArrowDown\") turnOrbit({ ...from, elevation: from.elevation - step })\n        else if (event.key === \"Home\") turnOrbit({ azimuth: 0, elevation: 0 })\n        else if (cubeFaces.includes(letter as CubeFace)) {\n          push({ face: letter as CubeFace, layer: 0, turns: event.shiftKey ? 3 : 1 })\n        } else if (letter === \"S\") {\n          api.scramble()\n        } else if (letter === \"H\") {\n          api.hint()\n        } else if (letter === \"Z\" && (event.metaKey || event.ctrlKey)) {\n          if (event.shiftKey) api.redo()\n          else api.undo()\n        } else if (event.key === \"Enter\") {\n          api.solve()\n        } else if (event.key === \"Backspace\") {\n          api.undo()\n        } else if (event.key === \"Escape\") {\n          api.reset()\n        } else {\n          return\n        }\n        event.preventDefault()\n      }}\n      viewBox={`0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}`}\n      width={width}\n      height={px((width * VIEW_HEIGHT) / VIEW_WIDTH)}\n      data-cube=\"puzzle-cube\"\n      data-cube-order={n}\n      data-cube-solved={String(solvedNow)}\n      data-cube-turning={spin ? spin.axis : \"\"}\n      data-cube-dragging={holding || orbitPress ? \"true\" : \"\"}\n      data-cube-moves={moveCount}\n      data-azimuth={px(swung)}\n      data-elevation={px(orbit.elevation)}\n      className={cn(\n        \"max-w-full select-none\",\n        interactive &&\n          \"cursor-grab touch-none focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[currentColor]\",\n        (holding || orbitPress) && \"cursor-grabbing\",\n        className,\n      )}\n      style={{ color: palette.foreground, ...style }}\n      {...props}\n    >\n      <g\n        data-view={view}\n        transform={frame.transform || undefined}\n      >\n        {showGround && flat > 0.03 ? (\n          <ellipse\n            data-ground\n            cx={px(under.x)}\n            cy={px(under.y + 4)}\n            rx={px(EDGE * 0.78)}\n            ry={px(EDGE * 0.78 * flat + 0.4)}\n            fill={palette.dark}\n            opacity={0.12}\n          />\n        ) : null}\n        {solvedNow && flat > 0.03 ? (\n          <ellipse\n            data-glow\n            cx={px(under.x)}\n            cy={px(under.y + 4)}\n            rx={px(EDGE * 0.66)}\n            ry={px(EDGE * 0.66 * flat + 0.4)}\n            fill={palette.glow}\n            opacity={0.16}\n          />\n        ) : null}\n\n        <g\n          data-cubies\n          transform={\n            swell\n              ? `translate(${px(origin.x)} ${px(origin.y)}) scale(${px(1 + swell)}) translate(${px(-origin.x)} ${px(-origin.y)})`\n              : undefined\n          }\n        >\n          {drawings.map(({ index, faces, turning }) => (\n            <g key={index} data-cubie={index} data-turning={turning ? \"true\" : undefined}>\n              {faces.map(({ face, plastic, sticker, shade }) => {\n                const stickerColor = colors.faces[stickerFace(cube.cubies[index], face)]\n                const painted = paintFace({\n                  variant,\n                  face,\n                  stickerColor,\n                  bodyShade: bodyShade(shade),\n                  shade,\n                  weight: lineWeight,\n                  turning,\n                  palette,\n                })\n                return (\n                  <React.Fragment key={face}>\n                    <path d={plastic} {...painted.plastic} />\n                    <path\n                      data-sticker\n                      data-face={face}\n                      d={sticker}\n                      {...painted.sticker}\n                      onPointerDown={interactive ? (event) => beginHold(event, index, face) : undefined}\n                    />\n                  </React.Fragment>\n                )\n              })}\n            </g>\n          ))}\n        </g>\n\n        {variant === \"blueprint\" && (\n          <g data-annotation opacity={0.7}>\n            <text\n              x={px(origin.x)}\n              y={px(under.y + EDGE * 0.22)}\n              textAnchor=\"middle\"\n              fontFamily=\"ui-monospace, monospace\"\n              fontSize={5}\n              fill={palette.foreground}\n            >\n              {`${n}\\u00d7${n}\\u00d7${n} \\u00b7 ${moveCount} MOVES${solvedNow ? \" \\u00b7 SOLVED\" : \"\"}`}\n            </text>\n          </g>\n        )}\n      </g>\n\n      {label && (\n        <text\n          x={VIEW_WIDTH / 2}\n          y={VIEW_HEIGHT - 6}\n          textAnchor=\"middle\"\n          fontFamily=\"ui-monospace, monospace\"\n          fontSize={6}\n          fill={palette.foreground}\n        >\n          {label}\n        </text>\n      )}\n    </svg>\n  )\n}\n\n/* ---------------------------------------------------------------- paint */\n\ntype FacePaint = {\n  plastic: React.SVGProps<SVGPathElement>\n  sticker: React.SVGProps<SVGPathElement>\n}\n\n/**\n * One face's paint for the four variants — paint only, never geometry. The\n * flat key light scales every fill; `wire` picks the turning slice out in\n * accent so the mechanism is legible from its structure alone.\n */\nfunction paintFace({\n  variant,\n  stickerColor,\n  bodyShade,\n  shade,\n  weight,\n  turning,\n  palette,\n}: {\n  variant: RobotVariant\n  face: CubeFace\n  stickerColor: string\n  bodyShade: string\n  shade: number\n  weight: number\n  turning: boolean\n  palette: ReturnType<typeof resolveRobotPalette>\n}): FacePaint {\n  const lit = shadeHex(stickerColor, shade)\n  switch (variant) {\n    case \"outline\":\n      return {\n        plastic: { fill: \"none\", stroke: palette.metal, strokeWidth: px(0.8 * weight) },\n        sticker: { fill: \"none\", stroke: stickerColor, strokeWidth: px(1.1 * weight) },\n      }\n    case \"blueprint\":\n      return {\n        plastic: { fill: bodyShade, fillOpacity: 0.1, stroke: palette.grid, strokeWidth: px(0.6 * weight) },\n        sticker: { fill: lit, fillOpacity: 0.32, stroke: palette.foreground, strokeWidth: px(0.7 * weight) },\n      }\n    case \"wire\":\n      return {\n        plastic: { fill: \"none\", stroke: palette.grid, strokeWidth: px(0.7 * weight) },\n        sticker: {\n          fill: \"none\",\n          stroke: turning ? palette.accent : palette.grid,\n          strokeWidth: px(0.9 * weight),\n        },\n      }\n    default:\n      return {\n        plastic: { fill: bodyShade, stroke: palette.dark, strokeWidth: px(0.5 * weight) },\n        sticker: { fill: lit, stroke: \"none\" },\n      }\n  }\n}\n\n/* ---------------------------------------------------------------- colour */\n\n/**\n * The palette as colours SVG can paint. Six face colours resolve\n * prop → `--robot-cube-<face>` → the standard scheme, the same three steps\n * every palette role takes; the body and the glow come from\n * `resolveRobotPalette` so the cube sits inside the page's theme.\n */\nfunction useCubeColors(\n  palette: ReturnType<typeof resolveRobotPalette>,\n  overrides: Partial<Record<CubeFace, string>> | undefined,\n) {\n  const key = cubeFaces.map((face) => overrides?.[face] ?? \"\").join(\"|\")\n  const [resolved, setResolved] = React.useState(() => ({\n    dark: \"#1f2430\",\n    glow: \"#38bdf8\",\n    faces: { ...standardCubeFaceColors },\n  }))\n\n  React.useEffect(() => {\n    const read = () => {\n      const faces = {} as Record<CubeFace, string>\n      for (const face of cubeFaces) {\n        const wanted = overrides?.[face] ?? faceVariables[face]\n        faces[face] = resolveCssColor(wanted, standardCubeFaceColors[face])\n      }\n      setResolved({\n        dark: resolveCssColor(palette.dark, \"#1f2430\"),\n        glow: resolveCssColor(palette.glow, \"#38bdf8\"),\n        faces,\n      })\n    }\n    read()\n    return watchCssColors(read)\n    // `key` stands in for the override object, which a caller rebuilds inline.\n  }, [key, overrides, palette.dark, palette.glow])\n\n  return resolved\n}\n\nexport { PuzzleCube }\n",
      "type": "registry:ui",
      "target": "@ui/puzzle-cube.tsx"
    }
  ],
  "categories": [
    "robotics",
    "devices"
  ],
  "type": "registry:ui"
}