Automatise le passage au cube et rend les temps configurables

This commit is contained in:
2026-04-12 12:26:57 +02:00
parent c10efa872c
commit d01e132782
5 changed files with 198 additions and 131 deletions

View File

@@ -6,13 +6,14 @@ Application web mobile-first pour téléphone et tablette, pensée comme applica
- 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
- permet de définir librement le temps de block et le temps par coup
- 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
- ouvre automatiquement la page cube dès que la phase chess du block est terminée
## Hypothèse de produit

243
app.js
View File

@@ -2,8 +2,8 @@ const PAGE = document.body.dataset.page;
const STORAGE_KEY = "chesscubing-arena-state-v2";
const WINDOW_NAME_KEY = "chesscubing-arena-state-v2:";
const BLOCK_DURATION_MS = 180000;
const MOVE_LIMIT_MS = 20000;
const DEFAULT_BLOCK_DURATION_MS = 180000;
const DEFAULT_MOVE_LIMIT_MS = 20000;
const TIME_MODE_INITIAL_CLOCK_MS = 600000;
const CUBE_TIME_CAP_MS = 120000;
@@ -40,7 +40,9 @@ let match = readStoredMatch();
let dirty = false;
if (match) {
normalizeRecoveredMatch(match);
if (normalizeRecoveredMatch(match)) {
dirty = true;
}
if (syncRunningState(match)) {
dirty = true;
}
@@ -90,6 +92,8 @@ function initSetupPage() {
const mode = getRadioValue(form, "mode") || "twice";
const preset = getRadioValue(form, "preset") || "fast";
const quota = PRESETS[preset].quota;
const blockDurationMs = getDurationInputMs(form, "blockSeconds", DEFAULT_BLOCK_DURATION_MS);
const moveLimitMs = getDurationInputMs(form, "moveSeconds", DEFAULT_MOVE_LIMIT_MS);
const timeImpact =
mode === "time"
? "Chronos cumules de 10 minutes, ajustes apres chaque phase cube avec plafond de 120 s pris en compte."
@@ -98,7 +102,7 @@ function initSetupPage() {
summary.innerHTML = `
<strong>${MODES[mode].label}</strong>
<span>${PRESETS[preset].description}</span>
<span>Chaque block dure 180 secondes, chaque coup est limite a 20 secondes.</span>
<span>Temps configures : block ${formatClock(blockDurationMs)}, coup ${formatClock(moveLimitMs)}.</span>
<span>${timeImpact}</span>
<span>Quota actif : ${quota} coups par joueur.</span>
`;
@@ -116,9 +120,7 @@ function initSetupPage() {
? resultText(match)
: match.phase === "cube"
? "Page cube prete"
: match.awaitingBlockClosure
? "Fin de block a confirmer"
: "Page chrono prete";
: "Page chrono prete";
resumeCard.innerHTML = `
<strong>${escapeHtml(match.config.matchLabel)}</strong>
@@ -155,6 +157,8 @@ function initSetupPage() {
matchLabel: sanitizeText(data.get("matchLabel")) || "Rencontre ChessCubing",
mode: getRadioValue(form, "mode") || "twice",
preset: getRadioValue(form, "preset") || "fast",
blockDurationMs: getDurationInputMs(form, "blockSeconds", DEFAULT_BLOCK_DURATION_MS),
moveLimitMs: getDurationInputMs(form, "moveSeconds", DEFAULT_MOVE_LIMIT_MS),
whiteName: sanitizeText(data.get("whiteName")) || "Blanc",
blackName: sanitizeText(data.get("blackName")) || "Noir",
arbiterName: sanitizeText(data.get("arbiterName")),
@@ -242,14 +246,6 @@ function initChronoPage() {
return;
}
if (match.awaitingBlockClosure) {
openCubePhase(match);
dirty = true;
persistMatch();
navigateTo("cube.html");
return;
}
if (match.running) {
pauseBlock(match);
} else {
@@ -266,14 +262,6 @@ function initChronoPage() {
return;
}
if (match.awaitingBlockClosure) {
openCubePhase(match);
dirty = true;
persistMatch();
navigateTo("cube.html");
return;
}
requestBlockClosure(match, "Cloture manuelle du block demandee par l'arbitre.");
dirty = true;
render();
@@ -297,7 +285,7 @@ function initChronoPage() {
}
match.currentTurn = opponentOf(match.currentTurn);
match.moveRemainingMs = MOVE_LIMIT_MS;
match.moveRemainingMs = getMoveLimitMs(match);
logEvent(match, "Trait corrige manuellement par l'arbitre.");
dirty = true;
render();
@@ -336,17 +324,6 @@ function initChronoPage() {
syncRunningState(match);
if (match.awaitingBlockClosure) {
if (match.currentTurn !== color) {
return;
}
registerReliefMove(match);
dirty = true;
render();
return;
}
if (!match.running || match.currentTurn !== color) {
return;
}
@@ -376,14 +353,6 @@ function initChronoPage() {
return;
}
if (match.awaitingBlockClosure) {
openCubePhase(match);
dirty = true;
persistMatch();
navigateTo("cube.html");
return;
}
if (match.running) {
pauseBlock(match);
} else {
@@ -421,19 +390,6 @@ function initChronoPage() {
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;
@@ -474,10 +430,17 @@ function initChronoPage() {
return;
}
if (!match.result && match.phase === "cube") {
persistMatch();
navigateTo("cube.html");
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);
refs.arbiterTimeoutButton.textContent = `Depassement ${formatClock(getMoveLimitMs(match))}`;
if (match.result) {
refs.centerLabel.textContent = "Resultat";
@@ -487,20 +450,13 @@ function initChronoPage() {
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.spineText.textContent =
"Chaque joueur tape sur sa grande zone quand son coup est termine. La page cube s'ouvrira automatiquement a la fin de la phase chess.";
refs.primaryButton.textContent = "Pause arbitre";
refs.arbiterStatus.textContent = `Block en cours. Joueur au trait : ${playerName(match, match.currentTurn)}.`;
} else {
@@ -508,14 +464,13 @@ function initChronoPage() {
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.spineText.textContent =
"Demarrez le block, puis laissez uniquement les deux grandes zones aux joueurs. La page cube prendra automatiquement le relais.";
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";
refs.arbiterCloseBlockButton.textContent = "Passer au cube";
renderZone("black");
renderZone("white");
@@ -799,15 +754,15 @@ function initCubePage() {
function createMatch(config) {
const quota = PRESETS[config.preset].quota;
const newMatch = {
schemaVersion: 2,
schemaVersion: 3,
config,
phase: "block",
running: false,
lastTickAt: null,
blockNumber: 1,
currentTurn: "white",
blockRemainingMs: BLOCK_DURATION_MS,
moveRemainingMs: MOVE_LIMIT_MS,
blockRemainingMs: config.blockDurationMs,
moveRemainingMs: config.moveLimitMs,
quota,
moves: {
white: 0,
@@ -848,7 +803,10 @@ function createMatch(config) {
history: [],
};
logEvent(newMatch, `Match cree en mode ${MODES[config.mode].label}, cadence ${PRESETS[config.preset].label}.`);
logEvent(
newMatch,
`Match cree en mode ${MODES[config.mode].label}, cadence ${PRESETS[config.preset].label}, block ${formatClock(config.blockDurationMs)} et coup ${formatClock(config.moveLimitMs)}.`,
);
logEvent(newMatch, "Les Blancs commencent le block 1.");
return newMatch;
}
@@ -863,7 +821,7 @@ function readStoredMatch() {
}
const parsed = JSON.parse(raw);
if (!parsed || parsed.schemaVersion !== 2) {
if (!parsed || !isSupportedSchemaVersion(parsed.schemaVersion)) {
return fromWindowName;
}
@@ -874,8 +832,44 @@ function readStoredMatch() {
}
function normalizeRecoveredMatch(storedMatch) {
let changed = false;
if (!storedMatch.config) {
storedMatch.config = {};
changed = true;
}
const blockDurationMs = getBlockDurationMs(storedMatch);
const moveLimitMs = getMoveLimitMs(storedMatch);
if (storedMatch.schemaVersion !== 3) {
storedMatch.schemaVersion = 3;
changed = true;
}
if (storedMatch.config.blockDurationMs !== blockDurationMs) {
storedMatch.config.blockDurationMs = blockDurationMs;
changed = true;
}
if (storedMatch.config.moveLimitMs !== moveLimitMs) {
storedMatch.config.moveLimitMs = moveLimitMs;
changed = true;
}
if (storedMatch.phase === "block" && typeof storedMatch.lastTickAt !== "number") {
storedMatch.lastTickAt = null;
changed = true;
}
if (typeof storedMatch.blockRemainingMs !== "number") {
storedMatch.blockRemainingMs = blockDurationMs;
changed = true;
}
if (typeof storedMatch.moveRemainingMs !== "number") {
storedMatch.moveRemainingMs = moveLimitMs;
changed = true;
}
if (!storedMatch.cube) {
@@ -892,6 +886,7 @@ function normalizeRecoveredMatch(storedMatch) {
round: 1,
history: [],
};
changed = true;
}
if (!storedMatch.cube.playerState) {
@@ -899,6 +894,7 @@ function normalizeRecoveredMatch(storedMatch) {
white: createCubePlayerState(),
black: createCubePlayerState(),
};
changed = true;
}
storedMatch.cube.playerState.white = normalizeCubePlayerState(storedMatch.cube.playerState.white);
@@ -911,7 +907,15 @@ function normalizeRecoveredMatch(storedMatch) {
step: 0,
starter: "white",
};
changed = true;
}
if (storedMatch.awaitingBlockClosure && storedMatch.phase === "block") {
openCubePhase(storedMatch, storedMatch.closureReason || "La phase chess etait deja terminee.");
changed = true;
}
return changed;
}
function syncRunningState(storedMatch) {
@@ -938,7 +942,10 @@ function syncRunningState(storedMatch) {
}
if (storedMatch.blockRemainingMs === 0) {
requestBlockClosure(storedMatch, "Les 180 secondes du block sont ecoulees.");
requestBlockClosure(
storedMatch,
`Le temps de block ${formatClock(getBlockDurationMs(storedMatch))} est ecoule.`,
);
} else if (storedMatch.moveRemainingMs === 0) {
registerMoveTimeout(storedMatch, true);
}
@@ -974,19 +981,20 @@ function pauseBlock(storedMatch) {
}
function requestBlockClosure(storedMatch, reason) {
if (!storedMatch || storedMatch.awaitingBlockClosure) {
if (!storedMatch || storedMatch.phase !== "block") {
return;
}
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.`);
storedMatch.awaitingBlockClosure = false;
storedMatch.closureReason = "";
storedMatch.moveRemainingMs = getMoveLimitMs(storedMatch);
logEvent(storedMatch, `${reason} Passage automatique vers la page cube.`);
openCubePhase(storedMatch, reason);
}
function openCubePhase(storedMatch) {
function openCubePhase(storedMatch, reason = "") {
if (!storedMatch) {
return;
}
@@ -1006,7 +1014,10 @@ function openCubePhase(storedMatch) {
black: createCubePlayerState(),
};
storedMatch.cube.round = 1;
logEvent(storedMatch, `Page cube ouverte. Cube n${storedMatch.cube.number} designe.`);
logEvent(
storedMatch,
`${reason ? `${reason} ` : ""}Page cube ouverte. Cube n${storedMatch.cube.number} designe.`,
);
}
function startCubeTimer(storedMatch, color) {
@@ -1099,8 +1110,8 @@ function prepareNextTwiceBlock(storedMatch, winner) {
storedMatch.phase = "block";
storedMatch.running = false;
storedMatch.lastTickAt = null;
storedMatch.blockRemainingMs = BLOCK_DURATION_MS;
storedMatch.moveRemainingMs = MOVE_LIMIT_MS;
storedMatch.blockRemainingMs = getBlockDurationMs(storedMatch);
storedMatch.moveRemainingMs = getMoveLimitMs(storedMatch);
storedMatch.moves = { white: 0, black: 0 };
storedMatch.currentTurn = winner;
storedMatch.doubleCoup = {
@@ -1130,8 +1141,8 @@ function prepareNextTimeBlock(storedMatch) {
storedMatch.phase = "block";
storedMatch.running = false;
storedMatch.lastTickAt = null;
storedMatch.blockRemainingMs = BLOCK_DURATION_MS;
storedMatch.moveRemainingMs = MOVE_LIMIT_MS;
storedMatch.blockRemainingMs = getBlockDurationMs(storedMatch);
storedMatch.moveRemainingMs = getMoveLimitMs(storedMatch);
storedMatch.moves = { white: 0, black: 0 };
storedMatch.doubleCoup = {
eligible: false,
@@ -1219,7 +1230,7 @@ function registerCountedMove(storedMatch, { source }) {
storedMatch.moves[actor] += 1;
storedMatch.lastMover = actor;
storedMatch.moveRemainingMs = MOVE_LIMIT_MS;
storedMatch.moveRemainingMs = getMoveLimitMs(storedMatch);
if (source === "double") {
storedMatch.doubleCoup.step = 0;
@@ -1241,7 +1252,7 @@ function registerFreeDoubleMove(storedMatch) {
const actor = storedMatch.currentTurn;
storedMatch.lastMover = actor;
storedMatch.moveRemainingMs = MOVE_LIMIT_MS;
storedMatch.moveRemainingMs = getMoveLimitMs(storedMatch);
storedMatch.doubleCoup.step = 2;
logEvent(
storedMatch,
@@ -1249,21 +1260,6 @@ function registerFreeDoubleMove(storedMatch) {
);
}
function registerReliefMove(storedMatch) {
if (!storedMatch || storedMatch.phase !== "block") {
return;
}
const actor = storedMatch.currentTurn;
storedMatch.lastMover = actor;
storedMatch.currentTurn = opponentOf(actor);
storedMatch.moveRemainingMs = MOVE_LIMIT_MS;
logEvent(
storedMatch,
`Coup hors quota joue par ${playerName(storedMatch, actor)} avant la page cube.`,
);
}
function registerMoveTimeout(storedMatch, automatic) {
if (!storedMatch || storedMatch.phase !== "block") {
return;
@@ -1273,7 +1269,7 @@ function registerMoveTimeout(storedMatch, automatic) {
if (storedMatch.doubleCoup.step === 1) {
storedMatch.doubleCoup.step = 0;
storedMatch.moveRemainingMs = MOVE_LIMIT_MS;
storedMatch.moveRemainingMs = getMoveLimitMs(storedMatch);
logEvent(
storedMatch,
`Depassement sur le premier coup gratuit de ${playerName(storedMatch, actor)} : le double coup est annule.`,
@@ -1290,10 +1286,10 @@ function registerMoveTimeout(storedMatch, automatic) {
}
storedMatch.currentTurn = opponentOf(actor);
storedMatch.moveRemainingMs = MOVE_LIMIT_MS;
storedMatch.moveRemainingMs = getMoveLimitMs(storedMatch);
logEvent(
storedMatch,
`${automatic ? "Temps par coup ecoule." : "Depassement manuel 20 s."} ${playerName(
`${automatic ? "Temps par coup ecoule." : `Depassement manuel du temps par coup ${formatClock(getMoveLimitMs(storedMatch))}.`} ${playerName(
storedMatch,
actor,
)} perd son coup, qui reste compte dans le quota.`,
@@ -1455,6 +1451,8 @@ function loadDemo(form, onRender) {
setInputValue(form, "matchLabel", "Demo officielle ChessCubing");
setRadioValue(form, "mode", "twice");
setRadioValue(form, "preset", "freeze");
setInputValue(form, "blockSeconds", "180");
setInputValue(form, "moveSeconds", "20");
setInputValue(form, "whiteName", "Nora");
setInputValue(form, "blackName", "Leo");
setInputValue(form, "arbiterName", "Arbitre demo");
@@ -1482,6 +1480,16 @@ function setRadioValue(form, name, value) {
}
}
function getDurationInputMs(form, name, fallbackMs) {
const input = form.querySelector(`[name="${name}"]`);
const seconds = Number.parseInt(String(input?.value || ""), 10);
if (!Number.isFinite(seconds) || seconds <= 0) {
return fallbackMs;
}
return seconds * 1000;
}
function playerName(storedMatch, color) {
return color === "white" ? storedMatch.config.whiteName : storedMatch.config.blackName;
}
@@ -1556,12 +1564,33 @@ function readWindowNameState() {
const raw = window.name.slice(WINDOW_NAME_KEY.length);
const parsed = JSON.parse(raw);
return parsed && parsed.schemaVersion === 2 ? parsed : null;
return parsed && isSupportedSchemaVersion(parsed.schemaVersion) ? parsed : null;
} catch {
return null;
}
}
function isSupportedSchemaVersion(version) {
return version === 2 || version === 3;
}
function getBlockDurationMs(storedMatch) {
return normalizeDurationMs(storedMatch?.config?.blockDurationMs, DEFAULT_BLOCK_DURATION_MS);
}
function getMoveLimitMs(storedMatch) {
return normalizeDurationMs(storedMatch?.config?.moveLimitMs, DEFAULT_MOVE_LIMIT_MS);
}
function normalizeDurationMs(value, fallbackMs) {
const duration = Number(value);
if (!Number.isFinite(duration) || duration <= 0) {
return fallbackMs;
}
return Math.round(duration);
}
function createCubePlayerState() {
return {
running: false,

View File

@@ -114,10 +114,10 @@
Pause / reprise
</button>
<button class="button secondary" id="arbiterCloseBlockButton" type="button">
Clore le block
Passer au cube
</button>
<button class="button secondary" id="arbiterTimeoutButton" type="button">
Dépassement 20 s
Dépassement temps coup
</button>
<button class="button secondary" id="arbiterSwitchTurnButton" type="button">
Corriger le trait

View File

@@ -21,7 +21,8 @@
<h1>ChessCubing Arena</h1>
<p class="lead">
Une version mobile et tablette organisée par phases : configuration,
page chrono ultra lisible, puis page cube dédiée.
page chrono ultra lisible, puis page cube dédiée qui s'ouvre
automatiquement à la fin de la phase chess.
</p>
<div class="hero-pills">
<span>Pages séparées</span>
@@ -118,6 +119,34 @@
</div>
</fieldset>
<fieldset class="field span-2">
<legend>Temps personnalisés</legend>
<div class="timing-grid">
<label class="field">
<span>Temps block (secondes)</span>
<input
name="blockSeconds"
type="number"
min="30"
max="1800"
step="5"
value="180"
/>
</label>
<label class="field">
<span>Temps coup (secondes)</span>
<input
name="moveSeconds"
type="number"
min="5"
max="300"
step="1"
value="20"
/>
</label>
</div>
</fieldset>
<label class="field">
<span>Joueur blanc</span>
<input name="whiteName" type="text" maxlength="40" placeholder="Blanc" value="Blanc" />
@@ -188,15 +217,16 @@
<strong>Gros boutons uniquement</strong>
<p>
Chaque joueur dispose d'une grande zone tactile pour signaler la
fin de son coup, avec le haut de l'écran inversé sur téléphone.
fin de son coup, puis l'app ouvre automatiquement la phase cube
quand le block chess est terminé.
</p>
</article>
<article class="rule-card">
<span class="micro-label">Page cube</span>
<strong>Une page dédiée</strong>
<p>
Le cube désigné, le chrono commun et les arrêts Blanc / Noir
sont isolés sur leur propre écran.
Les deux joueurs lancent et arrêtent leur propre chrono cube sur
un écran séparé, toujours en face-à-face sur mobile.
</p>
</article>
<article class="rule-card">

View File

@@ -8,14 +8,14 @@
--grid-line: rgba(92, 234, 255, 0.09);
--text: #e4fbff;
--muted: #7da9b6;
--warm: #ffb45d;
--warm-strong: #ff7a1f;
--warm: #7df7bd;
--warm-strong: #18d97f;
--cool: #75f2ff;
--cool-strong: #1ed6ff;
--white-seat: #ffd08a;
--white-seat: #b6ffd8;
--dark-seat: #8ef6ff;
--danger: #ff647f;
--success: #9ff68e;
--success: #7df7bd;
--shadow:
0 0 0 1px rgba(98, 242, 255, 0.08),
0 26px 80px rgba(0, 0, 0, 0.56),
@@ -39,9 +39,9 @@ body {
font-family: "Segoe UI", "Trebuchet MS", sans-serif;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(255, 122, 31, 0.18), transparent 22%),
radial-gradient(circle at top left, rgba(24, 217, 127, 0.18), transparent 22%),
radial-gradient(circle at 78% 18%, rgba(30, 214, 255, 0.18), transparent 20%),
radial-gradient(circle at bottom center, rgba(30, 214, 255, 0.1), transparent 26%),
radial-gradient(circle at bottom center, rgba(24, 217, 127, 0.1), transparent 26%),
linear-gradient(180deg, #01040a 0%, #03101b 48%, #041522 100%);
}
@@ -63,7 +63,7 @@ body::after {
inset: 0;
pointer-events: none;
background:
radial-gradient(circle at 50% 110%, rgba(30, 214, 255, 0.24), transparent 24%),
radial-gradient(circle at 50% 110%, rgba(24, 217, 127, 0.18), transparent 24%),
linear-gradient(180deg, transparent 0 58%, rgba(30, 214, 255, 0.06) 72%, transparent 100%);
mix-blend-mode: screen;
}
@@ -180,9 +180,9 @@ p {
}
.button.secondary {
background: linear-gradient(135deg, rgba(255, 122, 31, 0.18), rgba(255, 180, 93, 0.08));
border-color: rgba(255, 180, 93, 0.32);
box-shadow: 0 0 18px rgba(255, 122, 31, 0.1);
background: linear-gradient(135deg, rgba(24, 217, 127, 0.18), rgba(125, 247, 189, 0.08));
border-color: rgba(125, 247, 189, 0.32);
box-shadow: 0 0 18px rgba(24, 217, 127, 0.1);
}
.button.ghost {
@@ -343,6 +343,12 @@ p {
gap: 1rem;
}
.timing-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
}
.field {
display: grid;
gap: 0.45rem;
@@ -435,7 +441,7 @@ textarea:focus {
.option-card:has(input:checked) {
border-color: rgba(30, 214, 255, 0.52);
background:
linear-gradient(160deg, rgba(30, 214, 255, 0.16), rgba(255, 122, 31, 0.08));
linear-gradient(160deg, rgba(30, 214, 255, 0.16), rgba(24, 217, 127, 0.09));
box-shadow:
inset 0 0 0 1px rgba(30, 214, 255, 0.06),
0 0 24px rgba(30, 214, 255, 0.08);
@@ -561,7 +567,7 @@ textarea:focus {
.status-card.wide {
background:
linear-gradient(160deg, rgba(30, 214, 255, 0.14), rgba(255, 122, 31, 0.09));
linear-gradient(160deg, rgba(30, 214, 255, 0.14), rgba(24, 217, 127, 0.09));
border-color: rgba(30, 214, 255, 0.22);
}
@@ -615,9 +621,9 @@ textarea:focus {
}
.light-seat {
background: rgba(255, 180, 93, 0.12);
background: rgba(125, 247, 189, 0.12);
color: var(--white-seat);
box-shadow: 0 0 16px rgba(255, 122, 31, 0.12);
box-shadow: 0 0 16px rgba(24, 217, 127, 0.12);
}
.dark-seat {
@@ -661,12 +667,12 @@ textarea:focus {
.light-button {
background:
linear-gradient(165deg, rgba(255, 122, 31, 0.24), rgba(255, 180, 93, 0.08)),
linear-gradient(165deg, rgba(24, 217, 127, 0.24), rgba(125, 247, 189, 0.08)),
linear-gradient(180deg, rgba(11, 19, 24, 0.96), rgba(5, 10, 14, 0.98));
color: var(--white-seat);
box-shadow:
inset 0 0 0 1px rgba(255, 180, 93, 0.08),
0 0 28px rgba(255, 122, 31, 0.08);
inset 0 0 0 1px rgba(125, 247, 189, 0.08),
0 0 28px rgba(24, 217, 127, 0.08);
}
.dark-button {
@@ -786,6 +792,7 @@ textarea:focus {
.setup-grid,
.mode-grid,
.preset-grid,
.timing-grid,
.faceoff-board {
grid-template-columns: 1fr;
}