Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>Sudoku Visualizer</title> | |
| <style> | |
| /* --- Dark theme only, but with mid-contrast so it isn't "all dark" --- */ | |
| :root { | |
| --bg: #0b0e14; | |
| /* page background */ | |
| --panel: #121826; | |
| /* cards */ | |
| --panel-2: #0f1522; | |
| /* slight contrast */ | |
| --text: #d2d7e3; | |
| /* body text */ | |
| --muted: #8e98ad; | |
| /* secondary text */ | |
| --accent: #7aa2ff; | |
| /* focus color */ | |
| /* Board palette (muted, not bright white) */ | |
| --board-bg: #131824; | |
| /* board fill */ | |
| --line-thin: #3a455d; | |
| /* inner 1px */ | |
| --line-thick: #a7b0c4; | |
| /* 2px group lines (soft light gray-blue) */ | |
| --line-outer: #b9c1d3; | |
| /* 2px outer frame (slightly brighter than group) */ | |
| --num: #d6dbe6; | |
| /* filled numbers */ | |
| --num-empty: #7b8498; | |
| /* empty placeholders */ | |
| } | |
| * { | |
| box-sizing: border-box; | |
| } | |
| html, | |
| body { | |
| height: 100%; | |
| } | |
| body { | |
| margin: 0; | |
| background: var(--bg); | |
| color: var(--text); | |
| font: 16px/1.45 system-ui, -apple-system, Segoe UI, Roboto, Inter, "Helvetica Neue", Arial, sans-serif; | |
| } | |
| .app { | |
| max-width: 1100px; | |
| margin: 24px auto; | |
| padding: 0 16px; | |
| display: grid; | |
| gap: 18px; | |
| grid-template-columns: 1.1fr 1fr; | |
| } | |
| @media (max-width: 980px) { | |
| .app { | |
| grid-template-columns: 1fr; | |
| } | |
| } | |
| .card { | |
| background: linear-gradient(180deg, var(--panel) 0%, var(--panel-2) 100%); | |
| border: 1px solid #1d2740; | |
| } | |
| .app__header { | |
| grid-column: 1/-1; | |
| padding: 20px; | |
| } | |
| .app__header h1 { | |
| margin: 0 0 6px; | |
| font-weight: 800; | |
| font-size: clamp(22px, 2.4vw, 30px); | |
| } | |
| .app__header p { | |
| margin: 6px 0 0; | |
| color: var(--muted); | |
| } | |
| code { | |
| background: #0f1422; | |
| padding: 2px 6px; | |
| border-radius: 4px; | |
| color: #c4ccda; | |
| } | |
| .input-panel { | |
| padding: 16px; | |
| display: grid; | |
| gap: 12px; | |
| align-content: start; | |
| } | |
| .input-panel__label { | |
| margin: 6px 0; | |
| font-weight: 600; | |
| } | |
| textarea, | |
| input[type="text"] { | |
| width: 100%; | |
| color: var(--text); | |
| background: #0f1524; | |
| border: 1px solid #27324a; | |
| padding: 10px 12px; | |
| outline: none; | |
| resize: vertical; | |
| min-height: 110px; | |
| font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; | |
| } | |
| input[type="text"] { | |
| min-height: unset; | |
| } | |
| textarea:focus, | |
| input[type="text"]:focus { | |
| border-color: var(--accent); | |
| box-shadow: 0 0 0 2px rgba(122, 162, 255, .18); | |
| } | |
| .is-invalid { | |
| border-color: #ff6b6b ; | |
| box-shadow: 0 0 0 2px rgba(255, 107, 107, .18) ; | |
| } | |
| .input-panel__extras { | |
| display: grid; | |
| gap: 12px; | |
| grid-template-columns: 1fr 1fr; | |
| } | |
| @media (max-width: 640px) { | |
| .input-panel__extras { | |
| grid-template-columns: 1fr; | |
| } | |
| } | |
| .field__hint { | |
| margin: 6px 2px 0; | |
| color: var(--muted); | |
| font-size: .9rem; | |
| } | |
| .input-panel__hint { | |
| margin: 0; | |
| color: var(--muted); | |
| padding: 0 6px 8px; | |
| } | |
| .input-panel__controls { | |
| display: flex; | |
| gap: 10px; | |
| flex-wrap: wrap; | |
| padding-top: 4px; | |
| align-items: center; | |
| } | |
| button { | |
| appearance: none; | |
| border: 1px solid #27324a; | |
| background: #151d32; | |
| color: var(--text); | |
| padding: 10px 14px; | |
| font-weight: 700; | |
| cursor: pointer; | |
| } | |
| button:hover { | |
| filter: brightness(1.06); | |
| } | |
| button.secondary { | |
| background: #101727; | |
| } | |
| .grid-panel { | |
| padding: 16px; | |
| display: grid; | |
| gap: 12px; | |
| align-content: start; | |
| } | |
| .grid-head { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| gap: 12px; | |
| color: var(--muted); | |
| } | |
| .badge { | |
| font-size: .9rem; | |
| padding: 4px 8px; | |
| border: 1px solid #27324a; | |
| color: var(--muted); | |
| } | |
| /* --- Classic Sudoku board (no rounded corners, mid-contrast lines) --- */ | |
| .sudoku-grid { | |
| display: grid; | |
| grid-template-columns: repeat(9, 1fr); | |
| width: 100%; | |
| max-width: 560px; | |
| aspect-ratio: 1/1; | |
| background: var(--board-bg); | |
| border: 2px solid var(--line-outer); | |
| } | |
| .sudoku-cell { | |
| display: grid; | |
| place-items: center; | |
| font-weight: 600; | |
| font-size: clamp(16px, 2.2vw, 22px); | |
| border: 1px solid var(--line-thin); | |
| min-height: 48px; | |
| user-select: none; | |
| color: var(--num); | |
| } | |
| .sudoku-cell--empty { | |
| color: var(--num-empty); | |
| } | |
| .sudoku-cell--conflict { | |
| background: rgba(220, 38, 38, .16); | |
| border-color: #dc2626 ; | |
| color: #ffd7d7; | |
| } | |
| /* 3×3 separators */ | |
| .border-right-group { | |
| border-right: 2px solid var(--line-thick) ; | |
| } | |
| .border-bottom-group { | |
| border-bottom: 2px solid var(--line-thick) ; | |
| } | |
| /* Outer edges */ | |
| .border-left-strong { | |
| border-left: 2px solid var(--line-outer) ; | |
| } | |
| .border-right-strong { | |
| border-right: 2px solid var(--line-outer) ; | |
| } | |
| .border-top-strong { | |
| border-top: 2px solid var(--line-outer) ; | |
| } | |
| .border-bottom-strong { | |
| border-bottom: 2px solid var(--line-outer) ; | |
| } | |
| /* Toast / status */ | |
| .status-wrap { | |
| position: fixed; | |
| right: 18px; | |
| bottom: 18px; | |
| z-index: 999; | |
| display: grid; | |
| gap: 10px; | |
| width: min(420px, calc(100vw - 36px)); | |
| } | |
| .status-message { | |
| padding: 12px 14px; | |
| border: 1px solid #27324a; | |
| background: #0f1526; | |
| color: var(--text); | |
| } | |
| .status-message[data-tone="success"] { | |
| border-color: #1f3227; | |
| background: #0f1a15; | |
| } | |
| .status-message[data-tone="warning"] { | |
| border-color: #4b3a13; | |
| background: #17140e; | |
| } | |
| .status-message[data-tone="error"] { | |
| border-color: #4a1717; | |
| background: #160f0f; | |
| } | |
| .status-message[data-tone="info"] { | |
| border-color: #27324a; | |
| background: #0f1526; | |
| } | |
| .kbd { | |
| font: 12px/1.2 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; | |
| background: #10162a; | |
| border: 1px solid #27324a; | |
| padding: 3px 6px; | |
| border-radius: 4px; | |
| color: #c4ccda; | |
| } | |
| .sr-only { | |
| position: absolute; | |
| width: 1px; | |
| height: 1px; | |
| padding: 0; | |
| margin: -1px; | |
| overflow: hidden; | |
| clip: rect(0, 0, 0, 0); | |
| border: 0; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <main class="app"> | |
| <header class="app__header card"> | |
| <h1>Sudoku Visualizer</h1> | |
| <p> | |
| Paste <strong>81 values</strong> separated by commas, spaces, or newlines. Use <code>.</code>, | |
| <code>_</code>, or <code>0</code> for empty cells. Multi-digit entries (e.g. <code>10</code>) are | |
| supported. | |
| </p> | |
| </header> | |
| <section class="input-panel card" aria-label="Sudoku input"> | |
| <form id="sudoku-form"> | |
| <label class="input-panel__label" for="grid-input">Grid values</label> | |
| <textarea id="grid-input" name="grid-input" rows="6" | |
| placeholder="Example: 5,3,.,.,7,.,.,.,.; 6,.,.,1,9,5,.,.,." spellcheck="false"></textarea> | |
| <div class="input-panel__extras"> | |
| <div class="field"> | |
| <label class="input-panel__label" for="separator-input">Separator (optional)</label> | |
| <input id="separator-input" name="separator-input" type="text" | |
| placeholder="Defaults to comma, space or newline (leave empty)" maxlength="10" /> | |
| <p class="field__hint">Use <code>\n</code> for newline or <code>\s</code> / <code>space</code> | |
| for whitespace.</p> | |
| </div> | |
| <div class="field"> | |
| <label class="input-panel__label" for="empty-input">Empty token(s) (optional)</label> | |
| <input id="empty-input" name="empty-input" type="text" | |
| placeholder="e.g. . , _ , 0 or blank (comma/space separated)" maxlength="50" /> | |
| <p class="field__hint">Additional tokens to treat as empty. Defaults include <code>.</code>, | |
| <code>_</code>, <code>0</code>, <code>x</code>.</p> | |
| </div> | |
| </div> | |
| <div class="input-panel__controls"> | |
| <button type="submit" title="Visualize (Ctrl/⌘ + Enter)">Visualize</button> | |
| <button type="button" id="clear-button" class="secondary">Clear</button> | |
| <button type="button" id="sample-button" class="secondary">Load sample</button> | |
| <span class="badge" id="count-badge" aria-live="polite">—</span> | |
| </div> | |
| </form> | |
| <p class="input-panel__hint">The first 81 recognizable values will be used. Extra values are ignored so you | |
| can paste entire rows at once.</p> | |
| </section> | |
| <section class="grid-panel card" aria-live="polite" aria-label="Rendered Sudoku grid"> | |
| <div class="grid-head"> | |
| <strong>Grid</strong> | |
| </div> | |
| <div id="sudoku-grid" class="sudoku-grid"></div> | |
| </section> | |
| </main> | |
| <div class="status-wrap"> | |
| <div id="status-message" class="status-message" role="status" aria-live="polite"></div> | |
| </div> | |
| <script> | |
| (function () { | |
| const form = document.getElementById("sudoku-form"); | |
| const input = document.getElementById("grid-input"); | |
| const separatorInput = document.getElementById("separator-input"); | |
| const emptyInput = document.getElementById("empty-input"); | |
| const grid = document.getElementById("sudoku-grid"); | |
| const status = document.getElementById("status-message"); | |
| const countBadge = document.getElementById("count-badge"); | |
| const clearButton = document.getElementById("clear-button"); | |
| const sampleButton = document.getElementById("sample-button"); | |
| const samplePuzzle = | |
| "3, 10, 4, 8, 7, 6, 9, 2, 5,\n" + | |
| "6, 7, 9, 2, 5, 3, 10, 4, 8,\n" + | |
| "2, 8, 5, 9, 10, 4, 3, 6, 7,\n" + | |
| "4, 2, 10, 5, 9, 8, 7, 3, 6,\n" + | |
| "7, 9, 6, 3, 4, 10, 5, 8, 2,\n" + | |
| "8, 5, 3, 7, 6, 2, 4, 10, 9,\n" + | |
| "5, 6, 8, 4, 3, 7, 2, 9, 10,\n" + | |
| "9, 4, 2, 10, 8, 5, 6, 7, 3,\n" + | |
| "10, 3, 7, 6, 2, 9, 8, 5, 4"; | |
| // Live token counter and autosize | |
| input.addEventListener("input", () => { | |
| updateCount(); | |
| autosize(input); | |
| }); | |
| function updateCount() { | |
| const { values } = quickTokenize(input.value, separatorInput.value); | |
| countBadge.textContent = values.length ? `${values.length} token${values.length === 1 ? "" : "s"}` : "—"; | |
| } | |
| function autosize(el) { | |
| el.style.height = "auto"; | |
| el.style.height = Math.min(el.scrollHeight, 400) + "px"; | |
| } | |
| form.addEventListener("submit", (event) => { | |
| event.preventDefault(); | |
| const raw = input.value; | |
| const sep = separatorInput.value; | |
| const emptySpec = emptyInput.value; | |
| const { cells, error, warning } = parseInput(raw, sep, emptySpec); | |
| input.classList.toggle("is-invalid", Boolean(error)); | |
| if (error) { | |
| updateStatus(error, "error"); | |
| hideGrid(); | |
| return; | |
| } | |
| const conflictCount = renderGrid(cells); | |
| const filledCount = cells.filter(Boolean).length; | |
| const conflictNote = conflictCount ? ` • <strong>${conflictCount}</strong> conflict${conflictCount === 1 ? "" : "s"}` : ""; | |
| if (warning) { | |
| updateStatus(`${warning} Showing ${filledCount} filled cell${filledCount === 1 ? "" : "s"}.${conflictNote}`, conflictCount ? "warning" : "warning"); | |
| } else { | |
| updateStatus(`Showing ${filledCount} filled cell${filledCount === 1 ? "" : "s"}.${conflictNote}`, conflictCount ? "error" : "success"); | |
| } | |
| }); | |
| // Keyboard shortcuts | |
| document.addEventListener("keydown", (e) => { | |
| const isMetaEnter = (e.ctrlKey || e.metaKey) && e.key === "Enter"; | |
| if (isMetaEnter) { | |
| form.requestSubmit(); | |
| } else if (e.key === "Escape") { | |
| clearButton.click(); | |
| } | |
| }); | |
| clearButton.addEventListener("click", () => { | |
| input.value = ""; | |
| input.classList.remove("is-invalid"); | |
| hideGrid(); | |
| updateStatus("Cleared input. Paste a puzzle to get started.", "info"); | |
| updateCount(); | |
| input.focus(); | |
| }); | |
| sampleButton.addEventListener("click", () => { | |
| input.value = samplePuzzle; | |
| updateStatus("Loaded sample puzzle. Press Visualize or <span class=\"kbd\">Ctrl</span>/<span class=\"kbd\">⌘</span> + <span class=\"kbd\">Enter</span>.", "info"); | |
| updateCount(); | |
| input.focus(); | |
| }); | |
| function quickTokenize(raw, separator) { | |
| let values = []; | |
| if (separator && separator.trim()) { | |
| let pattern; | |
| if (separator === "\\n") pattern = /\n/; else if (separator === "\\s" || separator.toLowerCase() === "space") pattern = /\s+/; else pattern = new RegExp(escapeRegExp(separator)); | |
| values = raw.split(pattern).map((s) => (s || "").trim()).filter(Boolean); | |
| } else { | |
| values = (raw.match(/([+-]?\d+|[A-Za-z]+|\.|_)/g) || []).filter(Boolean); | |
| if (values.length === 0) { | |
| const compact = raw.replace(/\s+/g, ""); | |
| if (/^[0-9._]+$/.test(compact)) values = compact.split(""); | |
| } | |
| } | |
| return { values }; | |
| } | |
| function parseInput(raw, separator, emptySpec) { | |
| const result = { cells: [], error: null, warning: null }; | |
| if (!raw || !raw.trim()) { | |
| result.error = "Please paste or type 81 values first."; | |
| return result; | |
| } | |
| let values = quickTokenize(raw, separator).values; | |
| if (values.length === 0) { | |
| result.error = "No recognizable numbers were found."; | |
| return result; | |
| } | |
| if (values.length < 81) { | |
| result.error = `Need 81 values, found ${values.length}.`; | |
| return result; | |
| } | |
| if (values.length > 81) { | |
| result.warning = `Found ${values.length} values; using the first 81.`; | |
| values = values.slice(0, 81); | |
| } | |
| const defaults = [".", "_", "0", "x", "blank", "empty"]; | |
| const emptySet = new Set(defaults.map((s) => s.toLowerCase())); | |
| if (emptySpec && emptySpec.trim()) { | |
| const extras = emptySpec.split(/[\s,]+/).map((s) => s.trim().toLowerCase()).filter(Boolean); | |
| extras.forEach((t) => emptySet.add(t)); | |
| } | |
| result.cells = values.map((t) => normalizeToken(t, emptySet)); | |
| return result; | |
| } | |
| function escapeRegExp(string) { | |
| return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | |
| } | |
| function normalizeToken(token, emptySet) { | |
| const trimmed = (token || "").toString().trim(); | |
| if (!trimmed) return ""; | |
| const lowered = trimmed.toLowerCase(); | |
| if (emptySet && emptySet.has(lowered)) return ""; | |
| if (/^[A-Za-z]+$/.test(trimmed)) return ""; | |
| if (/^[+-]?\d+$/.test(trimmed)) { | |
| const numeric = Number.parseInt(trimmed, 10); | |
| if (!Number.isFinite(numeric) || numeric <= 0) return ""; | |
| return String(numeric); | |
| } | |
| return trimmed; | |
| } | |
| function renderGrid(cells) { | |
| grid.innerHTML = ""; | |
| if (!cells || cells.length !== 81) { hideGrid(); return 0; } | |
| const conflicts = findConflicts(cells); | |
| cells.forEach((value, index) => { | |
| const cell = document.createElement("div"); | |
| cell.className = "sudoku-cell"; | |
| const row = Math.floor(index / 9); | |
| const col = index % 9; | |
| if (!value) cell.classList.add("sudoku-cell--empty"); | |
| if (conflicts.has(index)) cell.classList.add("sudoku-cell--conflict"); | |
| if (col === 0) cell.classList.add("border-left-strong"); | |
| if (col === 8) cell.classList.add("border-right-strong"); | |
| if (col === 2 || col === 5) cell.classList.add("border-right-group"); | |
| if (row === 0) cell.classList.add("border-top-strong"); | |
| if (row === 8) cell.classList.add("border-bottom-strong"); | |
| if (row === 2 || row === 5) cell.classList.add("border-bottom-group"); | |
| cell.textContent = value; | |
| grid.appendChild(cell); | |
| }); | |
| grid.dataset.ready = "true"; | |
| return conflicts.size; | |
| } | |
| function findConflicts(cells) { | |
| const bad = new Set(); | |
| const rows = Array.from({ length: 9 }, () => new Map()); | |
| const cols = Array.from({ length: 9 }, () => new Map()); | |
| const boxes = Array.from({ length: 9 }, () => new Map()); | |
| for (let i = 0; i < 81; i++) { | |
| const v = cells[i]; | |
| if (!v) continue; // ignore empties | |
| const r = Math.floor(i / 9); | |
| const c = i % 9; | |
| const b = Math.floor(r / 3) * 3 + Math.floor(c / 3); | |
| if (!rows[r].has(v)) rows[r].set(v, []); | |
| if (!cols[c].has(v)) cols[c].set(v, []); | |
| if (!boxes[b].has(v)) boxes[b].set(v, []); | |
| rows[r].get(v).push(i); | |
| cols[c].get(v).push(i); | |
| boxes[b].get(v).push(i); | |
| } | |
| const markDupes = (maps) => { | |
| for (const m of maps) { | |
| for (const [_val, idxs] of m) { | |
| if (idxs.length > 1) idxs.forEach((ix) => bad.add(ix)); | |
| } | |
| } | |
| }; | |
| markDupes(rows); | |
| markDupes(cols); | |
| markDupes(boxes); | |
| return bad; | |
| } | |
| function hideGrid() { | |
| // Keep board visible; just clear contents if needed | |
| if (!grid.children.length) { | |
| renderGrid(new Array(81).fill("")); | |
| return; | |
| } | |
| [...grid.children].forEach((c) => (c.textContent = "")); | |
| grid.dataset.ready = ""; | |
| } | |
| function updateStatus(message, tone) { | |
| status.innerHTML = message; // allow small inline markup for kbd hints | |
| status.dataset.tone = tone || ""; | |
| } | |
| // Initial UI state | |
| updateStatus("Paste values and click <strong>Visualize</strong> to see the grid.", "info"); | |
| renderGrid(new Array(81).fill("")); // show board immediately | |
| updateCount(); | |
| (function autosizeOnLoad() { const el = input; el.style.height = "auto"; el.style.height = Math.min(el.scrollHeight, 400) + "px"; })(); | |
| input.focus(); | |
| })(); | |
| </script> | |
| </body> | |
| </html> |