diff --git a/README.md b/README.md index 75707dd..95ef7d8 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,14 @@ Application web mobile-first pour téléphone et tablette, pensée comme applica ## Ce que fait cette première version - configure une rencontre `Twice` ou `Time` +- sépare l'application en pages dédiées : configuration, phase chrono, phase cube - gère les blocks de 180 secondes et le temps par coup de 20 secondes - suit les quotas `FAST`, `FREEZE` et `MASTERS` - orchestre la phase cube avec désignation du cube, capture des temps et préparation du block suivant - applique la logique du double coup V2 en `Twice` - applique les ajustements `bloc -` et `bloc +` en `Time` avec plafond de 120 s pris en compte - conserve un historique local dans le navigateur +- propose une page chrono pensée pour le téléphone avec deux grandes zones tactiles, une par joueur ## Hypothèse de produit @@ -27,7 +29,9 @@ L'application est ensuite disponible sur `http://localhost:8080`. ## Fichiers clés -- `index.html` : structure de l'interface +- `index.html` : page de configuration et reprise de match +- `chrono.html` : page dédiée à la phase chrono +- `cube.html` : page dédiée à la phase cube - `styles.css` : design mobile/tablette - `app.js` : logique de match et arbitrage - `docker-compose.yml` + `Dockerfile` : exécution locale diff --git a/app.js b/app.js index 0c41290..e2c63b6 100644 --- a/app.js +++ b/app.js @@ -1,227 +1,793 @@ +const PAGE = document.body.dataset.page; + +const STORAGE_KEY = "chesscubing-arena-state-v2"; const BLOCK_DURATION_MS = 180000; const MOVE_LIMIT_MS = 20000; const TIME_MODE_INITIAL_CLOCK_MS = 600000; const CUBE_TIME_CAP_MS = 120000; -const STORAGE_KEY = "chesscubing-arena-state-v1"; const PRESETS = { fast: { label: "FAST", quota: 6, - description: "Format nerveux : 6 coups par joueur et par block.", + description: "6 coups par joueur et par block.", }, freeze: { label: "FREEZE", quota: 8, - description: "Format intermédiaire : 8 coups par joueur et par block.", + description: "8 coups par joueur et par block.", }, masters: { label: "MASTERS", quota: 10, - description: "Format long : 10 coups par joueur et par block.", + description: "10 coups par joueur et par block.", }, }; const MODES = { twice: { label: "ChessCubing Twice", - subtitle: - "Le gagnant du cube démarre le block suivant. Double coup V2 possible.", + subtitle: "Le gagnant du cube ouvre le block suivant.", }, time: { label: "ChessCubing Time", - subtitle: - "Blocks identiques au Twice avec chronos cumulés et alternance bloc - / +.", + subtitle: "Chronos cumules et alternance bloc - / bloc +.", }, }; -const refs = { - setupForm: document.querySelector("#setupForm"), - setupSummary: document.querySelector("#setupSummary"), - loadDemoButton: document.querySelector("#loadDemoButton"), - livePanel: document.querySelector("#livePanel"), - heroModeHint: document.querySelector("#heroModeHint"), - matchTitle: document.querySelector("#matchTitle"), - phaseBadge: document.querySelector("#phaseBadge"), - blockLabel: document.querySelector("#blockLabel"), - modeLabel: document.querySelector("#modeLabel"), - blockMeta: document.querySelector("#blockMeta"), - blockTimer: document.querySelector("#blockTimer"), - moveTimer: document.querySelector("#moveTimer"), - blockStatusText: document.querySelector("#blockStatusText"), - turnLabel: document.querySelector("#turnLabel"), - whiteNameDisplay: document.querySelector("#whiteNameDisplay"), - blackNameDisplay: document.querySelector("#blackNameDisplay"), - whiteMoveCount: document.querySelector("#whiteMoveCount"), - blackMoveCount: document.querySelector("#blackMoveCount"), - whiteClockLabel: document.querySelector("#whiteClockLabel"), - blackClockLabel: document.querySelector("#blackClockLabel"), - whiteCubeLabel: document.querySelector("#whiteCubeLabel"), - blackCubeLabel: document.querySelector("#blackCubeLabel"), - whiteCard: document.querySelector("#whiteCard"), - blackCard: document.querySelector("#blackCard"), - startPauseButton: document.querySelector("#startPauseButton"), - confirmBlockButton: document.querySelector("#confirmBlockButton"), - moveActionButton: document.querySelector("#moveActionButton"), - reliefMoveButton: document.querySelector("#reliefMoveButton"), - timeoutMoveButton: document.querySelector("#timeoutMoveButton"), - switchTurnButton: document.querySelector("#switchTurnButton"), - contextNotice: document.querySelector("#contextNotice"), - doubleCard: document.querySelector("#doubleCard"), - cubeNumber: document.querySelector("#cubeNumber"), - startCubeButton: document.querySelector("#startCubeButton"), - cubeElapsed: document.querySelector("#cubeElapsed"), - cubeStatusText: document.querySelector("#cubeStatusText"), - captureWhiteCubeButton: document.querySelector("#captureWhiteCubeButton"), - captureBlackCubeButton: document.querySelector("#captureBlackCubeButton"), - whiteCubeResult: document.querySelector("#whiteCubeResult"), - blackCubeResult: document.querySelector("#blackCubeResult"), - whiteCubeCap: document.querySelector("#whiteCubeCap"), - blackCubeCap: document.querySelector("#blackCubeCap"), - applyCubeButton: document.querySelector("#applyCubeButton"), - redoCubeButton: document.querySelector("#redoCubeButton"), - historyList: document.querySelector("#historyList"), - resetMatchButton: document.querySelector("#resetMatchButton"), - whiteWinButton: document.querySelector("#whiteWinButton"), - blackWinButton: document.querySelector("#blackWinButton"), - drawStopButton: document.querySelector("#drawStopButton"), -}; +let match = readStoredMatch(); +let dirty = false; -const state = { - match: restoreState(), - lastTickAt: null, -}; +if (match) { + normalizeRecoveredMatch(match); + if (syncRunningState(match)) { + dirty = true; + } +} -hydrateRecoveredState(); -bindEvents(); -renderSetupSummary(); -render(); -startTicker(); +window.addEventListener("beforeunload", flushState); +document.addEventListener("visibilitychange", () => { + if (document.hidden) { + if (match) { + syncRunningState(match); + } + flushState(); + } +}); -function bindEvents() { - document.querySelectorAll("[data-scroll-target]").forEach((button) => { - button.addEventListener("click", () => { - const target = document.getElementById(button.dataset.scrollTarget); - target?.scrollIntoView({ behavior: "smooth", block: "start" }); +window.setInterval(() => { + if (dirty) { + persistMatch(); + } +}, 1000); + +switch (PAGE) { + case "setup": + initSetupPage(); + break; + case "chrono": + initChronoPage(); + break; + case "cube": + initCubePage(); + break; + default: + break; +} + +function initSetupPage() { + const form = document.querySelector("#setupForm"); + const summary = document.querySelector("#setupSummary"); + const loadDemoButton = document.querySelector("#loadDemoButton"); + const resumeCard = document.querySelector("#resumeCard"); + + if (!form || !summary || !loadDemoButton || !resumeCard) { + return; + } + + const renderSummary = () => { + const mode = getRadioValue(form, "mode") || "twice"; + const preset = getRadioValue(form, "preset") || "fast"; + const quota = PRESETS[preset].quota; + const timeImpact = + mode === "time" + ? "Chronos cumules de 10 minutes, ajustes apres chaque phase cube avec plafond de 120 s pris en compte." + : "Le gagnant du cube commence le block suivant, avec double coup V2 possible."; + + summary.innerHTML = ` + ${MODES[mode].label} + ${PRESETS[preset].description} + Chaque block dure 180 secondes, chaque coup est limite a 20 secondes. + ${timeImpact} + Quota actif : ${quota} coups par joueur. + `; + }; + + const renderResume = () => { + if (!match) { + resumeCard.classList.add("empty"); + resumeCard.innerHTML = "

Aucun match en cours pour l'instant.

"; + return; + } + + resumeCard.classList.remove("empty"); + const phaseLabel = match.result + ? resultText(match) + : match.phase === "cube" + ? "Page cube prete" + : match.awaitingBlockClosure + ? "Fin de block a confirmer" + : "Page chrono prete"; + + resumeCard.innerHTML = ` + ${escapeHtml(match.config.matchLabel)} +

${escapeHtml(MODES[match.config.mode].label)}

+

${escapeHtml(match.config.whiteName)} vs ${escapeHtml(match.config.blackName)}

+

${escapeHtml(phaseLabel)}

