sudoku-visualizer / index.html
Emilio Cantú
init
2e97c7c
<!doctype html>
<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 !important;
box-shadow: 0 0 0 2px rgba(255, 107, 107, .18) !important;
}
.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 !important;
color: #ffd7d7;
}
/* 3×3 separators */
.border-right-group {
border-right: 2px solid var(--line-thick) !important;
}
.border-bottom-group {
border-bottom: 2px solid var(--line-thick) !important;
}
/* Outer edges */
.border-left-strong {
border-left: 2px solid var(--line-outer) !important;
}
.border-right-strong {
border-right: 2px solid var(--line-outer) !important;
}
.border-top-strong {
border-top: 2px solid var(--line-outer) !important;
}
.border-bottom-strong {
border-bottom: 2px solid var(--line-outer) !important;
}
/* 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>