comparisons

Claude Sonnet 5 · Claude Code Web — 3D Rubik's cube simulator in a single HTML file

~/tests/cubo-magico/claude-sonnet-5-claude-code-web  — 3D Rubik's cube simulator in a single HTML file

open in new tab
Claude Sonnet 5 Anthropic Claude Code Web
  • Leonardo
  • ~6min
loading artifact

src/index.html

html758 lines20 KB
<!doctype html>
<html lang="pt-br">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cubo Magico 3D</title>
<style>
  :root{
    --bg-0:#0a0c11;
    --bg-1:#12151d;
    --bg-2:#181c26;
    --panel-border:#262c3a;
    --text-0:#eef1f7;
    --text-1:#9aa3b8;
    --accent:#5b8cff;
    --u-color:#ffffff;
    --d-color:#ffd500;
    --l-color:#ff5800;
    --r-color:#b71234;
    --f-color:#009e60;
    --b-color:#0051ba;
  }
  *{box-sizing:border-box;}
  html,body{
    margin:0;
    padding:0;
    height:100%;
    overflow:hidden;
    background:var(--bg-0);
    color:var(--text-0);
    font-family:"Segoe UI",Roboto,-apple-system,BlinkMacSystemFont,sans-serif;
  }
  #app{
    display:flex;
    flex-direction:column;
    height:100vh;
    width:100vw;
  }
  header{
    display:flex;
    align-items:center;
    justify-content:space-between;
    padding:10px 20px;
    background:linear-gradient(180deg,var(--bg-2),var(--bg-1));
    border-bottom:1px solid var(--panel-border);
    flex:0 0 auto;
    z-index:5;
  }
  header h1{
    font-size:17px;
    margin:0;
    font-weight:600;
    letter-spacing:.02em;
    display:flex;
    align-items:center;
    gap:8px;
  }
  header h1 .dot{
    width:10px;height:10px;border-radius:2px;
    background:conic-gradient(from 0deg,#fff 0 60deg,#ffd500 60deg 120deg,#009e60 120deg 180deg,#0051ba 180deg 240deg,#b71234 240deg 300deg,#ff5800 300deg 360deg);
    display:inline-block;
  }
  .stats{
    display:flex;
    gap:22px;
    font-family:"Consolas","DM Mono",monospace;
  }
  .stat{
    display:flex;
    flex-direction:column;
    align-items:flex-end;
    line-height:1.15;
  }
  .stat .label{
    font-size:10px;
    text-transform:uppercase;
    letter-spacing:.08em;
    color:var(--text-1);
  }
  .stat .value{
    font-size:19px;
    font-weight:600;
    color:var(--text-0);
  }
  main{
    flex:1 1 auto;
    display:flex;
    min-height:0;
  }
  #canvas-wrap{
    flex:1 1 auto;
    position:relative;
    min-width:0;
  }
  #canvas-wrap canvas{
    display:block;
    width:100%;
    height:100%;
  }
  #hint{
    position:absolute;
    left:16px;
    bottom:12px;
    font-size:11.5px;
    color:var(--text-1);
    background:rgba(10,12,17,.55);
    padding:6px 10px;
    border-radius:6px;
    pointer-events:none;
    border:1px solid var(--panel-border);
  }
  #toast{
    position:absolute;
    top:16px;
    left:50%;
    transform:translateX(-50%) translateY(-12px);
    background:rgba(20,24,33,.92);
    border:1px solid var(--accent);
    color:var(--text-0);
    padding:10px 18px;
    border-radius:8px;
    font-size:13.5px;
    opacity:0;
    transition:opacity .25s ease, transform .25s ease;
    pointer-events:none;
    white-space:nowrap;
    box-shadow:0 8px 24px rgba(0,0,0,.4);
  }
  #toast.show{
    opacity:1;
    transform:translateX(-50%) translateY(0);
  }
  #panel{
    flex:0 0 300px;
    background:var(--bg-1);
    border-left:1px solid var(--panel-border);
    padding:16px;
    overflow-y:auto;
    display:flex;
    flex-direction:column;
    gap:16px;
  }
  .section-title{
    font-size:11px;
    text-transform:uppercase;
    letter-spacing:.09em;
    color:var(--text-1);
    margin:0 0 8px 0;
    font-weight:600;
  }
  .move-row{
    display:flex;
    align-items:center;
    gap:8px;
    margin-bottom:6px;
  }
  .move-row .face-tag{
    width:26px;
    height:26px;
    border-radius:6px;
    display:flex;
    align-items:center;
    justify-content:center;
    font-weight:700;
    font-size:12px;
    color:#111;
    flex:0 0 auto;
    border:1px solid rgba(0,0,0,.25);
  }
  .move-row .btns{
    display:flex;
    gap:6px;
    flex:1 1 auto;
  }
  .move-btn{
    flex:1 1 0;
    background:var(--bg-2);
    border:1px solid var(--panel-border);
    color:var(--text-0);
    border-radius:6px;
    padding:6px 0;
    font-size:12.5px;
    font-family:"Consolas","DM Mono",monospace;
    cursor:pointer;
    transition:background .12s ease, transform .06s ease, border-color .12s ease;
  }
  .move-btn:hover{
    background:#222838;
    border-color:var(--accent);
  }
  .move-btn:active{
    transform:scale(.94);
  }
  .actions{
    display:flex;
    gap:8px;
  }
  .action-btn{
    flex:1 1 0;
    padding:10px 6px;
    border-radius:8px;
    border:1px solid var(--panel-border);
    background:var(--bg-2);
    color:var(--text-0);
    font-size:12.5px;
    font-weight:600;
    cursor:pointer;
    display:flex;
    flex-direction:column;
    align-items:center;
    gap:2px;
    transition:background .12s ease, border-color .12s ease, transform .06s ease;
  }
  .action-btn:hover{
    border-color:var(--accent);
    background:#222838;
  }
  .action-btn:active{
    transform:scale(.96);
  }
  .action-btn.primary{
    background:linear-gradient(135deg,#5b8cff,#7f5bff);
    border-color:transparent;
    color:#fff;
  }
  .action-btn kbd{
    font-size:9.5px;
    color:var(--text-1);
    font-family:"Consolas",monospace;
  }
  .action-btn.primary kbd{
    color:rgba(255,255,255,.75);
  }
  .legend{
    font-size:11.5px;
    color:var(--text-1);
    line-height:1.65;
    border-top:1px solid var(--panel-border);
    padding-top:12px;
  }
  .legend b{
    color:var(--text-0);
  }
  .legend kbd{
    background:var(--bg-2);
    border:1px solid var(--panel-border);
    border-radius:4px;
    padding:1px 5px;
    font-family:"Consolas",monospace;
    font-size:10.5px;
    color:var(--text-0);
  }
  footer{
    flex:0 0 auto;
    padding:5px 20px;
    font-size:10.5px;
    color:var(--text-1);
    border-top:1px solid var(--panel-border);
    background:var(--bg-1);
  }
</style>
</head>
<body>
<div id="app">
  <header>
    <h1><span class="dot"></span>Cubo Magico 3D</h1>
    <div class="stats">
      <div class="stat">
        <span class="label">Movimentos</span>
        <span class="value" id="moveCount">0</span>
      </div>
      <div class="stat">
        <span class="label">Tempo</span>
        <span class="value" id="timer">00:00.0</span>
      </div>
    </div>
  </header>
  <main>
    <div id="canvas-wrap">
      <div id="hint">Arraste para girar a camera &middot; Scroll para zoom</div>
      <div id="toast"></div>
    </div>
    <div id="panel">
      <div>
        <p class="section-title">Movimentos de camada</p>
        <div id="move-grid"></div>
      </div>
      <div>
        <p class="section-title">Acoes</p>
        <div class="actions">
          <button class="action-btn primary" id="btn-scramble">
            <span>Embaralhar</span>
            <kbd>Enter</kbd>
          </button>
          <button class="action-btn" id="btn-undo">
            <span>Desfazer</span>
            <kbd>Z</kbd>
          </button>
          <button class="action-btn" id="btn-reset">
            <span>Resetar</span>
            <kbd>Esc</kbd>
          </button>
        </div>
      </div>
      <div class="legend">
        <b>Teclado</b><br/>
        <kbd>U D L R F B</kbd> giro horario da face<br/>
        <kbd>Shift</kbd> + face = anti-horario ( ' )<br/>
        <kbd>Ctrl</kbd> + face = meia volta ( 2 )<br/>
        <kbd>Z</kbd> desfazer &middot; <kbd>Enter</kbd> embaralhar &middot; <kbd>Esc</kbd> resetar
      </div>
    </div>
  </main>
  <footer>U = topo &middot; D = base &middot; L = esquerda &middot; R = direita &middot; F = frente &middot; B = fundo</footer>
</div>

<script type="importmap">
{"imports":{
  "three":"https://cdn.jsdelivr.net/npm/three@0.180.0/build/three.module.js",
  "three/addons/":"https://cdn.jsdelivr.net/npm/three@0.180.0/examples/jsm/"
}}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

/* ---------------------------------------------------------------------- */
/*  Setup basico: cena, camera, renderer, luzes, controles                */
/* ---------------------------------------------------------------------- */

const wrap = document.getElementById('canvas-wrap');

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0d1017);

const camera = new THREE.PerspectiveCamera(45, wrap.clientWidth / wrap.clientHeight, 0.1, 100);
camera.position.set(4.6, 4.2, 5.8);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(wrap.clientWidth, wrap.clientHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
wrap.appendChild(renderer.domElement);

const hemi = new THREE.HemisphereLight(0xffffff, 0x30313a, 1.15);
scene.add(hemi);
const dir1 = new THREE.DirectionalLight(0xffffff, 1.05);
dir1.position.set(6, 9, 7);
scene.add(dir1);
const dir2 = new THREE.DirectionalLight(0xffffff, 0.45);
dir2.position.set(-6, -4, -5);
scene.add(dir2);

const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.enablePan = false;
controls.minDistance = 5;
controls.maxDistance = 13;
controls.target.set(0, 0, 0);

new ResizeObserver(() => {
  const w = wrap.clientWidth, h = wrap.clientHeight;
  if (w === 0 || h === 0) return;
  camera.aspect = w / h;
  camera.updateProjectionMatrix();
  renderer.setSize(w, h);
}).observe(wrap);

/* ---------------------------------------------------------------------- */
/*  Construcao do cubo 3x3x3                                              */
/* ---------------------------------------------------------------------- */

const CUBIE_SIZE = 0.92;
const boxGeometry = new THREE.BoxGeometry(CUBIE_SIZE, CUBIE_SIZE, CUBIE_SIZE);
const edgesGeometry = new THREE.EdgesGeometry(boxGeometry);
const edgeMaterial = new THREE.LineBasicMaterial({ color: 0x05060a, linewidth: 1 });

function mat(hex) {
  return new THREE.MeshStandardMaterial({ color: hex, roughness: 0.45, metalness: 0.05 });
}

const colorMat = {
  red: mat(0xb71234),    // R (+x)
  orange: mat(0xff5800), // L (-x)
  white: mat(0xf5f5f5),  // U (+y)
  yellow: mat(0xffd500), // D (-y)
  green: mat(0x009e60),  // F (+z)
  blue: mat(0x0051ba),   // B (-z)
  black: mat(0x0c0d10),
};

let cubies = [];

function createCubie(x, y, z) {
  const materials = [
    x === 1 ? colorMat.red : colorMat.black,     // +x
    x === -1 ? colorMat.orange : colorMat.black, // -x
    y === 1 ? colorMat.white : colorMat.black,   // +y
    y === -1 ? colorMat.yellow : colorMat.black, // -y
    z === 1 ? colorMat.green : colorMat.black,   // +z
    z === -1 ? colorMat.blue : colorMat.black,   // -z
  ];
  const mesh = new THREE.Mesh(boxGeometry, materials);
  mesh.castShadow = false;
  const edges = new THREE.LineSegments(edgesGeometry, edgeMaterial);

  const group = new THREE.Group();
  group.add(mesh);
  group.add(edges);
  group.position.set(x, y, z);
  group.userData.home = { x, y, z };
  return group;
}

function buildCube() {
  for (let x = -1; x <= 1; x++) {
    for (let y = -1; y <= 1; y++) {
      for (let z = -1; z <= 1; z++) {
        if (x === 0 && y === 0 && z === 0) continue; // nucleo invisivel, omitido
        const cubie = createCubie(x, y, z);
        scene.add(cubie);
        cubies.push(cubie);
      }
    }
  }
}

function destroyCube() {
  cubies.forEach((c) => scene.remove(c));
  cubies = [];
}

buildCube();

/* ---------------------------------------------------------------------- */
/*  Logica de movimentos (notacao U D L R F B, ' e 2)                     */
/* ---------------------------------------------------------------------- */

const FACE_INFO = {
  U: { axis: 'y', layer: 1 },
  D: { axis: 'y', layer: -1 },
  L: { axis: 'x', layer: -1 },
  R: { axis: 'x', layer: 1 },
  F: { axis: 'z', layer: 1 },
  B: { axis: 'z', layer: -1 },
};

function cwAngle(axis, layer) {
  return -layer * (Math.PI / 2);
}

function angleFor(face, modifier) {
  const { axis, layer } = FACE_INFO[face];
  const cw = cwAngle(axis, layer);
  if (modifier === "'") return -cw;
  if (modifier === '2') return cw * 2;
  return cw;
}

function easeInOutCubic(t) {
  return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}

function snapObject(obj) {
  obj.position.x = Math.round(obj.position.x);
  obj.position.y = Math.round(obj.position.y);
  obj.position.z = Math.round(obj.position.z);
  const m = new THREE.Matrix4().makeRotationFromQuaternion(obj.quaternion);
  const e = m.elements;
  [0, 1, 2, 4, 5, 6, 8, 9, 10].forEach((i) => {
    e[i] = Math.round(e[i]);
  });
  obj.quaternion.setFromRotationMatrix(m);
}

let epoch = 0; // incrementado no reset para invalidar animacoes em voo

function animateMove(face, modifier, fast) {
  const myEpoch = epoch;
  const { axis, layer } = FACE_INFO[face];
  const targetAngle = angleFor(face, modifier);
  const selected = cubies.filter((c) => Math.round(c.position[axis]) === layer);

  const pivot = new THREE.Group();
  scene.add(pivot);
  selected.forEach((c) => pivot.attach(c));

  const duration = fast ? 110 : 240;

  return new Promise((resolve) => {
    const start = performance.now();
    function step(now) {
      if (myEpoch !== epoch) {
        // cubo foi resetado durante a animacao: aborta silenciosamente
        resolve();
        return;
      }
      const t = Math.min(1, (now - start) / duration);
      const eased = easeInOutCubic(t);
      pivot.rotation[axis] = targetAngle * eased;
      if (t < 1) {
        requestAnimationFrame(step);
      } else {
        pivot.rotation[axis] = targetAngle;
        pivot.updateMatrixWorld(true);
        selected.forEach((c) => {
          scene.attach(c);
          snapObject(c);
        });
        scene.remove(pivot);
        resolve();
      }
    }
    requestAnimationFrame(step);
  });
}

/* ---------------------------------------------------------------------- */
/*  Fila de movimentos, historico, contador, cronometro                   */
/* ---------------------------------------------------------------------- */

const queue = [];
let processing = false;
let moveCount = 0;
const history = [];

const moveCountEl = document.getElementById('moveCount');
const timerEl = document.getElementById('timer');
const toastEl = document.getElementById('toast');

function updateMoveCount() {
  moveCountEl.textContent = String(moveCount);
}

async function processQueue() {
  if (processing) return;
  processing = true;
  while (queue.length) {
    const task = queue.shift();
    if (task.type === 'move') {
      await animateMove(task.face, task.modifier, task.fast);
      if (myEpochValid(task)) {
        if (task.record) {
          history.push({ face: task.face, modifier: task.modifier });
          moveCount++;
        } else {
          moveCount = Math.max(0, moveCount - 1);
        }
        updateMoveCount();
        startTimerIfNeeded();
        checkSolved();
      }
    }
  }
  processing = false;
}

function myEpochValid(task) {
  return task.epoch === epoch;
}

function enqueueMove(face, modifier, record = true, fast = false) {
  queue.push({ type: 'move', face, modifier, record, fast, epoch });
  processQueue();
}

function enqueueUndo() {
  if (history.length === 0) return;
  const last = history.pop();
  const inv = last.modifier === "'" ? '' : last.modifier === '' ? "'" : '2';
  queue.push({ type: 'move', face: last.face, modifier: inv, record: false, fast: false, epoch });
  processQueue();
}

function scramble(n = 22) {
  let lastFace = null;
  for (let i = 0; i < n; i++) {
    const faces = Object.keys(FACE_INFO).filter((f) => f !== lastFace);
    const face = faces[Math.floor(Math.random() * faces.length)];
    const mods = ['', "'", '2'];
    const modifier = mods[Math.floor(Math.random() * mods.length)];
    queue.push({ type: 'move', face, modifier, record: true, fast: true, epoch });
    lastFace = face;
  }
  processQueue();
}

function resetCube() {
  epoch++;
  queue.length = 0;
  history.length = 0;
  moveCount = 0;
  updateMoveCount();
  resetTimer();
  destroyCube();
  buildCube();
  hideToast();
}

/* ---------------------------------------------------------------------- */
/*  Cronometro                                                            */
/* ---------------------------------------------------------------------- */

let timerRunning = false;
let timerStartTs = null;
let elapsedMs = 0;
let timerHandle = null;

function startTimerIfNeeded() {
  if (!timerRunning && history.length >= 1) {
    timerRunning = true;
    timerStartTs = performance.now() - elapsedMs;
    timerHandle = setInterval(updateTimerDisplay, 100);
  }
}

function stopTimer() {
  if (timerRunning) {
    timerRunning = false;
    clearInterval(timerHandle);
    elapsedMs = performance.now() - timerStartTs;
    updateTimerDisplay();
  }
}

function resetTimer() {
  timerRunning = false;
  clearInterval(timerHandle);
  elapsedMs = 0;
  timerStartTs = null;
  updateTimerDisplay();
}

function formatTime(ms) {
  const totalDs = Math.floor(ms / 100);
  const min = Math.floor(totalDs / 600);
  const sec = Math.floor((totalDs % 600) / 10);
  const ds = totalDs % 10;
  return `${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}.${ds}`;
}

function updateTimerDisplay() {
  const ms = timerRunning ? performance.now() - timerStartTs : elapsedMs;
  timerEl.textContent = formatTime(ms);
}

/* ---------------------------------------------------------------------- */
/*  Deteccao de cubo resolvido (bonus informativo)                        */
/* ---------------------------------------------------------------------- */

function isSolved() {
  return cubies.every((c) => {
    const h = c.userData.home;
    const px = Math.round(c.position.x), py = Math.round(c.position.y), pz = Math.round(c.position.z);
    if (px !== h.x || py !== h.y || pz !== h.z) return false;
    return Math.abs(c.quaternion.w) > 0.999;
  });
}

let toastTimeout = null;
function showToast(msg) {
  toastEl.textContent = msg;
  toastEl.classList.add('show');
  clearTimeout(toastTimeout);
  toastTimeout = setTimeout(hideToast, 3200);
}
function hideToast() {
  toastEl.classList.remove('show');
}

function checkSolved() {
  if (moveCount > 0 && isSolved()) {
    stopTimer();
    showToast(`Resolvido! ${moveCount} movimentos em ${formatTime(elapsedMs)}`);
  }
}

/* ---------------------------------------------------------------------- */
/*  Interface: botoes de movimento                                        */
/* ---------------------------------------------------------------------- */

const FACE_COLORS = { U: '#f5f5f5', D: '#ffd500', L: '#ff5800', R: '#b71234', F: '#009e60', B: '#0051ba' };
const FACE_LABELS = { U: 'U', D: 'D', L: 'L', R: 'R', F: 'F', B: 'B' };

const moveGrid = document.getElementById('move-grid');
Object.keys(FACE_INFO).forEach((face) => {
  const row = document.createElement('div');
  row.className = 'move-row';

  const tag = document.createElement('div');
  tag.className = 'face-tag';
  tag.style.background = FACE_COLORS[face];
  tag.textContent = FACE_LABELS[face];
  row.appendChild(tag);

  const btns = document.createElement('div');
  btns.className = 'btns';
  [['', face], ["'", face + "'"], ['2', face + '2']].forEach(([modifier, label]) => {
    const b = document.createElement('button');
    b.className = 'move-btn';
    b.textContent = label;
    b.addEventListener('click', () => enqueueMove(face, modifier, true, false));
    btns.appendChild(b);
  });
  row.appendChild(btns);
  moveGrid.appendChild(row);
});

document.getElementById('btn-scramble').addEventListener('click', () => scramble());
document.getElementById('btn-undo').addEventListener('click', () => enqueueUndo());
document.getElementById('btn-reset').addEventListener('click', () => resetCube());

/* ---------------------------------------------------------------------- */
/*  Controle por teclado                                                  */
/* ---------------------------------------------------------------------- */

window.addEventListener('keydown', (e) => {
  if (e.repeat) return;
  const key = e.key.toLowerCase();

  if (['u', 'd', 'l', 'r', 'f', 'b'].includes(key)) {
    e.preventDefault();
    const face = key.toUpperCase();
    const modifier = e.ctrlKey || e.metaKey ? '2' : e.shiftKey ? "'" : '';
    enqueueMove(face, modifier, true, false);
    return;
  }
  if (key === 'z' || e.key === 'Backspace') {
    e.preventDefault();
    enqueueUndo();
    return;
  }
  if (e.key === 'Enter') {
    e.preventDefault();
    scramble();
    return;
  }
  if (e.key === 'Escape') {
    e.preventDefault();
    resetCube();
    return;
  }
});

/* ---------------------------------------------------------------------- */
/*  Loop de renderizacao                                                  */
/* ---------------------------------------------------------------------- */

function tick() {
  controls.update();
  renderer.render(scene, camera);
  requestAnimationFrame(tick);
}
tick();
</script>
</body>
</html>
claude-sonnet-5-claude-code-web 1 file 759 lines 20 KB shortcuts p preview tab c code tab [ previous file ] next file esc leave the artifact

prompt used in this run

Crie um simulador de cubo magico 3D em um unico arquivo HTML.

Requisitos funcionais:
- Cubo 3x3x3 renderizado em 3D com Three.js, com as seis cores nas faces
- Rotacao animada de camadas, uma camada por vez, sem quebrar o estado do cubo
- Notacao padrao (U, D, L, R, F, B) com as variantes ' (anti-horario) e 2 (meia volta)
- Botoes de embaralhar, desfazer o ultimo movimento e resetar
- Contador de movimentos e cronometro
- Controle por teclado e por botoes na tela
- Camera orbital com o mouse (arrastar para girar, scroll para zoom)

Restricoes tecnicas (obrigatorias):
- UM unico arquivo index.html. Todo o CSS e todo o JavaScript inline. Nao crie nenhum outro arquivo
- Three.js vem do CDN, declarado num <script type="importmap"> inline e consumido por um <script type="module"> tambem inline:

  <script type="importmap">
  {"imports":{
    "three":"https://cdn.jsdelivr.net/npm/three@0.180.0/build/three.module.js",
    "three/addons/":"https://cdn.jsdelivr.net/npm/three@0.180.0/examples/jsm/"
  }}
  </script>
  <script type="module">
  import * as THREE from 'three';
  import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
  </script>

  Atencao: importar https://cdn.jsdelivr.net/npm/three@0.180.0/examples/jsm/controls/OrbitControls.js pela URL direta NAO funciona — esse arquivo faz `import ... from 'three'`, e o especificador bare so resolve pelo importmap
- NAO use <script type="module" src="..."> apontando para arquivo local
- NAO use localStorage nem sessionStorage — eles lancam SecurityError no ambiente onde a pagina roda
- A pagina roda dentro de um iframe sandbox de origem opaca: sem cookies, sem acesso a janela pai
- Deve caber e ficar legivel em 1440x900
author's notes
A single 21 KB / 758-line file. The cube shows all six colors correctly, with a full notation grid (half-turns included), a move counter, a timer and a keyboard legend. Verified inside the opaque-origin iframe with real keyboard interaction: (R U R' U') repeated six times returns the cube to solved and the run itself announces "Solved! 24 moves", with no console errors — meaning layer rotation preserves logical state across 24 chained moves. It rotates a layer by reparenting the cubies onto a pivot and handing them back to the scene with position and quaternion snapping, which is what prevents floating-point drift.