+
+ + +
+ `; + + resumeCard.querySelector("#resumeMatchButton")?.addEventListener("click", () => { + navigateTo(routeForMatch(match)); }); - }); - refs.setupForm.addEventListener("submit", (event) => { + resumeCard.querySelector("#clearMatchButton")?.addEventListener("click", () => { + clearMatch(); + renderResume(); + }); + }; + + form.addEventListener("input", renderSummary); + loadDemoButton.addEventListener("click", () => loadDemo(form, renderSummary)); + + form.addEventListener("submit", (event) => { event.preventDefault(); - const formData = new FormData(refs.setupForm); const config = { - matchLabel: sanitizeText(formData.get("matchLabel")) || "Rencontre ChessCubing", - mode: formData.get("mode") || "twice", - preset: formData.get("preset") || "fast", - whiteName: sanitizeText(formData.get("whiteName")) || "Blanc", - blackName: sanitizeText(formData.get("blackName")) || "Noir", - arbiterName: sanitizeText(formData.get("arbiterName")) || "", - eventName: sanitizeText(formData.get("eventName")) || "", - notes: sanitizeText(formData.get("notes")) || "", + matchLabel: sanitizeText(form.elements.matchLabel.value) || "Rencontre ChessCubing", + mode: getRadioValue(form, "mode") || "twice", + preset: getRadioValue(form, "preset") || "fast", + whiteName: sanitizeText(form.elements.whiteName.value) || "Blanc", + blackName: sanitizeText(form.elements.blackName.value) || "Noir", + arbiterName: sanitizeText(form.elements.arbiterName.value), + eventName: sanitizeText(form.elements.eventName.value), + notes: sanitizeText(form.elements.notes.value), }; - state.match = createMatch(config); - persistState(); - refs.livePanel.classList.remove("hidden"); - refs.livePanel.scrollIntoView({ behavior: "smooth", block: "start" }); + match = createMatch(config); + dirty = true; + persistMatch(); + navigateTo("chrono.html"); + }); + + renderSummary(); + renderResume(); +} + +function initChronoPage() { + if (!match) { + replaceTo("index.html"); + return; + } + + if (!match.result && match.phase === "cube") { + replaceTo("cube.html"); + return; + } + + const refs = { + title: document.querySelector("#chronoTitle"), + subtitle: document.querySelector("#chronoSubtitle"), + blockTimer: document.querySelector("#blockTimer"), + moveTimer: document.querySelector("#moveTimer"), + centerLabel: document.querySelector("#chronoCenterLabel"), + centerValue: document.querySelector("#chronoCenterValue"), + spineLabel: document.querySelector("#spineLabel"), + spineHeadline: document.querySelector("#spineHeadline"), + spineText: document.querySelector("#spineText"), + primaryButton: document.querySelector("#primaryChronoButton"), + whiteName: document.querySelector("#whiteNameChrono"), + blackName: document.querySelector("#blackNameChrono"), + whiteMoves: document.querySelector("#whiteMovesChrono"), + blackMoves: document.querySelector("#blackMovesChrono"), + whiteClock: document.querySelector("#whiteClockChrono"), + blackClock: document.querySelector("#blackClockChrono"), + whiteHint: document.querySelector("#whiteHintChrono"), + blackHint: document.querySelector("#blackHintChrono"), + whiteButton: document.querySelector("#whiteMoveButton"), + blackButton: document.querySelector("#blackMoveButton"), + whiteZone: document.querySelector("#whiteZone"), + blackZone: document.querySelector("#blackZone"), + openArbiterButton: document.querySelector("#openArbiterButton"), + closeArbiterButton: document.querySelector("#closeArbiterButton"), + arbiterModal: document.querySelector("#arbiterModal"), + arbiterStatus: document.querySelector("#arbiterStatus"), + arbiterPauseButton: document.querySelector("#arbiterPauseButton"), + arbiterCloseBlockButton: document.querySelector("#arbiterCloseBlockButton"), + arbiterTimeoutButton: document.querySelector("#arbiterTimeoutButton"), + arbiterSwitchTurnButton: document.querySelector("#arbiterSwitchTurnButton"), + arbiterWhiteWinButton: document.querySelector("#arbiterWhiteWinButton"), + arbiterBlackWinButton: document.querySelector("#arbiterBlackWinButton"), + arbiterStopButton: document.querySelector("#arbiterStopButton"), + arbiterResetButton: document.querySelector("#arbiterResetButton"), + }; + + const openModal = () => toggleModal(refs.arbiterModal, true); + const closeModal = () => toggleModal(refs.arbiterModal, false); + + refs.openArbiterButton?.addEventListener("click", openModal); + refs.closeArbiterButton?.addEventListener("click", closeModal); + refs.arbiterModal?.addEventListener("click", (event) => { + const target = event.target; + if (target instanceof HTMLElement && target.dataset.closeModal === "true") { + closeModal(); + } + }); + + refs.whiteButton?.addEventListener("click", () => handleChronoTap("white")); + refs.blackButton?.addEventListener("click", () => handleChronoTap("black")); + refs.primaryButton?.addEventListener("click", handlePrimaryChronoAction); + + refs.arbiterPauseButton?.addEventListener("click", () => { + syncRunningState(match); + if (match.result || match.phase !== "block") { + return; + } + + if (match.awaitingBlockClosure) { + openCubePhase(match); + dirty = true; + persistMatch(); + navigateTo("cube.html"); + return; + } + + if (match.running) { + pauseBlock(match); + } else { + startBlock(match); + } + + dirty = true; render(); }); - refs.setupForm.addEventListener("input", renderSetupSummary); - refs.loadDemoButton.addEventListener("click", loadDemo); - - refs.startPauseButton.addEventListener("click", () => { - if (!state.match || state.match.result) { + refs.arbiterCloseBlockButton?.addEventListener("click", () => { + syncRunningState(match); + if (match.result || match.phase !== "block") { return; } - if (state.match.phase === "block" && state.match.running) { - pauseBlock(); - } else { - startBlock(); - } - }); - - refs.confirmBlockButton.addEventListener("click", () => { - if (!state.match || state.match.result) { + if (match.awaitingBlockClosure) { + openCubePhase(match); + dirty = true; + persistMatch(); + navigateTo("cube.html"); return; } - if (state.match.phase === "cube") { - return; - } - - syncRunningTimers(); - - if (!state.match.awaitingBlockClosure && state.match.phase === "block") { - requestBlockClosure("Clôture manuelle du block demandée par l'arbitre."); - } else { - openCubePhase(); - } - }); - - refs.moveActionButton.addEventListener("click", () => { - if (!state.match || state.match.phase !== "block" || !state.match.running) { - return; - } - - if (state.match.doubleCoup.step === 1) { - registerFreeDoubleMove(); - return; - } - - const isSecondDoubleMove = state.match.doubleCoup.step === 2; - registerCountedMove({ - label: isSecondDoubleMove - ? "Deuxième coup du double coup enregistré." - : "Coup compté enregistré.", - source: isSecondDoubleMove ? "double" : "standard", - }); - }); - - refs.reliefMoveButton.addEventListener("click", () => { - if (!state.match || state.match.phase !== "block") { - return; - } - registerReliefMove(); - }); - - refs.timeoutMoveButton.addEventListener("click", () => { - if (!state.match || state.match.phase !== "block") { - return; - } - registerMoveTimeout(false); - }); - - refs.switchTurnButton.addEventListener("click", () => { - if (!state.match || state.match.result) { - return; - } - state.match.currentTurn = opponentOf(state.match.currentTurn); - state.match.moveRemainingMs = MOVE_LIMIT_MS; - logEvent("Trait corrigé manuellement par l'arbitre."); - persistState(); + requestBlockClosure(match, "Cloture manuelle du block demandee par l'arbitre."); + dirty = true; render(); }); - refs.startCubeButton.addEventListener("click", startCubePhase); - refs.captureWhiteCubeButton.addEventListener("click", () => captureCubeTime("white")); - refs.captureBlackCubeButton.addEventListener("click", () => captureCubeTime("black")); - refs.applyCubeButton.addEventListener("click", applyCubeOutcome); - refs.redoCubeButton.addEventListener("click", replayCubePhase); + refs.arbiterTimeoutButton?.addEventListener("click", () => { + syncRunningState(match); + if (match.result || match.phase !== "block") { + return; + } - refs.resetMatchButton.addEventListener("click", resetMatch); - refs.whiteWinButton.addEventListener("click", () => setResult("white")); - refs.blackWinButton.addEventListener("click", () => setResult("black")); - refs.drawStopButton.addEventListener("click", () => setResult("stopped")); + registerMoveTimeout(match, false); + dirty = true; + render(); + }); + + refs.arbiterSwitchTurnButton?.addEventListener("click", () => { + syncRunningState(match); + if (match.result || match.phase !== "block") { + return; + } + + match.currentTurn = opponentOf(match.currentTurn); + match.moveRemainingMs = MOVE_LIMIT_MS; + logEvent(match, "Trait corrige manuellement par l'arbitre."); + dirty = true; + render(); + }); + + refs.arbiterWhiteWinButton?.addEventListener("click", () => { + syncRunningState(match); + setResult(match, "white"); + dirty = true; + render(); + }); + + refs.arbiterBlackWinButton?.addEventListener("click", () => { + syncRunningState(match); + setResult(match, "black"); + dirty = true; + render(); + }); + + refs.arbiterStopButton?.addEventListener("click", () => { + syncRunningState(match); + setResult(match, "stopped"); + dirty = true; + render(); + }); + + refs.arbiterResetButton?.addEventListener("click", () => { + clearMatch(); + replaceTo("index.html"); + }); + + function handleChronoTap(color) { + if (!match || match.result || match.phase !== "block") { + return; + } + + syncRunningState(match); + + if (match.awaitingBlockClosure) { + if (match.currentTurn !== color) { + return; + } + + registerReliefMove(match); + dirty = true; + render(); + return; + } + + if (!match.running || match.currentTurn !== color) { + return; + } + + if (match.doubleCoup.step === 1) { + registerFreeDoubleMove(match); + } else { + registerCountedMove(match, { + source: match.doubleCoup.step === 2 ? "double" : "standard", + }); + } + + dirty = true; + render(); + } + + function handlePrimaryChronoAction() { + syncRunningState(match); + + if (match.result) { + navigateTo("index.html"); + return; + } + + if (match.phase !== "block") { + navigateTo("cube.html"); + return; + } + + if (match.awaitingBlockClosure) { + openCubePhase(match); + dirty = true; + persistMatch(); + navigateTo("cube.html"); + return; + } + + if (match.running) { + pauseBlock(match); + } else { + startBlock(match); + } + + dirty = true; + render(); + } + + function renderZone(color) { + const isWhite = color === "white"; + const button = isWhite ? refs.whiteButton : refs.blackButton; + const name = isWhite ? refs.whiteName : refs.blackName; + const moves = isWhite ? refs.whiteMoves : refs.blackMoves; + const clock = isWhite ? refs.whiteClock : refs.blackClock; + const hint = isWhite ? refs.whiteHint : refs.blackHint; + const zone = isWhite ? refs.whiteZone : refs.blackZone; + const actorName = playerName(match, color); + const active = match.currentTurn === color; + + name.textContent = actorName; + moves.textContent = `${match.moves[color]} / ${match.quota}`; + clock.textContent = match.clocks + ? `Chrono ${formatSignedClock(match.clocks[color])}` + : `Dernier cube ${renderLastCube(match, color)}`; + + button.classList.toggle("active-turn", active && !match.result); + zone.classList.toggle("active-zone", active && !match.result); + + if (match.result) { + button.textContent = resultText(match); + button.disabled = true; + hint.textContent = "Le match est termine."; + return; + } + + if (match.awaitingBlockClosure) { + if (active) { + button.textContent = "Coup hors quota"; + button.disabled = false; + hint.textContent = "A utiliser seulement si un roi est encore en echec avant la page cube."; + } else { + button.textContent = "En attente"; + button.disabled = true; + hint.textContent = "Attente de la reponse adverse ou du passage a la page cube."; + } + return; + } + + if (!match.running) { + button.textContent = "Block en pause"; + button.disabled = true; + hint.textContent = active + ? "Le block n'a pas encore demarre ou a ete mis en pause." + : `${playerName(match, match.currentTurn)} reprendra au demarrage.`; + return; + } + + if (!active) { + button.textContent = "Attends"; + button.disabled = true; + hint.textContent = `${playerName(match, match.currentTurn)} est en train de jouer.`; + return; + } + + if (match.doubleCoup.step === 1) { + button.textContent = "1er coup gratuit"; + button.disabled = false; + hint.textContent = "Ce coup ne compte pas et ne doit pas donner echec."; + return; + } + + if (match.doubleCoup.step === 2) { + button.textContent = "2e coup du double"; + button.disabled = false; + hint.textContent = "Ce coup compte dans le quota et l'echec redevient autorise."; + return; + } + + button.textContent = "J'ai fini mon coup"; + button.disabled = false; + hint.textContent = "Tape des que ton coup est joue sur l'echiquier."; + } + + function render() { + if (!match) { + return; + } + + refs.title.textContent = match.config.matchLabel; + refs.subtitle.textContent = `Block ${match.blockNumber} - ${MODES[match.config.mode].label} - ${renderModeContext(match)}`; + refs.blockTimer.textContent = formatClock(match.blockRemainingMs); + refs.moveTimer.textContent = formatClock(match.moveRemainingMs); + + if (match.result) { + refs.centerLabel.textContent = "Resultat"; + refs.centerValue.textContent = resultText(match); + refs.spineLabel.textContent = "Termine"; + refs.spineHeadline.textContent = resultText(match); + refs.spineText.textContent = "Retournez a la configuration pour lancer une nouvelle rencontre."; + refs.primaryButton.textContent = "Retour a l'accueil"; + refs.arbiterStatus.textContent = "Le match est termine. Vous pouvez revenir a l'accueil ou reinitialiser."; + } else if (match.awaitingBlockClosure) { + refs.centerLabel.textContent = "Transition"; + refs.centerValue.textContent = "Fin de block"; + refs.spineLabel.textContent = "Page suivante"; + refs.spineHeadline.textContent = "Passer a la page cube"; + refs.spineText.textContent = `${match.closureReason} Jouez encore des coups hors quota si necessaire, puis ouvrez la page cube.`; + refs.primaryButton.textContent = "Ouvrir la page cube"; + refs.arbiterStatus.textContent = refs.spineText.textContent; + } else if (match.running) { + refs.centerLabel.textContent = "Trait"; + refs.centerValue.textContent = playerName(match, match.currentTurn); + refs.spineLabel.textContent = "Chrono en cours"; + refs.spineHeadline.textContent = `Block ${match.blockNumber} actif`; + refs.spineText.textContent = "Chaque joueur tape sur sa grande zone quand son coup est termine."; + refs.primaryButton.textContent = "Pause arbitre"; + refs.arbiterStatus.textContent = `Block en cours. Joueur au trait : ${playerName(match, match.currentTurn)}.`; + } else { + refs.centerLabel.textContent = "Trait"; + refs.centerValue.textContent = playerName(match, match.currentTurn); + refs.spineLabel.textContent = "Pret"; + refs.spineHeadline.textContent = `Block ${match.blockNumber}`; + refs.spineText.textContent = "Demarrez le block, puis laissez uniquement les deux grandes zones aux joueurs."; + refs.primaryButton.textContent = "Demarrer le block"; + refs.arbiterStatus.textContent = `Block pret. ${playerName(match, match.currentTurn)} commencera.`; + } + + refs.arbiterCloseBlockButton.textContent = match.awaitingBlockClosure + ? "Ouvrir la page cube" + : "Clore le block"; + + renderZone("black"); + renderZone("white"); + } + + render(); + + window.setInterval(() => { + if (!match) { + return; + } + + const changed = syncRunningState(match); + if (changed) { + dirty = true; + } + + if (!match.result && match.phase === "cube") { + persistMatch(); + navigateTo("cube.html"); + return; + } + + render(); + }, 100); +} + +function initCubePage() { + if (!match) { + replaceTo("index.html"); + return; + } + + if (!match.result && match.phase !== "cube") { + replaceTo("chrono.html"); + return; + } + + const refs = { + title: document.querySelector("#cubeTitle"), + subtitle: document.querySelector("#cubeSubtitle"), + blockLabel: document.querySelector("#cubeBlockLabel"), + elapsed: document.querySelector("#cubeElapsed"), + centerLabel: document.querySelector("#cubeCenterLabel"), + centerValue: document.querySelector("#cubeCenterValue"), + spineLabel: document.querySelector("#cubeSpineLabel"), + spineHeadline: document.querySelector("#cubeSpineHeadline"), + spineText: document.querySelector("#cubeSpineText"), + primaryButton: document.querySelector("#primaryCubeButton"), + whiteName: document.querySelector("#whiteNameCube"), + blackName: document.querySelector("#blackNameCube"), + whiteButton: document.querySelector("#whiteCubeButton"), + blackButton: document.querySelector("#blackCubeButton"), + whiteResult: document.querySelector("#whiteCubeResult"), + blackResult: document.querySelector("#blackCubeResult"), + whiteCap: document.querySelector("#whiteCubeCap"), + blackCap: document.querySelector("#blackCubeCap"), + whiteHint: document.querySelector("#whiteHintCube"), + blackHint: document.querySelector("#blackHintCube"), + openHelpButton: document.querySelector("#openCubeHelpButton"), + closeHelpButton: document.querySelector("#closeCubeHelpButton"), + helpModal: document.querySelector("#cubeHelpModal"), + helpStatus: document.querySelector("#cubeHelpStatus"), + replayCubeButton: document.querySelector("#replayCubeButton"), + resetButton: document.querySelector("#cubeResetButton"), + }; + + const openModal = () => toggleModal(refs.helpModal, true); + const closeModal = () => toggleModal(refs.helpModal, false); + + refs.openHelpButton?.addEventListener("click", openModal); + refs.closeHelpButton?.addEventListener("click", closeModal); + refs.helpModal?.addEventListener("click", (event) => { + const target = event.target; + if (target instanceof HTMLElement && target.dataset.closeCubeModal === "true") { + closeModal(); + } + }); + + refs.whiteButton?.addEventListener("click", () => { + captureCubeTime(match, "white"); + dirty = true; + render(); + }); + + refs.blackButton?.addEventListener("click", () => { + captureCubeTime(match, "black"); + dirty = true; + render(); + }); + + refs.primaryButton?.addEventListener("click", () => { + if (match.result) { + navigateTo("index.html"); + return; + } + + if (match.phase !== "cube") { + navigateTo("chrono.html"); + return; + } + + if (!match.cube.running && match.cube.times.white === null && match.cube.times.black === null) { + startCubePhase(match); + dirty = true; + render(); + return; + } + + if (match.cube.running) { + return; + } + + if (match.cube.times.white !== null && match.cube.times.black !== null) { + if (match.config.mode === "twice" && match.cube.times.white === match.cube.times.black) { + replayCubePhase(match); + dirty = true; + render(); + return; + } + + applyCubeOutcome(match); + dirty = true; + persistMatch(); + navigateTo("chrono.html"); + } + }); + + refs.replayCubeButton?.addEventListener("click", () => { + replayCubePhase(match); + dirty = true; + render(); + }); + + refs.resetButton?.addEventListener("click", () => { + clearMatch(); + replaceTo("index.html"); + }); + + function renderCubeZone(color) { + const isWhite = color === "white"; + const button = isWhite ? refs.whiteButton : refs.blackButton; + const name = isWhite ? refs.whiteName : refs.blackName; + const result = isWhite ? refs.whiteResult : refs.blackResult; + const cap = isWhite ? refs.whiteCap : refs.blackCap; + const hint = isWhite ? refs.whiteHint : refs.blackHint; + const time = match.cube.times[color]; + + name.textContent = playerName(match, color); + result.textContent = time === null ? "--" : formatStopwatch(time); + cap.textContent = renderCubeCap(match, time); + + if (match.result) { + button.textContent = resultText(match); + button.disabled = true; + hint.textContent = "Le match est termine."; + return; + } + + if (match.phase !== "cube") { + button.textContent = "Retour chrono"; + button.disabled = true; + hint.textContent = "La page cube est terminee."; + return; + } + + if (!match.cube.running) { + if (time !== null) { + button.textContent = "Temps capture"; + button.disabled = true; + hint.textContent = "Arret deja enregistre pour ce joueur."; + } else { + button.textContent = "Pret"; + button.disabled = true; + hint.textContent = "Attente du demarrage de la phase cube."; + } + return; + } + + if (time !== null) { + button.textContent = "Temps capture"; + button.disabled = true; + hint.textContent = "Le temps de ce joueur est deja enregistre."; + return; + } + + button.textContent = "J'ai fini le cube"; + button.disabled = false; + hint.textContent = "Tape au moment exact ou le cube est resolu."; + } + + function render() { + refs.title.textContent = match.cube.number ? `Cube n${match.cube.number}` : "Phase cube"; + refs.subtitle.textContent = `Block ${match.blockNumber} - ${MODES[match.config.mode].label} - ${renderModeContext(match)}`; + refs.blockLabel.textContent = `${match.blockNumber}`; + refs.elapsed.textContent = renderCubeElapsed(match); + + if (match.result) { + refs.centerLabel.textContent = "Resultat"; + refs.centerValue.textContent = resultText(match); + refs.spineLabel.textContent = "Termine"; + refs.spineHeadline.textContent = resultText(match); + refs.spineText.textContent = "Retournez a la configuration pour relancer une rencontre."; + refs.primaryButton.textContent = "Retour a l'accueil"; + refs.helpStatus.textContent = "Le match est termine."; + } else if (match.cube.running) { + refs.centerLabel.textContent = "Etat"; + refs.centerValue.textContent = "Cube en cours"; + refs.spineLabel.textContent = "Arrets"; + refs.spineHeadline.textContent = "Attendre les deux fins"; + refs.spineText.textContent = "Chaque joueur tape sur sa grande zone des qu'il a resolu son cube."; + refs.primaryButton.textContent = "Chrono cube en cours"; + refs.helpStatus.textContent = "Phase cube en cours. Les arrets sont saisis directement par les joueurs."; + } else if ( + match.cube.times.white !== null && + match.cube.times.black !== null && + match.config.mode === "twice" && + match.cube.times.white === match.cube.times.black + ) { + refs.centerLabel.textContent = "Decision"; + refs.centerValue.textContent = "Egalite parfaite"; + refs.spineLabel.textContent = "Reglement"; + refs.spineHeadline.textContent = "Rejouer la phase cube"; + refs.spineText.textContent = "Le mode Twice impose de relancer immediatement la phase cube en cas d'egalite parfaite."; + refs.primaryButton.textContent = "Rejouer la phase cube"; + refs.helpStatus.textContent = refs.spineText.textContent; + } else if (match.cube.times.white !== null && match.cube.times.black !== null) { + refs.centerLabel.textContent = "Decision"; + refs.centerValue.textContent = "Phase cube complete"; + refs.spineLabel.textContent = "Suite"; + refs.spineHeadline.textContent = "Ouvrir la page chrono"; + refs.spineText.textContent = "Appliquer le resultat du cube pour preparer le block suivant."; + refs.primaryButton.textContent = "Appliquer et ouvrir la page chrono"; + refs.helpStatus.textContent = refs.spineText.textContent; + } else { + refs.centerLabel.textContent = "Etat"; + refs.centerValue.textContent = "Pret"; + refs.spineLabel.textContent = "Depart"; + refs.spineHeadline.textContent = "Lancer la phase cube"; + refs.spineText.textContent = "Demarrez le chrono commun, puis laissez chaque joueur utiliser uniquement sa grande zone."; + refs.primaryButton.textContent = "Demarrer la phase cube"; + refs.helpStatus.textContent = "La phase cube n'a pas encore commence."; + } + + renderCubeZone("black"); + renderCubeZone("white"); + refs.primaryButton.disabled = match.result ? false : match.cube.running; + } + + render(); + + window.setInterval(() => { + if (!match) { + return; + } + + if (!match.result && match.phase !== "cube") { + persistMatch(); + navigateTo("chrono.html"); + return; + } + + render(); + }, 100); } function createMatch(config) { const quota = PRESETS[config.preset].quota; - const match = { + const newMatch = { + schemaVersion: 2, config, phase: "block", running: false, + lastTickAt: null, blockNumber: 1, currentTurn: "white", blockRemainingMs: BLOCK_DURATION_MS, @@ -231,12 +797,13 @@ function createMatch(config) { white: 0, black: 0, }, - clocks: config.mode === "time" - ? { - white: TIME_MODE_INITIAL_CLOCK_MS, - black: TIME_MODE_INITIAL_CLOCK_MS, - } - : null, + clocks: + config.mode === "time" + ? { + white: TIME_MODE_INITIAL_CLOCK_MS, + black: TIME_MODE_INITIAL_CLOCK_MS, + } + : null, lastMover: null, awaitingBlockClosure: false, closureReason: "", @@ -261,830 +828,597 @@ function createMatch(config) { history: [], }; - logEvent( - `Match créé en mode ${MODES[config.mode].label}, cadence ${PRESETS[config.preset].label}.`, - match, - ); - logEvent("Les Blancs commencent le block 1.", match); - - return match; + logEvent(newMatch, `Match cree en mode ${MODES[config.mode].label}, cadence ${PRESETS[config.preset].label}.`); + logEvent(newMatch, "Les Blancs commencent le block 1."); + return newMatch; } -function restoreState() { +function readStoredMatch() { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) { return null; } + const parsed = JSON.parse(raw); - return parsed && typeof parsed === "object" ? parsed : null; + if (!parsed || parsed.schemaVersion !== 2) { + return null; + } + + return parsed; } catch { return null; } } -function hydrateRecoveredState() { - if (!state.match) { - return; +function normalizeRecoveredMatch(storedMatch) { + if (storedMatch.phase === "block" && typeof storedMatch.lastTickAt !== "number") { + storedMatch.lastTickAt = null; } - if (state.match.running) { - state.match.running = false; - state.match.awaitingBlockClosure = false; - state.match.closureReason = ""; - state.match.moveRemainingMs = Math.max(state.match.moveRemainingMs, 0); + if (!storedMatch.cube) { + storedMatch.cube = { + number: null, + running: false, + startedAt: null, + elapsedMs: 0, + times: { white: null, black: null }, + round: 1, + history: [], + }; } - if (state.match.cube?.running) { - state.match.cube.running = false; - state.match.cube.startedAt = null; + if (!storedMatch.doubleCoup) { + storedMatch.doubleCoup = { + eligible: false, + step: 0, + starter: "white", + }; } } -function renderSetupSummary() { - const formData = new FormData(refs.setupForm); - const mode = formData.get("mode") || "twice"; - const preset = formData.get("preset") || "fast"; - const quota = PRESETS[preset].quota; - - refs.setupSummary.innerHTML = ` - ${MODES[mode].label} - ${PRESETS[preset].description} - Chaque block dure 180 secondes et chaque coup est limité à 20 secondes. - ${ - mode === "time" - ? "Chronos cumulés de 10 minutes par joueur, ajustés par les temps cube avec plafond de 120 s pris en compte." - : "Le gagnant du cube ouvre le block suivant, avec double coup éventuel selon la règle V2." - } - Quota actif : ${quota} coups par joueur et par block. - `; - - refs.heroModeHint.textContent = MODES[mode].subtitle; -} - -function startTicker() { - window.setInterval(() => { - if (!state.match || state.match.result) { - return; - } - - syncRunningTimers(); - render(); - }, 100); -} - -function startBlock() { - if (!state.match || state.match.result) { - return; +function syncRunningState(storedMatch) { + if (!storedMatch || storedMatch.result) { + return false; } - if (state.match.phase !== "block") { - return; - } - - state.match.running = true; - state.match.awaitingBlockClosure = false; - state.match.closureReason = ""; - state.lastTickAt = Date.now(); - - const intro = - state.match.blockNumber === 1 && state.match.moves.white === 0 && state.match.moves.black === 0 - ? "Block 1 démarré." - : `Block ${state.match.blockNumber} relancé.`; - - logEvent(intro); - persistState(); - render(); -} - -function pauseBlock() { - if (!state.match || !state.match.running) { - return; - } - - syncRunningTimers(); - state.match.running = false; - logEvent(`Block ${state.match.blockNumber} mis en pause.`); - persistState(); - render(); -} - -function syncRunningTimers() { - if (!state.match || !state.match.running || state.match.phase !== "block") { - state.lastTickAt = Date.now(); - return; + if (!storedMatch.running || storedMatch.phase !== "block" || !storedMatch.lastTickAt) { + return false; } const now = Date.now(); - const delta = now - (state.lastTickAt || now); - state.lastTickAt = now; - + const delta = now - storedMatch.lastTickAt; if (delta <= 0) { - return; + return false; } - state.match.blockRemainingMs = Math.max(0, state.match.blockRemainingMs - delta); - state.match.moveRemainingMs = Math.max(0, state.match.moveRemainingMs - delta); + storedMatch.lastTickAt = now; + storedMatch.blockRemainingMs = Math.max(0, storedMatch.blockRemainingMs - delta); + storedMatch.moveRemainingMs = Math.max(0, storedMatch.moveRemainingMs - delta); - if (state.match.clocks) { - state.match.clocks[state.match.currentTurn] -= delta; + if (storedMatch.clocks) { + storedMatch.clocks[storedMatch.currentTurn] -= delta; } - if (state.match.blockRemainingMs === 0) { - requestBlockClosure("Les 180 secondes du block sont écoulées."); - return; + if (storedMatch.blockRemainingMs === 0) { + requestBlockClosure(storedMatch, "Les 180 secondes du block sont ecoulees."); + } else if (storedMatch.moveRemainingMs === 0) { + registerMoveTimeout(storedMatch, true); } - if (state.match.moveRemainingMs === 0) { - registerMoveTimeout(true); - } + return true; } -function requestBlockClosure(reason) { - if (!state.match || state.match.awaitingBlockClosure) { +function startBlock(storedMatch) { + if (!storedMatch || storedMatch.phase !== "block" || storedMatch.result) { return; } - state.match.running = false; - state.match.awaitingBlockClosure = true; - state.match.closureReason = reason; - state.match.moveRemainingMs = MOVE_LIMIT_MS; - logEvent(`${reason} Vérifier un éventuel échec à parer avant la phase cube.`); - persistState(); - render(); + storedMatch.running = true; + storedMatch.awaitingBlockClosure = false; + storedMatch.closureReason = ""; + storedMatch.lastTickAt = Date.now(); + logEvent( + storedMatch, + storedMatch.blockNumber === 1 && storedMatch.moves.white === 0 && storedMatch.moves.black === 0 + ? "Block 1 demarre." + : `Block ${storedMatch.blockNumber} relance.`, + ); } -function openCubePhase() { - if (!state.match) { +function pauseBlock(storedMatch) { + if (!storedMatch || !storedMatch.running) { return; } - state.match.phase = "cube"; - state.match.running = false; - state.match.awaitingBlockClosure = false; - state.match.closureReason = ""; - state.match.cube.number = pickCubeNumber(); - state.match.cube.running = false; - state.match.cube.startedAt = null; - state.match.cube.elapsedMs = 0; - state.match.cube.times = { white: null, black: null }; - state.match.cube.round = 1; - logEvent(`Phase cube ouverte. Cube n°${state.match.cube.number} désigné par l'application.`); - persistState(); - render(); + storedMatch.running = false; + storedMatch.lastTickAt = null; + logEvent(storedMatch, `Block ${storedMatch.blockNumber} mis en pause.`); } -function startCubePhase() { - if (!state.match || state.match.phase !== "cube" || state.match.result) { +function requestBlockClosure(storedMatch, reason) { + if (!storedMatch || storedMatch.awaitingBlockClosure) { return; } - if (!state.match.cube.number) { - state.match.cube.number = pickCubeNumber(); - } - - state.match.cube.running = true; - state.match.cube.startedAt = Date.now(); - state.match.cube.elapsedMs = 0; - logEvent(`Phase cube démarrée sur le cube n°${state.match.cube.number}.`); - persistState(); - render(); + storedMatch.running = false; + storedMatch.lastTickAt = null; + storedMatch.awaitingBlockClosure = true; + storedMatch.closureReason = reason; + storedMatch.moveRemainingMs = MOVE_LIMIT_MS; + logEvent(storedMatch, `${reason} Verifier si des coups hors quota sont encore necessaires avant la page cube.`); } -function captureCubeTime(color) { - if (!state.match || state.match.phase !== "cube" || !state.match.cube.running) { +function openCubePhase(storedMatch) { + if (!storedMatch) { return; } - if (state.match.cube.times[color] !== null) { + storedMatch.phase = "cube"; + storedMatch.running = false; + storedMatch.lastTickAt = null; + storedMatch.awaitingBlockClosure = false; + storedMatch.closureReason = ""; + storedMatch.cube.number = pickCubeNumber(); + storedMatch.cube.running = false; + storedMatch.cube.startedAt = null; + storedMatch.cube.elapsedMs = 0; + storedMatch.cube.times = { white: null, black: null }; + storedMatch.cube.round = 1; + logEvent(storedMatch, `Page cube ouverte. Cube n${storedMatch.cube.number} designe.`); +} + +function startCubePhase(storedMatch) { + if (!storedMatch || storedMatch.phase !== "cube" || storedMatch.result) { return; } - const elapsedMs = Date.now() - state.match.cube.startedAt; - state.match.cube.times[color] = elapsedMs; - state.match.cube.elapsedMs = Math.max(state.match.cube.elapsedMs, elapsedMs); - logEvent(`${playerLabel(color)} arrêté en ${formatStopwatch(elapsedMs)} sur la phase cube.`); + if (!storedMatch.cube.number) { + storedMatch.cube.number = pickCubeNumber(); + } + + storedMatch.cube.running = true; + storedMatch.cube.startedAt = Date.now(); + storedMatch.cube.elapsedMs = 0; + logEvent(storedMatch, `Phase cube demarree sur le cube n${storedMatch.cube.number}.`); +} + +function captureCubeTime(storedMatch, color) { + if ( + !storedMatch || + storedMatch.phase !== "cube" || + !storedMatch.cube.running || + storedMatch.cube.times[color] !== null + ) { + return; + } + + const elapsedMs = Date.now() - storedMatch.cube.startedAt; + storedMatch.cube.times[color] = elapsedMs; + storedMatch.cube.elapsedMs = Math.max(storedMatch.cube.elapsedMs, elapsedMs); + logEvent(storedMatch, `${playerName(storedMatch, color)} arrete le cube en ${formatStopwatch(elapsedMs)}.`); if ( - state.match.cube.times.white !== null && - state.match.cube.times.black !== null + storedMatch.cube.times.white !== null && + storedMatch.cube.times.black !== null ) { - state.match.cube.running = false; - state.match.cube.history.push({ - blockNumber: state.match.blockNumber, - number: state.match.cube.number, - white: state.match.cube.times.white, - black: state.match.cube.times.black, + storedMatch.cube.running = false; + storedMatch.cube.history.push({ + blockNumber: storedMatch.blockNumber, + number: storedMatch.cube.number, + white: storedMatch.cube.times.white, + black: storedMatch.cube.times.black, }); } - - persistState(); - render(); } -function applyCubeOutcome() { - if (!state.match || state.match.phase !== "cube") { +function applyCubeOutcome(storedMatch) { + if (!storedMatch || storedMatch.phase !== "cube") { return; } - const { white, black } = state.match.cube.times; + const white = storedMatch.cube.times.white; + const black = storedMatch.cube.times.black; if (white === null || black === null) { return; } - if (state.match.config.mode === "twice") { - if (white === black) { - logEvent("Égalité parfaite sur la phase cube : le règlement impose de rejouer la phase."); - render(); - return; - } - + if (storedMatch.config.mode === "twice") { const winner = white < black ? "white" : "black"; - prepareNextTwiceBlock(winner); - } else { - applyTimeAdjustments(white, black); - prepareNextTimeBlock(); - } - - persistState(); - render(); -} - -function prepareNextTwiceBlock(winner) { - if (!state.match) { + prepareNextTwiceBlock(storedMatch, winner); return; } - const hadDouble = state.match.lastMover !== winner && state.match.lastMover !== null; - logEvent(`${playerLabel(winner)} gagne la phase cube et commencera le block suivant.`); + applyTimeAdjustments(storedMatch, white, black); + prepareNextTimeBlock(storedMatch); +} - state.match.blockNumber += 1; - state.match.phase = "block"; - state.match.running = false; - state.match.blockRemainingMs = BLOCK_DURATION_MS; - state.match.moveRemainingMs = MOVE_LIMIT_MS; - state.match.moves = { white: 0, black: 0 }; - state.match.currentTurn = winner; - state.match.doubleCoup = { +function prepareNextTwiceBlock(storedMatch, winner) { + const hadDouble = storedMatch.lastMover !== winner && storedMatch.lastMover !== null; + logEvent(storedMatch, `${playerName(storedMatch, winner)} gagne la phase cube et ouvrira le block suivant.`); + + storedMatch.blockNumber += 1; + storedMatch.phase = "block"; + storedMatch.running = false; + storedMatch.lastTickAt = null; + storedMatch.blockRemainingMs = BLOCK_DURATION_MS; + storedMatch.moveRemainingMs = MOVE_LIMIT_MS; + storedMatch.moves = { white: 0, black: 0 }; + storedMatch.currentTurn = winner; + storedMatch.doubleCoup = { eligible: hadDouble, step: hadDouble ? 1 : 0, starter: winner, }; - state.match.cube.running = false; - state.match.cube.startedAt = null; - state.match.cube.elapsedMs = 0; - state.match.cube.times = { white: null, black: null }; - state.match.cube.number = null; + storedMatch.cube.running = false; + storedMatch.cube.startedAt = null; + storedMatch.cube.elapsedMs = 0; + storedMatch.cube.times = { white: null, black: null }; + storedMatch.cube.number = null; if (hadDouble) { - logEvent( - `Double coup disponible pour ${playerLabel( - winner, - )} : premier coup gratuit sans échec, puis second coup compté.`, - ); + logEvent(storedMatch, `Double coup disponible pour ${playerName(storedMatch, winner)}.`); } else { - logEvent("Aucun double coup accordé sur ce départ."); + logEvent(storedMatch, "Aucun double coup n'est accorde sur ce depart."); } } -function prepareNextTimeBlock() { - if (!state.match) { - return; - } - - state.match.blockNumber += 1; - state.match.phase = "block"; - state.match.running = false; - state.match.blockRemainingMs = BLOCK_DURATION_MS; - state.match.moveRemainingMs = MOVE_LIMIT_MS; - state.match.moves = { white: 0, black: 0 }; - state.match.doubleCoup = { +function prepareNextTimeBlock(storedMatch) { + storedMatch.blockNumber += 1; + storedMatch.phase = "block"; + storedMatch.running = false; + storedMatch.lastTickAt = null; + storedMatch.blockRemainingMs = BLOCK_DURATION_MS; + storedMatch.moveRemainingMs = MOVE_LIMIT_MS; + storedMatch.moves = { white: 0, black: 0 }; + storedMatch.doubleCoup = { eligible: false, step: 0, - starter: state.match.currentTurn, + starter: storedMatch.currentTurn, }; - state.match.cube.running = false; - state.match.cube.startedAt = null; - state.match.cube.elapsedMs = 0; - state.match.cube.times = { white: null, black: null }; - state.match.cube.number = null; + storedMatch.cube.running = false; + storedMatch.cube.startedAt = null; + storedMatch.cube.elapsedMs = 0; + storedMatch.cube.times = { white: null, black: null }; + storedMatch.cube.number = null; logEvent( - `Block ${state.match.blockNumber} prêt. Le trait est conservé en mode Time : ${playerLabel( - state.match.currentTurn, + storedMatch, + `Block ${storedMatch.blockNumber} pret. Le trait est conserve : ${playerName( + storedMatch, + storedMatch.currentTurn, )} reprend.`, ); } -function applyTimeAdjustments(whiteTime, blackTime) { - if (!state.match || !state.match.clocks) { +function applyTimeAdjustments(storedMatch, whiteTime, blackTime) { + if (!storedMatch.clocks) { return; } - const blockType = getTimeBlockType(state.match.blockNumber); const cappedWhite = Math.min(whiteTime, CUBE_TIME_CAP_MS); const cappedBlack = Math.min(blackTime, CUBE_TIME_CAP_MS); + const blockType = getTimeBlockType(storedMatch.blockNumber); if (blockType === "minus") { - state.match.clocks.white -= cappedWhite; - state.match.clocks.black -= cappedBlack; + storedMatch.clocks.white -= cappedWhite; + storedMatch.clocks.black -= cappedBlack; logEvent( - `Bloc - : ${formatStopwatch(cappedWhite)} retiré au chrono Blanc, ${formatStopwatch( + storedMatch, + `Bloc - : ${formatStopwatch(cappedWhite)} retire au chrono Blanc, ${formatStopwatch( cappedBlack, - )} retiré au chrono Noir.`, + )} retire au chrono Noir.`, ); } else { - state.match.clocks.white += cappedBlack; - state.match.clocks.black += cappedWhite; + storedMatch.clocks.white += cappedBlack; + storedMatch.clocks.black += cappedWhite; logEvent( - `Bloc + : ${formatStopwatch(cappedBlack)} ajouté au chrono Blanc, ${formatStopwatch( + storedMatch, + `Bloc + : ${formatStopwatch(cappedBlack)} ajoute au chrono Blanc, ${formatStopwatch( cappedWhite, - )} ajouté au chrono Noir.`, + )} ajoute au chrono Noir.`, ); } } -function replayCubePhase() { - if (!state.match || state.match.phase !== "cube") { +function replayCubePhase(storedMatch) { + if (!storedMatch || storedMatch.phase !== "cube") { return; } - state.match.cube.running = false; - state.match.cube.startedAt = null; - state.match.cube.elapsedMs = 0; - state.match.cube.times = { white: null, black: null }; - state.match.cube.round += 1; - logEvent(`Phase cube relancée (tentative ${state.match.cube.round}).`); - persistState(); - render(); + storedMatch.cube.running = false; + storedMatch.cube.startedAt = null; + storedMatch.cube.elapsedMs = 0; + storedMatch.cube.times = { white: null, black: null }; + storedMatch.cube.round += 1; + logEvent(storedMatch, `Phase cube relancee (tentative ${storedMatch.cube.round}).`); } -function registerCountedMove({ label, source }) { - if (!state.match || state.match.phase !== "block") { +function registerCountedMove(storedMatch, { source }) { + if (!storedMatch || storedMatch.phase !== "block") { return; } - const actor = state.match.currentTurn; - if (state.match.moves[actor] >= state.match.quota) { + const actor = storedMatch.currentTurn; + if (storedMatch.moves[actor] >= storedMatch.quota) { logEvent( - `${playerLabel(actor)} a déjà atteint son quota de coups pour ce block. Utiliser un coup hors quota si nécessaire.`, + storedMatch, + `${playerName(storedMatch, actor)} a deja atteint son quota. Utiliser le mode hors quota si necessaire.`, ); - render(); return; } - state.match.moves[actor] += 1; - state.match.lastMover = actor; - state.match.moveRemainingMs = MOVE_LIMIT_MS; + storedMatch.moves[actor] += 1; + storedMatch.lastMover = actor; + storedMatch.moveRemainingMs = MOVE_LIMIT_MS; if (source === "double") { - state.match.doubleCoup.step = 0; + storedMatch.doubleCoup.step = 0; } logEvent( - `${label} ${playerLabel(actor)} est à ${state.match.moves[actor]} / ${state.match.quota}.`, + storedMatch, + `${playerName(storedMatch, actor)} valide son coup (${storedMatch.moves[actor]} / ${storedMatch.quota}).`, ); - state.match.currentTurn = opponentOf(actor); - verifyQuotaCompletion(); - persistState(); - render(); + storedMatch.currentTurn = opponentOf(actor); + verifyQuotaCompletion(storedMatch); } -function registerFreeDoubleMove() { - if (!state.match || state.match.doubleCoup.step !== 1) { +function registerFreeDoubleMove(storedMatch) { + if (!storedMatch || storedMatch.doubleCoup.step !== 1) { return; } - const actor = state.match.currentTurn; - state.match.lastMover = actor; - state.match.moveRemainingMs = MOVE_LIMIT_MS; - state.match.doubleCoup.step = 2; + const actor = storedMatch.currentTurn; + storedMatch.lastMover = actor; + storedMatch.moveRemainingMs = MOVE_LIMIT_MS; + storedMatch.doubleCoup.step = 2; logEvent( - `Premier coup gratuit du double coup joué par ${playerLabel( - actor, - )}. Rappel arbitre : il ne doit pas donner échec.`, + storedMatch, + `Premier coup gratuit du double coup joue par ${playerName(storedMatch, actor)}.`, ); - persistState(); - render(); } -function registerReliefMove() { - if (!state.match) { +function registerReliefMove(storedMatch) { + if (!storedMatch || storedMatch.phase !== "block") { return; } - const actor = state.match.currentTurn; - state.match.lastMover = actor; - state.match.currentTurn = opponentOf(actor); - state.match.moveRemainingMs = MOVE_LIMIT_MS; + const actor = storedMatch.currentTurn; + storedMatch.lastMover = actor; + storedMatch.currentTurn = opponentOf(actor); + storedMatch.moveRemainingMs = MOVE_LIMIT_MS; logEvent( - `Coup hors quota joué par ${playerLabel( - actor, - )} pour gérer une situation arbitrale ou parer un échec.`, + storedMatch, + `Coup hors quota joue par ${playerName(storedMatch, actor)} avant la page cube.`, ); - persistState(); - render(); } -function registerMoveTimeout(fromTimer) { - if (!state.match) { +function registerMoveTimeout(storedMatch, automatic) { + if (!storedMatch || storedMatch.phase !== "block") { return; } - const actor = state.match.currentTurn; + const actor = storedMatch.currentTurn; - if (state.match.doubleCoup.step === 1) { - state.match.doubleCoup.step = 0; - state.match.moveRemainingMs = MOVE_LIMIT_MS; + if (storedMatch.doubleCoup.step === 1) { + storedMatch.doubleCoup.step = 0; + storedMatch.moveRemainingMs = MOVE_LIMIT_MS; logEvent( - `Dépassement sur le premier coup gratuit de ${playerLabel( - actor, - )} : le double coup est annulé, mais le joueur conserve son coup normal.`, + storedMatch, + `Depassement sur le premier coup gratuit de ${playerName(storedMatch, actor)} : le double coup est annule.`, ); - persistState(); - render(); return; } - if (state.match.moves[actor] < state.match.quota) { - state.match.moves[actor] += 1; + if (storedMatch.moves[actor] < storedMatch.quota) { + storedMatch.moves[actor] += 1; } - if (state.match.doubleCoup.step === 2) { - state.match.doubleCoup.step = 0; + if (storedMatch.doubleCoup.step === 2) { + storedMatch.doubleCoup.step = 0; } - state.match.currentTurn = opponentOf(actor); - state.match.moveRemainingMs = MOVE_LIMIT_MS; + storedMatch.currentTurn = opponentOf(actor); + storedMatch.moveRemainingMs = MOVE_LIMIT_MS; logEvent( - `${fromTimer ? "Temps par coup écoulé." : "Dépassement manuel 20 s."} ${playerLabel( + storedMatch, + `${automatic ? "Temps par coup ecoule." : "Depassement manuel 20 s."} ${playerName( + storedMatch, actor, - )} perd son coup, qui est comptabilisé dans le quota.`, + )} perd son coup, qui reste compte dans le quota.`, ); - verifyQuotaCompletion(); - persistState(); - render(); + verifyQuotaCompletion(storedMatch); } -function verifyQuotaCompletion() { - if (!state.match) { - return; - } - +function verifyQuotaCompletion(storedMatch) { if ( - state.match.moves.white >= state.match.quota && - state.match.moves.black >= state.match.quota + storedMatch.moves.white >= storedMatch.quota && + storedMatch.moves.black >= storedMatch.quota ) { - requestBlockClosure("Les deux joueurs ont atteint leur quota de coups."); + requestBlockClosure(storedMatch, "Les deux joueurs ont atteint leur quota de coups."); } } -function setResult(winner) { - if (!state.match || state.match.result) { +function setResult(storedMatch, winner) { + if (!storedMatch || storedMatch.result) { return; } - syncRunningTimers(); - state.match.running = false; - state.match.result = winner; - - if (winner === "white" || winner === "black") { - logEvent(`${playerLabel(winner)} remporte la partie par décision arbitrale, mat ou abandon.`); - } else { - logEvent("La partie est arrêtée par abandon ou décision arbitrale."); - } - - persistState(); - render(); + storedMatch.running = false; + storedMatch.lastTickAt = null; + storedMatch.result = winner; + logEvent(storedMatch, `${resultText(storedMatch)}.`); } -function resetMatch() { - localStorage.removeItem(STORAGE_KEY); - state.match = null; - refs.livePanel.classList.add("hidden"); - render(); +function renderModeContext(storedMatch) { + if (storedMatch.config.mode === "time") { + return getTimeBlockType(storedMatch.blockNumber) === "minus" + ? "Bloc - : temps cube retire a son propre chrono" + : "Bloc + : temps cube ajoute au chrono adverse"; + } + + if (storedMatch.doubleCoup.step === 1) { + return `Double coup actif pour ${playerName(storedMatch, storedMatch.doubleCoup.starter)}`; + } + + if (storedMatch.doubleCoup.step === 2) { + return "Deuxieme coup du double en attente"; + } + + return "Le gagnant du cube ouvrira le prochain block"; } -function pickCubeNumber() { - return Math.floor(Math.random() * 4) + 1; -} - -function render() { - const match = state.match; - refs.livePanel.classList.toggle("hidden", !match); - - if (!match) { - return; - } - - refs.matchTitle.textContent = match.config.matchLabel; - refs.modeLabel.textContent = MODES[match.config.mode].label; - refs.phaseBadge.textContent = renderPhaseBadge(match); - refs.blockLabel.textContent = `Block ${match.blockNumber}`; - refs.blockMeta.textContent = renderBlockMeta(match); - refs.blockTimer.textContent = formatClock(match.blockRemainingMs); - refs.moveTimer.textContent = formatClock(match.moveRemainingMs); - refs.blockStatusText.textContent = renderBlockStatus(match); - refs.turnLabel.textContent = `Trait : ${playerLabel(match.currentTurn)}`; - - refs.whiteNameDisplay.textContent = match.config.whiteName; - refs.blackNameDisplay.textContent = match.config.blackName; - refs.whiteMoveCount.textContent = `${match.moves.white} / ${match.quota} coups`; - refs.blackMoveCount.textContent = `${match.moves.black} / ${match.quota} coups`; - - refs.whiteClockLabel.textContent = renderClockLabel(match, "white"); - refs.blackClockLabel.textContent = renderClockLabel(match, "black"); - refs.whiteCubeLabel.textContent = renderLastCubeLabel(match, "white"); - refs.blackCubeLabel.textContent = renderLastCubeLabel(match, "black"); - - refs.whiteCard.classList.toggle("active", match.currentTurn === "white" && !match.result); - refs.blackCard.classList.toggle("active", match.currentTurn === "black" && !match.result); - - refs.startPauseButton.textContent = - match.phase === "block" && match.running ? "Mettre en pause" : "Démarrer le block"; - refs.startPauseButton.disabled = match.phase !== "block" || Boolean(match.result); - refs.confirmBlockButton.disabled = match.phase !== "block" || Boolean(match.result); - refs.confirmBlockButton.textContent = match.awaitingBlockClosure - ? "Ouvrir la phase cube" - : "Clore le block"; - - refs.moveActionButton.disabled = - match.phase !== "block" || !match.running || Boolean(match.result); - refs.moveActionButton.textContent = renderMoveButtonLabel(match); - - refs.reliefMoveButton.disabled = match.phase !== "block" || Boolean(match.result); - refs.timeoutMoveButton.disabled = match.phase !== "block" || Boolean(match.result); - refs.switchTurnButton.disabled = Boolean(match.result); - - refs.contextNotice.innerHTML = `Contexte du moment
${renderContextNotice( - match, - )}`; - refs.doubleCard.innerHTML = renderDoubleCard(match); - - refs.cubeNumber.textContent = match.cube.number || "-"; - refs.cubeElapsed.textContent = renderCubeElapsed(match); - refs.cubeStatusText.textContent = renderCubeStatus(match); - refs.startCubeButton.disabled = - match.phase !== "cube" || match.cube.running || Boolean(match.result); - refs.captureWhiteCubeButton.disabled = - match.phase !== "cube" || !match.cube.running || match.cube.times.white !== null; - refs.captureBlackCubeButton.disabled = - match.phase !== "cube" || !match.cube.running || match.cube.times.black !== null; - refs.applyCubeButton.disabled = - match.phase !== "cube" || - match.cube.times.white === null || - match.cube.times.black === null || - Boolean(match.result); - refs.redoCubeButton.disabled = match.phase !== "cube" || Boolean(match.result); - - refs.whiteCubeResult.textContent = - match.cube.times.white === null ? "--" : formatStopwatch(match.cube.times.white); - refs.blackCubeResult.textContent = - match.cube.times.black === null ? "--" : formatStopwatch(match.cube.times.black); - refs.whiteCubeCap.textContent = renderCubeCap(match.cube.times.white); - refs.blackCubeCap.textContent = renderCubeCap(match.cube.times.black); - - refs.historyList.innerHTML = [...match.history] - .slice(-18) - .reverse() - .map( - (item) => ` -
  • - ${escapeHtml(item.time)} - ${escapeHtml(item.message)} -
  • - `, - ) - .join(""); -} - -function renderPhaseBadge(match) { - if (match.result) { - return "Partie terminée"; - } - - if (match.phase === "cube") { - return "Phase cube"; - } - - if (match.awaitingBlockClosure) { - return "Fin de block à confirmer"; - } - - return match.running ? "Block en cours" : "Block prêt"; -} - -function renderBlockMeta(match) { - const preset = PRESETS[match.config.preset]; - if (match.config.mode === "time") { - const type = getTimeBlockType(match.blockNumber) === "minus" ? "Bloc -" : "Bloc +"; - return `${type} • ${preset.label} • ${preset.quota} coups par joueur • Le trait est conservé après le cube.`; - } - - return `${preset.label} • ${preset.quota} coups par joueur • Le gagnant du cube démarrera le block suivant.`; -} - -function renderBlockStatus(match) { - if (match.result) { - return "La rencontre est archivée dans l'historique."; - } - - if (match.phase === "cube") { - return "Le jeu d'échecs est suspendu, aucun coup n'est autorisé."; - } - - if (match.awaitingBlockClosure) { - return match.closureReason; - } - - return match.running - ? "Le block tourne. Utiliser les commandes d'arbitrage à chaque coup." - : "Le block est prêt, les chronos sont arrêtés."; -} - -function renderClockLabel(match, color) { - if (!match.clocks) { - return "Mode Twice : pas de chrono cumulé."; - } - - return `Chrono cumulé : ${formatSignedClock(match.clocks[color])}`; -} - -function renderLastCubeLabel(match, color) { - const last = match.cube.history.at(-1); +function renderLastCube(storedMatch, color) { + const last = storedMatch.cube.history.at(-1); if (!last) { - return "Dernière phase cube : --"; + return "--"; } - return `Dernière phase cube : ${formatStopwatch(last[color])}`; + return formatStopwatch(last[color]); } -function renderMoveButtonLabel(match) { - if (match.doubleCoup.step === 1) { - return "1er coup gratuit du double"; - } - - if (match.doubleCoup.step === 2) { - return "2e coup du double"; - } - - return "Coup compté"; -} - -function renderContextNotice(match) { - if (match.result) { - return "Le match est terminé. L'historique conserve les grandes étapes pour une reprise ultérieure."; - } - - if (match.phase === "cube") { - if (match.config.mode === "twice") { - return "Le gagnant du cube déterminera le départ du block suivant. En cas d'égalité parfaite, la phase doit être rejouée."; - } - - const type = getTimeBlockType(match.blockNumber) === "minus" ? "Bloc -" : "Bloc +"; - return `${type} : le temps cube sera ${ - type === "Bloc -" - ? "retiré du chrono du joueur concerné" - : "ajouté au chrono adverse" - }, avec plafond pris en compte de 120 secondes.`; - } - - if (match.awaitingBlockClosure) { - return "Le block est théoriquement terminé. Si un roi est en échec, jouer les coups nécessaires hors quota, puis ouvrir la phase cube."; - } - - if (match.config.mode === "time") { - return "Mode Time : la partie ne se termine jamais au temps. Les chronos cumulés servent de ressource stratégique, mais seule la victoire par mat ou abandon compte."; - } - - return "Mode Twice : surveiller la fin du block, la victoire en cube, et la règle du double coup V2 pour le départ suivant."; -} - -function renderDoubleCard(match) { - if (match.config.mode !== "twice") { - return "Mode TimeAucun système de priorité ou de double coup n'existe dans cette variante."; - } - - if (!match.doubleCoup.eligible && match.doubleCoup.step === 0) { - return "Double coupPas de double coup actif pour ce départ."; - } - - if (match.doubleCoup.step === 1) { - return `Double coup actif pour ${escapeHtml( - playerLabel(match.doubleCoup.starter), - )}Premier coup gratuit, non compté, sans échec autorisé. Utiliser le bouton principal pour l'enregistrer.`; - } - - if (match.doubleCoup.step === 2) { - return `Deuxième coup du doubleCe coup compte comme premier coup du block. Capture autorisée uniquement sur pion ou pièce mineure. L'échec redevient autorisé.`; - } - - return "Double coupLe départ est standard sur ce block."; -} - -function renderCubeElapsed(match) { - if (match.phase !== "cube") { +function renderCubeElapsed(storedMatch) { + if (storedMatch.phase !== "cube") { return "00:00.0"; } - if (match.cube.running) { - const live = Date.now() - match.cube.startedAt; - return formatStopwatch(live); + if (storedMatch.cube.running) { + return formatStopwatch(Date.now() - storedMatch.cube.startedAt); } - if (match.cube.elapsedMs > 0) { - return formatStopwatch(match.cube.elapsedMs); + if (storedMatch.cube.elapsedMs > 0) { + return formatStopwatch(storedMatch.cube.elapsedMs); } return "00:00.0"; } -function renderCubeStatus(match) { - if (match.phase !== "cube") { - return "La phase cube se déclenche à la fin du block."; - } - - if (match.cube.running) { - return "Les deux joueurs sont en résolution. Arrêter chaque côté au moment de la fin."; - } - - if (match.cube.times.white !== null && match.cube.times.black !== null) { - if (match.config.mode === "twice" && match.cube.times.white === match.cube.times.black) { - return "Égalité parfaite : le règlement impose de rejouer immédiatement la phase cube."; - } - - return "Les deux temps sont saisis. Appliquer l'issue pour préparer le block suivant."; - } - - return "Lancer la phase cube puis capturer chaque fin de résolution."; -} - -function renderCubeCap(time) { +function renderCubeCap(storedMatch, time) { if (time === null) { return ""; } - if (!state.match || state.match.config.mode !== "time") { - return ""; + if (storedMatch.config.mode !== "time") { + return "temps capture"; } if (time <= CUBE_TIME_CAP_MS) { return "plafond non atteint"; } - return `pris en compte : ${formatStopwatch(CUBE_TIME_CAP_MS)}`; + return `pris en compte ${formatStopwatch(CUBE_TIME_CAP_MS)}`; } -function formatClock(ms) { - const totalSeconds = Math.floor(ms / 1000); - const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, "0"); - const seconds = String(totalSeconds % 60).padStart(2, "0"); - return `${minutes}:${seconds}`; -} - -function formatSignedClock(ms) { - const negative = ms < 0; - const absolute = Math.abs(ms); - const totalSeconds = Math.floor(absolute / 1000); - const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, "0"); - const seconds = String(totalSeconds % 60).padStart(2, "0"); - return `${negative ? "-" : ""}${minutes}:${seconds}`; -} - -function formatStopwatch(ms) { - const totalSeconds = Math.floor(ms / 1000); - const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, "0"); - const seconds = String(totalSeconds % 60).padStart(2, "0"); - const tenths = Math.floor((ms % 1000) / 100); - return `${minutes}:${seconds}.${tenths}`; -} - -function getTimeBlockType(blockNumber) { - return blockNumber % 2 === 1 ? "minus" : "plus"; -} - -function playerLabel(color) { - if (!state.match) { - return color === "white" ? "Blanc" : "Noir"; +function resultText(storedMatch) { + if (storedMatch.result === "white") { + return `Victoire de ${playerName(storedMatch, "white")}`; } - return color === "white" ? state.match.config.whiteName : state.match.config.blackName; + if (storedMatch.result === "black") { + return `Victoire de ${playerName(storedMatch, "black")}`; + } + + return "Partie arretee"; +} + +function routeForMatch(storedMatch) { + if (!storedMatch) { + return "index.html"; + } + + return storedMatch.phase === "cube" && !storedMatch.result ? "cube.html" : "chrono.html"; +} + +function navigateTo(target) { + persistMatch(); + window.location.href = target; +} + +function replaceTo(target) { + window.location.replace(target); +} + +function persistMatch() { + if (!match) { + localStorage.removeItem(STORAGE_KEY); + dirty = false; + return; + } + + localStorage.setItem(STORAGE_KEY, JSON.stringify(match)); + dirty = false; +} + +function flushState() { + if (match) { + syncRunningState(match); + } + + if (dirty) { + persistMatch(); + } +} + +function clearMatch() { + match = null; + persistMatch(); +} + +function toggleModal(element, open) { + if (!element) { + return; + } + + element.classList.toggle("hidden", !open); + element.setAttribute("aria-hidden", String(!open)); +} + +function loadDemo(form, onRender) { + form.elements.matchLabel.value = "Demo officielle ChessCubing"; + setRadioValue(form, "mode", "twice"); + setRadioValue(form, "preset", "freeze"); + form.elements.whiteName.value = "Nora"; + form.elements.blackName.value = "Leo"; + form.elements.arbiterName.value = "Arbitre demo"; + form.elements.eventName.value = "Session telephone"; + form.elements.notes.value = "8 cubes verifies, variante prete, tirage au sort effectue."; + onRender(); +} + +function getRadioValue(form, name) { + const selected = form.querySelector(`input[name="${name}"]:checked`); + return selected ? selected.value : ""; +} + +function setRadioValue(form, name, value) { + const input = form.querySelector(`input[name="${name}"][value="${value}"]`); + if (input) { + input.checked = true; + } +} + +function playerName(storedMatch, color) { + return color === "white" ? storedMatch.config.whiteName : storedMatch.config.blackName; } function opponentOf(color) { return color === "white" ? "black" : "white"; } -function logEvent(message, targetMatch = state.match) { - if (!targetMatch) { - return; - } +function getTimeBlockType(blockNumber) { + return blockNumber % 2 === 1 ? "minus" : "plus"; +} - const now = new Date(); - targetMatch.history.push({ +function pickCubeNumber() { + return Math.floor(Math.random() * 4) + 1; +} + +function logEvent(storedMatch, message) { + storedMatch.history.push({ message, - time: now.toLocaleTimeString("fr-FR", { + time: new Date().toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit", second: "2-digit", @@ -1092,21 +1426,27 @@ function logEvent(message, targetMatch = state.match) { }); } -function persistState() { - if (!state.match) { - localStorage.removeItem(STORAGE_KEY); - return; - } - - localStorage.setItem(STORAGE_KEY, JSON.stringify(state.match)); +function formatClock(ms) { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)); + const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, "0"); + const seconds = String(totalSeconds % 60).padStart(2, "0"); + return `${minutes}:${seconds}`; } -function escapeHtml(value) { - return String(value) - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """); +function formatSignedClock(ms) { + const negative = ms < 0 ? "-" : ""; + const totalSeconds = Math.floor(Math.abs(ms) / 1000); + const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, "0"); + const seconds = String(totalSeconds % 60).padStart(2, "0"); + return `${negative}${minutes}:${seconds}`; +} + +function formatStopwatch(ms) { + const totalSeconds = Math.floor(Math.abs(ms) / 1000); + const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, "0"); + const seconds = String(totalSeconds % 60).padStart(2, "0"); + const tenths = Math.floor((Math.abs(ms) % 1000) / 100); + return `${minutes}:${seconds}.${tenths}`; } function sanitizeText(value) { @@ -1116,14 +1456,11 @@ function sanitizeText(value) { .replace(/\s+/g, " "); } -function loadDemo() { - refs.setupForm.matchLabel.value = "Démo officielle ChessCubing"; - refs.setupForm.mode.value = "twice"; - refs.setupForm.preset.value = "freeze"; - refs.setupForm.whiteName.value = "Nora"; - refs.setupForm.blackName.value = "Léo"; - refs.setupForm.arbiterName.value = "Arbitre demo"; - refs.setupForm.eventName.value = "Session tablette"; - refs.setupForm.notes.value = "8 cubes vérifiés, variantes prêtes, tirage au sort effectué."; - renderSetupSummary(); +function escapeHtml(value) { + return String(value || "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); } diff --git a/chrono.html b/chrono.html new file mode 100644 index 0000000..0e8b294 --- /dev/null +++ b/chrono.html @@ -0,0 +1,143 @@ + + + + + + + ChessCubing Arena | Phase Chrono + + + +
    +
    + Configuration +
    +

    Phase chrono

    +

    Block 1

    +

    +
    + +
    + +
    +
    + Temps block + 03:00 +
    +
    + Temps coup + 00:20 +
    +
    + Trait + Blanc +
    +
    + +
    +
    +
    +
    +
    + Noir +

    Noir

    +
    +
    + 0 / 6 + +
    +
    + + + +

    +
    +
    + +
    +
    +

    État du block

    + Prêt à démarrer +

    +
    + + +
    + +
    +
    +
    +
    + Blanc +

    Blanc

    +
    +
    + 0 / 6 + +
    +
    + + + +

    +
    +
    +
    +
    + + + + + + diff --git a/cube.html b/cube.html new file mode 100644 index 0000000..c58b424 --- /dev/null +++ b/cube.html @@ -0,0 +1,127 @@ + + + + + + + ChessCubing Arena | Phase Cube + + + +
    +
    + Configuration +
    +

    Phase cube

    +

    Cube n°1

    +

    +
    + +
    + +
    +
    + Block + 1 +
    +
    + Chrono cube + 00:00.0 +
    +
    + État + Prêt +
    +
    + +
    +
    +
    +
    +
    + Noir +

    Noir

    +
    +
    + -- + +
    +
    + + + +

    +
    +
    + +
    +
    +

    Blocage

    + Lancer la phase cube +

    +
    + + +
    + +
    +
    +
    +
    + Blanc +

    Blanc

    +
    +
    + -- + +
    +
    + + + +

    +
    +
    +
    +
    + + + + + + diff --git a/index.html b/index.html index 65b4bb4..f5aff09 100644 --- a/index.html +++ b/index.html @@ -5,72 +5,62 @@ - ChessCubing Arena + ChessCubing Arena | Configuration - +
    -
    -
    + +
    +

    Application officielle de match

    ChessCubing Arena

    - Une web app pensée pour l'arbitrage sur téléphone et tablette, - directement dérivée des règlements de - ChessCubing Twice et - ChessCubing Time. + Une version mobile et tablette organisée par phases : configuration, + page chrono ultra lisible, puis page cube dédiée.

    -
    - - -
    - Mobile-first + Pages séparées + Face-à-face téléphone Twice + Time - Arbitrage en direct - Fonctionne hors build + Arbitrage tactile
    -
    -
    +
    +
    -

    Préparer la rencontre

    -

    Configuration de match

    +

    Nouvelle rencontre

    +

    Configuration

    - Lancement rapide pour club, démo ou tournoi. Les choix pilotent le - tableau d'arbitrage en direct. + Les réglages ci-dessous préparent les pages chrono et cube.

    @@ -92,16 +82,16 @@ ChessCubing Twice - Le gagnant du cube commence le block suivant et peut obtenir - un double coup selon la règle V2. + Le gagnant du cube ouvre le block suivant et peut obtenir un + double coup V2.
    @@ -113,41 +103,29 @@
    -
    +
    -
    - + - - diff --git a/styles.css b/styles.css index 7568788..82e32d8 100644 --- a/styles.css +++ b/styles.css @@ -1,18 +1,20 @@ :root { - --bg: #07151f; - --bg-soft: rgba(12, 31, 42, 0.84); - --panel: rgba(10, 26, 37, 0.86); + --bg: #06131d; + --bg-soft: rgba(11, 26, 38, 0.88); + --panel: rgba(9, 22, 34, 0.9); --panel-border: rgba(255, 255, 255, 0.08); - --panel-highlight: rgba(255, 193, 124, 0.22); - --text: #eef5f2; - --muted: #97adb0; - --warm: #ffb86c; - --warm-strong: #ff8f3c; - --cool: #5de2d8; - --cool-strong: #23bdb0; - --danger: #ff6b6b; + --text: #edf6f3; + --muted: #9bb1b6; + --warm: #ffbd6d; + --warm-strong: #ff8f37; + --cool: #64dfd4; + --cool-strong: #1bbcae; + --white-seat: #ffd9b0; + --dark-seat: #baf5ef; + --danger: #ff7272; + --success: #8fe388; --shadow: 0 24px 70px rgba(0, 0, 0, 0.35); - --radius: 26px; + --radius: 28px; } *, @@ -31,9 +33,9 @@ body { font-family: "Avenir Next", "Segoe UI", sans-serif; color: var(--text); background: - radial-gradient(circle at top left, rgba(255, 184, 108, 0.15), transparent 30%), - radial-gradient(circle at bottom right, rgba(93, 226, 216, 0.16), transparent 26%), - linear-gradient(160deg, #030c12 0%, #07151f 46%, #0a2331 100%); + radial-gradient(circle at top left, rgba(255, 189, 109, 0.16), transparent 28%), + radial-gradient(circle at bottom right, rgba(100, 223, 212, 0.18), transparent 26%), + linear-gradient(165deg, #020a10 0%, #07141d 45%, #0a2433 100%); } body::before { @@ -42,79 +44,46 @@ body::before { inset: 0; pointer-events: none; background-image: - linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px), - linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px); - background-size: 42px 42px; - mask-image: radial-gradient(circle at center, black 48%, transparent 100%); + linear-gradient(rgba(255, 255, 255, 0.025) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.025) 1px, transparent 1px); + background-size: 40px 40px; + mask-image: radial-gradient(circle at center, black 50%, transparent 100%); } .ambient { position: fixed; - width: 28rem; - height: 28rem; + width: 30rem; + height: 30rem; border-radius: 50%; - filter: blur(70px); + filter: blur(80px); opacity: 0.22; pointer-events: none; } .ambient-left { - top: -10rem; + top: -12rem; left: -8rem; background: var(--warm-strong); } .ambient-right { - right: -10rem; - bottom: -8rem; + right: -12rem; + bottom: -9rem; background: var(--cool-strong); } -.layout { - position: relative; - width: min(1200px, calc(100% - 2rem)); - margin: 0 auto; - padding: 1.2rem 0 2.4rem; +a { + color: var(--warm); + text-decoration: none; } -.hero, -.panel, -.footer { - animation: rise 0.7s ease both; -} - -.hero { - display: grid; - grid-template-columns: minmax(0, 1.35fr) minmax(280px, 0.95fr); - gap: 1.2rem; - align-items: stretch; - margin-bottom: 1.2rem; -} - -.hero-copy, -.hero-preview, -.panel, -.panel.inset { - border: 1px solid var(--panel-border); - border-radius: calc(var(--radius) + 4px); - background: var(--panel); - backdrop-filter: blur(18px); - box-shadow: var(--shadow); -} - -.hero-copy { - padding: 2rem; -} - -.hero-preview { - padding: 1.4rem; - display: grid; - gap: 1rem; +a:hover { + text-decoration: underline; } .eyebrow, .micro-label { - margin: 0 0 0.4rem; + margin: 0 0 0.45rem; text-transform: uppercase; letter-spacing: 0.18em; color: var(--cool); @@ -132,95 +101,175 @@ strong { font-family: "Baskerville", "Georgia", serif; } -h1 { - margin: 0; - font-size: clamp(2.8rem, 7vw, 4.8rem); - line-height: 0.96; -} - +h1, h2, -h3 { - margin: 0; +h3, +p { + margin-top: 0; } .lead, .section-copy, .preview-banner p, .rule-card p, -.preview-list, -.footer p { +.phase-subtitle, +.zone-foot, +.resume-card, +.setup-summary, +.modal-card p { color: var(--muted); } -.hero-actions, -.setup-actions, -.action-grid, -.result-grid, -.capture-grid { - display: flex; - flex-wrap: wrap; - gap: 0.75rem; +.button { + appearance: none; + border: 0; + border-radius: 18px; + padding: 0.95rem 1.15rem; + font: inherit; + font-weight: 700; + cursor: pointer; + transition: transform 160ms ease, filter 160ms ease, background 160ms ease; + color: var(--text); } -.hero-actions { - margin: 1.6rem 0 1rem; +.button:hover { + transform: translateY(-2px); + filter: brightness(1.05); +} + +.button:disabled { + opacity: 0.45; + cursor: not-allowed; + transform: none; +} + +.button.primary { + background: linear-gradient(135deg, var(--warm-strong), var(--warm)); + color: #20150a; +} + +.button.secondary { + background: linear-gradient(135deg, rgba(100, 223, 212, 0.18), rgba(100, 223, 212, 0.08)); + border: 1px solid rgba(100, 223, 212, 0.2); +} + +.button.ghost { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.09); +} + +.button.danger { + color: #ffd8d8; + border-color: rgba(255, 114, 114, 0.3); +} + +.button.small { + padding: 0.7rem 0.9rem; +} + +.setup-shell { + position: relative; + width: min(1220px, calc(100% - 2rem)); + margin: 0 auto; + padding: 1.2rem 0 2rem; +} + +.hero, +.panel { + border: 1px solid var(--panel-border); + border-radius: calc(var(--radius) + 4px); + background: var(--panel); + backdrop-filter: blur(20px); + box-shadow: var(--shadow); +} + +.hero { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(280px, 0.9fr); + gap: 1.2rem; + padding: 1.3rem; +} + +.hero-copy, +.hero-preview { + padding: 1rem; +} + +.hero-preview { + display: grid; + gap: 1rem; +} + +.hero-setup h1 { + margin: 0; + font-size: clamp(2.7rem, 6.5vw, 4.9rem); + line-height: 0.95; } .hero-pills { display: flex; flex-wrap: wrap; gap: 0.65rem; + margin-top: 1.2rem; } .hero-pills span, .mini-chip, -.status-badge { +.seat-tag { display: inline-flex; align-items: center; - gap: 0.4rem; - padding: 0.5rem 0.85rem; + justify-content: center; + padding: 0.42rem 0.78rem; border-radius: 999px; border: 1px solid rgba(255, 255, 255, 0.08); - background: rgba(255, 255, 255, 0.04); - color: var(--text); + background: rgba(255, 255, 255, 0.05); } .preview-card, .preview-banner, -.notice-card, -.double-card, .rule-card, +.resume-card, .setup-summary, -.timer-card, -.player-card, -.cube-results > div { - padding: 1rem; - border-radius: 22px; +.status-card, +.spine-card, +.modal-card, +.zone-inner { + border-radius: 24px; + border: 1px solid rgba(255, 255, 255, 0.08); background: linear-gradient(160deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.02)); - border: 1px solid rgba(255, 255, 255, 0.07); } -.preview-list { +.preview-card, +.preview-banner, +.rule-card, +.resume-card, +.setup-summary, +.status-card, +.spine-card, +.modal-card { + padding: 1rem; +} + +.phase-list { margin: 0.8rem 0 0; - padding-left: 1rem; + padding-left: 1.1rem; + color: var(--muted); } -.preview-list li + li { +.phase-list li + li { margin-top: 0.45rem; } -.workspace { +.setup-grid { display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(300px, 0.82fr); gap: 1.2rem; + margin-top: 1.2rem; } .panel { - padding: 1.4rem; -} - -.panel.inset { - padding: 1.15rem; + padding: 1.35rem; } .section-heading { @@ -228,7 +277,7 @@ h3 { justify-content: space-between; gap: 1rem; align-items: flex-start; - margin-bottom: 1.2rem; + margin-bottom: 1.1rem; } .setup-form { @@ -246,18 +295,24 @@ h3 { grid-column: 1 / -1; } -legend, -.field > span { - font-weight: 600; +.field > span, +legend { + font-weight: 700; +} + +fieldset { + border: 0; + margin: 0; + padding: 0; } input, textarea { width: 100%; padding: 0.95rem 1rem; - border-radius: 16px; - border: 1px solid rgba(255, 255, 255, 0.08); - background: rgba(0, 0, 0, 0.18); + border-radius: 18px; + border: 1px solid rgba(255, 255, 255, 0.09); + background: rgba(0, 0, 0, 0.2); color: var(--text); font: inherit; } @@ -269,16 +324,10 @@ textarea { input:focus, textarea:focus { - outline: 2px solid rgba(93, 226, 216, 0.35); + outline: 2px solid rgba(100, 223, 212, 0.35); outline-offset: 1px; } -fieldset { - margin: 0; - padding: 0; - border: 0; -} - .option-grid { display: grid; gap: 0.85rem; @@ -296,351 +345,435 @@ fieldset { position: relative; display: grid; gap: 0.45rem; - min-height: 9rem; + min-height: 8.7rem; padding: 1rem 1rem 1rem 3rem; border-radius: 22px; border: 1px solid rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.03); cursor: pointer; - transition: transform 180ms ease, border-color 180ms ease, background 180ms ease; + transition: transform 160ms ease, border-color 160ms ease, background 160ms ease; } .option-card:hover { transform: translateY(-3px); - border-color: rgba(255, 184, 108, 0.35); + border-color: rgba(255, 189, 109, 0.3); } .option-card input { position: absolute; - top: 1.2rem; + top: 1.1rem; left: 1rem; width: 1.1rem; height: 1.1rem; } .option-card:has(input:checked) { + border-color: rgba(255, 189, 109, 0.55); background: - linear-gradient(160deg, rgba(255, 184, 108, 0.18), rgba(93, 226, 216, 0.08)); - border-color: rgba(255, 184, 108, 0.55); - box-shadow: inset 0 0 0 1px rgba(255, 184, 108, 0.14); -} - -.button { - appearance: none; - border: 0; - border-radius: 16px; - padding: 0.95rem 1.1rem; - font: inherit; - font-weight: 700; - cursor: pointer; - color: var(--text); - transition: transform 160ms ease, filter 160ms ease, background 160ms ease; -} - -.button:hover { - transform: translateY(-2px); - filter: brightness(1.04); -} - -.button:disabled { - opacity: 0.4; - cursor: not-allowed; - transform: none; -} - -.button.primary { - background: linear-gradient(135deg, var(--warm-strong), var(--warm)); - color: #1d140a; -} - -.button.secondary { - background: linear-gradient(135deg, rgba(93, 226, 216, 0.18), rgba(93, 226, 216, 0.08)); - border: 1px solid rgba(93, 226, 216, 0.25); -} - -.button.ghost { - background: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.08); -} - -.button.capture { - flex: 1 1 12rem; - background: linear-gradient(135deg, rgba(255, 184, 108, 0.16), rgba(255, 184, 108, 0.08)); - border: 1px solid rgba(255, 184, 108, 0.25); -} - -.button.danger { - border-color: rgba(255, 107, 107, 0.25); - color: #ffd7d7; -} - -.button.small { - padding: 0.7rem 0.95rem; + linear-gradient(160deg, rgba(255, 189, 109, 0.17), rgba(100, 223, 212, 0.08)); } .setup-summary { display: grid; - gap: 0.35rem; + gap: 0.38rem; +} + +.setup-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.side-panel { + display: grid; + gap: 1rem; + align-content: start; +} + +.resume-card.empty { + text-align: center; +} + +.resume-card strong { + display: block; + margin-bottom: 0.35rem; +} + +.resume-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 0.95rem; +} + +.rules-stack { + display: grid; + gap: 0.95rem; +} + +.phase-body { + min-height: 100dvh; + overflow: hidden; +} + +.phase-shell { + position: relative; + width: min(1400px, calc(100% - 1.2rem)); + min-height: 100dvh; + margin: 0 auto; + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + gap: 0.9rem; + padding: 0.8rem 0 0.9rem; +} + +.phase-header { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 0.8rem; + align-items: center; +} + +.brand-link { + justify-self: start; + padding: 0.75rem 1rem; + border-radius: 18px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.05); + color: var(--text); +} + +.brand-link:hover { + text-decoration: none; +} + +.phase-title { + text-align: center; +} + +.phase-title h1 { + margin: 0; + font-size: clamp(1.9rem, 4vw, 3rem); +} + +.phase-subtitle { + margin: 0.25rem 0 0; +} + +.utility-button { + justify-self: end; +} + +.status-strip { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.8rem; +} + +.status-card { + min-height: 6.4rem; + display: grid; + align-content: center; + gap: 0.28rem; + text-align: center; +} + +.status-card span { color: var(--muted); } -.live-grid { +.status-card strong { + font-size: clamp(1.6rem, 3.8vw, 2.7rem); + line-height: 0.94; +} + +.status-card.wide { + background: + linear-gradient(160deg, rgba(255, 189, 109, 0.14), rgba(100, 223, 212, 0.08)); +} + +.faceoff-board { display: grid; - grid-template-columns: 1.2fr 0.95fr; - gap: 1rem; + grid-template-columns: minmax(0, 1fr) minmax(280px, 340px) minmax(0, 1fr); + gap: 0.8rem; + min-height: 0; } -.live-grid > .panel.inset:nth-child(4), -.live-grid > .panel.inset:nth-child(5) { - grid-column: span 1; +.player-zone { + min-height: 0; } -.score-panel { - grid-column: 1 / -1; +.zone-inner { + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 0.9rem; + height: 100%; + padding: 1rem; } -.score-head { +.zone-head { + display: flex; + justify-content: space-between; + gap: 0.8rem; + align-items: flex-start; +} + +.zone-head h2 { + margin: 0.2rem 0 0; + font-size: clamp(1.45rem, 3vw, 2.4rem); +} + +.zone-stats { + text-align: right; + display: grid; + gap: 0.2rem; + color: var(--muted); +} + +.zone-stats strong { + font-size: 1.4rem; + color: var(--text); +} + +.seat-tag { + font-size: 0.8rem; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.light-seat { + background: rgba(255, 189, 109, 0.14); + color: var(--white-seat); +} + +.dark-seat { + background: rgba(100, 223, 212, 0.14); + color: var(--dark-seat); +} + +.zone-button { + width: 100%; + min-height: clamp(220px, 44vh, 560px); + padding: 1rem; + border-radius: 28px; + border: 1px solid rgba(255, 255, 255, 0.09); + font: inherit; + font-weight: 800; + letter-spacing: 0.01em; + line-height: 1.02; + font-size: clamp(2.1rem, 5vw, 4.5rem); + cursor: pointer; + color: var(--text); + transition: transform 150ms ease, filter 150ms ease, opacity 150ms ease; + touch-action: manipulation; +} + +.zone-button:hover { + transform: translateY(-2px); + filter: brightness(1.04); +} + +.zone-button:disabled { + cursor: not-allowed; + transform: none; + opacity: 0.52; +} + +.light-button { + background: + linear-gradient(165deg, rgba(255, 143, 55, 0.24), rgba(255, 189, 109, 0.1)); + box-shadow: inset 0 0 0 1px rgba(255, 189, 109, 0.08); +} + +.dark-button { + background: + linear-gradient(165deg, rgba(27, 188, 174, 0.22), rgba(100, 223, 212, 0.09)); + box-shadow: inset 0 0 0 1px rgba(100, 223, 212, 0.08); +} + +.zone-button.active-turn { + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.08), + 0 0 40px rgba(255, 189, 109, 0.18); + animation: pulse 1.6s ease-in-out infinite; +} + +.active-zone .zone-inner { + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.07); +} + +.zone-foot { + min-height: 3.4rem; + margin: 0; +} + +.phase-spine { + display: grid; + align-content: center; + gap: 0.8rem; +} + +.spine-card { + display: grid; + gap: 0.35rem; +} + +.spine-card strong { + font-size: clamp(1.45rem, 3vw, 2.2rem); +} + +.spine-card p { + margin-bottom: 0; +} + +.spine-button { + min-height: 4.4rem; +} + +.modal { + position: fixed; + inset: 0; + z-index: 20; + display: grid; + place-items: center; + padding: 1rem; +} + +.modal.hidden { + display: none; +} + +.modal-backdrop { + position: absolute; + inset: 0; + background: rgba(2, 8, 14, 0.72); + backdrop-filter: blur(6px); +} + +.modal-card { + position: relative; + z-index: 1; + width: min(760px, 100%); + background: rgba(10, 23, 35, 0.96); + box-shadow: var(--shadow); +} + +.modal-head { display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; - margin-bottom: 1rem; + margin-bottom: 0.8rem; } -.timer-grid, -.player-grid, -.cube-results, -.rules-grid { - display: grid; - gap: 0.85rem; +.modal-head h2 { + margin-bottom: 0; } -.timer-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - margin-bottom: 0.85rem; -} - -.timer-card strong, -.cube-clock strong { - display: block; - margin: 0.4rem 0; - font-size: clamp(2rem, 6vw, 3.6rem); - line-height: 0.95; -} - -.timer-card.emphasized { - background: - linear-gradient(160deg, rgba(255, 184, 108, 0.22), rgba(255, 255, 255, 0.03)); -} - -.player-grid, -.cube-results, -.rules-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); -} - -.player-card { - position: relative; - overflow: hidden; -} - -.player-card::after { - content: ""; - position: absolute; - inset: auto -10% -50% auto; - width: 9rem; - height: 9rem; - border-radius: 50%; - opacity: 0.18; -} - -.white-seat::after { - background: var(--warm); -} - -.black-seat::after { - background: var(--cool); -} - -.player-card.active { - border-color: var(--panel-highlight); - box-shadow: 0 0 0 1px rgba(255, 184, 108, 0.2), inset 0 0 0 1px rgba(255, 184, 108, 0.14); - animation: pulse 1.8s ease-in-out infinite; -} - -.player-name-row { +.modal-actions { display: flex; - justify-content: space-between; - gap: 1rem; - align-items: center; - margin-bottom: 0.35rem; -} - -.player-color { - padding: 0.25rem 0.55rem; - border-radius: 999px; - font-size: 0.82rem; - text-transform: uppercase; - letter-spacing: 0.1em; - background: rgba(255, 255, 255, 0.08); -} - -.muted { - color: var(--muted); -} - -.notice-card, -.double-card { - margin-top: 1rem; - color: var(--muted); -} - -.double-card strong { - display: block; - margin-bottom: 0.35rem; - color: var(--warm); -} - -.cube-head { - display: flex; - justify-content: space-between; - gap: 1rem; - align-items: center; - margin-bottom: 1rem; -} - -.cube-clock { - padding: 1rem; - border-radius: 22px; - background: - linear-gradient(160deg, rgba(93, 226, 216, 0.18), rgba(255, 255, 255, 0.03)); - border: 1px solid rgba(93, 226, 216, 0.18); - margin-bottom: 1rem; -} - -.cube-results { - margin: 1rem 0; -} - -.cube-results strong { - font-size: 1.35rem; - display: block; - margin-top: 0.2rem; -} - -.compact { - margin-top: 0.8rem; -} - -.history-list { - list-style: none; - margin: 0; - padding: 0; - display: grid; + flex-wrap: wrap; gap: 0.75rem; - max-height: 24rem; - overflow: auto; -} - -.history-list li { - padding: 0.85rem 0.95rem; - border-radius: 18px; - background: rgba(255, 255, 255, 0.04); - border: 1px solid rgba(255, 255, 255, 0.06); -} - -.history-list small { - display: block; - margin-bottom: 0.25rem; - color: var(--cool); -} - -.footer { margin-top: 1rem; - padding: 1rem 0 0; -} - -.footer a { - color: var(--warm); -} - -.hidden { - display: none; -} - -@keyframes rise { - from { - opacity: 0; - transform: translateY(14px); - } - - to { - opacity: 1; - transform: translateY(0); - } } @keyframes pulse { 0%, 100% { - box-shadow: 0 0 0 1px rgba(255, 184, 108, 0.2), inset 0 0 0 1px rgba(255, 184, 108, 0.14); + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.08), + 0 0 28px rgba(255, 189, 109, 0.12); } 50% { - box-shadow: 0 0 0 1px rgba(255, 184, 108, 0.32), 0 0 32px rgba(255, 184, 108, 0.16); + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.1), + 0 0 38px rgba(255, 189, 109, 0.2); } } -@media (max-width: 960px) { +@media (max-width: 1100px) { .hero, - .live-grid, + .setup-grid, .mode-grid, .preset-grid, - .player-grid, - .cube-results, - .rules-grid, - .timer-grid { + .faceoff-board, + .status-strip { grid-template-columns: 1fr; } - .setup-form, - .live-grid { + .phase-header { + grid-template-columns: 1fr; + text-align: center; + } + + .brand-link, + .utility-button { + justify-self: center; + } +} + +@media (max-width: 900px) { + .setup-shell, + .phase-shell { + width: min(100% - 1rem, 100%); + } + + .panel, + .hero { + padding: 1rem; + } + + .setup-form { grid-template-columns: 1fr; } - .section-heading, - .score-head, - .cube-head { - flex-direction: column; + .setup-actions, + .resume-actions, + .modal-actions { + display: grid; + } + + .faceoff-board { + grid-template-rows: minmax(0, 1fr) auto minmax(0, 1fr); + gap: 0.7rem; + } + + .opponent-zone .mirrored-mobile { + transform: rotate(180deg); + } + + .zone-button { + min-height: clamp(180px, 28vh, 340px); + font-size: clamp(1.9rem, 7vw, 3.2rem); } } @media (max-width: 640px) { - .layout { - width: min(100% - 1rem, 100%); + .hero-setup h1 { + font-size: 2.7rem; } - .hero-copy, - .hero-preview, - .panel { - padding: 1rem; - border-radius: 22px; + .status-card { + min-height: 5.6rem; } - h1 { - font-size: 2.65rem; + .status-card strong { + font-size: 1.9rem; } - .button { + .zone-head, + .modal-head, + .section-heading { + flex-direction: column; + } + + .zone-stats { + text-align: left; + } + + .button, + .spine-button { width: 100%; - justify-content: center; - } - - .hero-actions, - .setup-actions, - .action-grid, - .result-grid, - .capture-grid { - display: grid; } }