diff --git a/Ponomar-Regular.woff b/Ponomar-Regular.woff
new file mode 100644
index 0000000..f4f1289
Binary files /dev/null and b/Ponomar-Regular.woff differ
diff --git a/app.js b/app.js
new file mode 100644
index 0000000..29fd10c
--- /dev/null
+++ b/app.js
@@ -0,0 +1,643 @@
+import { SYSTEMS, generateChoices } from './numerals.js';
+
+const SYSTEM_RANGES = {
+ binary: [
+ { label: '2\u2070 \u2013 2\u00B3', min: 1, max: 8 },
+ { label: '2\u00B3 \u2013 2\u2077', min: 8, max: 128 },
+ { label: '2\u2070 \u2013 2\u2077', min: 1, max: 128 },
+ { label: '2\u2070 \u2013 2\u00B9\u2075', min: 1, max: 32768 },
+ ],
+ ternary: [
+ { label: '3\u2070 \u2013 3\u00B9', min: 1, max: 3 },
+ { label: '\u20133\u00B9 \u2013 3\u00B9', min: -3, max: 3 },
+ { label: '\u20133\u00B2 \u2013 3\u00B2', min: -9, max: 9 },
+ { label: '\u20133\u00B3 \u2013 3\u00B3', min: -27, max: 27 },
+ ],
+ octal: [
+ { label: '8\u2070 \u2013 8\u00B9', min: 1, max: 8 },
+ { label: '8\u00B2 \u2013 8\u00B3', min: 64, max: 512 },
+ { label: '8\u2070 \u2013 8\u00B3', min: 1, max: 512 },
+ { label: '8\u2070 \u2013 8\u2074', min: 1, max: 4096 },
+ ],
+ hex: [
+ { label: 'F\u2070 \u2013 F\u00B9', min: 1, max: 16 },
+ { label: 'F\u2070 \u2013 F\u00B2', min: 1, max: 256 },
+ { label: 'F\u00B2 \u2013 F\u2074', min: 256, max: 65536 },
+ { label: 'F\u2070 \u2013 F\u2074', min: 1, max: 65536 },
+ ],
+ braille: [
+ { label: '0 \u2013 9', min: 0, max: 9 },
+ { label: '10 \u2013 99', min: 10, max: 99 },
+ { label: '100 \u2013 999', min: 100, max: 999 },
+ { label: '0 \u2013 9999', min: 0, max: 9999 },
+ ],
+ roman: [
+ { label: '1 \u2013 9', min: 1, max: 9 },
+ { label: '10 \u2013 99', min: 10, max: 99 },
+ { label: '100 \u2013 999', min: 100, max: 999 },
+ { label: '1000 \u2013 3999', min: 1000, max: 3999 },
+ ],
+ greek: [
+ { label: '1 \u2013 9', min: 1, max: 9 },
+ { label: '10 \u2013 99', min: 10, max: 99 },
+ { label: '100 \u2013 999', min: 100, max: 999 },
+ { label: '1000 \u2013 9999', min: 1000, max: 9999 },
+ ],
+ slavonic: [
+ { label: '1 \u2013 9', min: 1, max: 9 },
+ { label: '10 \u2013 99', min: 10, max: 99 },
+ { label: '100 \u2013 999', min: 100, max: 999 },
+ { label: '1000 \u2013 9999', min: 1000, max: 9999 },
+ ],
+ hebrew: [
+ { label: '1 \u2013 9', min: 1, max: 9 },
+ { label: '10 \u2013 99', min: 10, max: 99 },
+ { label: '100 \u2013 499', min: 100, max: 499 },
+ ],
+};
+
+const SYSTEM_GROUPS = {
+ pos: ['binary', 'octal', 'hex', 'ternary', 'braille'],
+ nonpos: ['roman', 'greek', 'slavonic', 'hebrew'],
+};
+
+const MODES = [
+ { label: 'Choose', value: 'choice' },
+ { label: 'Type', value: 'manual' },
+];
+
+const TIMINGS = [
+ { label: '60 sec', value: 60 },
+ { label: '40 sec', value: 40 },
+ { label: '10 sec', value: 10 },
+ { label: 'No limit', value: 0 },
+];
+
+const ROUNDS = [10, 25, 50, 100];
+
+let game = {
+ system: null,
+ range: null,
+ mode: null,
+ timing: null,
+ rounds: null,
+ currentRound: 0,
+ correctCount: 0,
+ correctAnswer: null,
+ answered: false,
+ timerInterval: null,
+ timeLeft: 0,
+ mistakes: [],
+};
+
+function showScreen(id) {
+ document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
+ document.getElementById('screen-' + id).classList.add('active');
+}
+
+function shuffle(arr) {
+ const a = [...arr];
+ for (let i = a.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [a[i], a[j]] = [a[j], a[i]];
+ }
+ return a;
+}
+
+function randInt(min, max) {
+ return min + Math.floor(Math.random() * (max - min + 1));
+}
+
+// ---- SETUP ----
+let setupState = {};
+
+function renderSetupSystem() {
+ setupState = {};
+ const posDiv = document.getElementById('setup-systems-pos');
+ const nonposDiv = document.getElementById('setup-systems-nonpos');
+ posDiv.innerHTML = '';
+ nonposDiv.innerHTML = '';
+
+ for (const [group, keys] of Object.entries(SYSTEM_GROUPS)) {
+ const target = group === 'pos' ? posDiv : nonposDiv;
+ keys.forEach(key => {
+ const btn = document.createElement('button');
+ btn.className = 'option-btn';
+ btn.textContent = SYSTEMS[key].name;
+ btn.addEventListener('click', () => {
+ posDiv.querySelectorAll('.option-btn').forEach(b => b.classList.remove('selected'));
+ nonposDiv.querySelectorAll('.option-btn').forEach(b => b.classList.remove('selected'));
+ btn.classList.add('selected');
+ setupState.system = key;
+ renderSetupRange();
+ });
+ target.appendChild(btn);
+ });
+ }
+
+ showScreen('setup-system');
+}
+
+function renderSetupRange() {
+ const ranges = SYSTEM_RANGES[setupState.system];
+
+ document.getElementById('range-title').textContent = 'Number Range — ' + SYSTEMS[setupState.system].name;
+ const rangesDiv = document.getElementById('setup-ranges');
+ rangesDiv.innerHTML = '';
+ delete setupState.range;
+ delete setupState.rangeData;
+
+ ranges.forEach((r, i) => {
+ const btn = document.createElement('button');
+ btn.className = 'option-btn';
+ btn.textContent = r.label;
+ btn.addEventListener('click', () => {
+ rangesDiv.querySelectorAll('.option-btn').forEach(b => b.classList.remove('selected'));
+ btn.classList.add('selected');
+ setupState.range = i;
+ setupState.rangeData = r;
+ renderSetupOptions();
+ });
+ rangesDiv.appendChild(btn);
+ });
+
+ showScreen('setup-range');
+}
+
+function renderSetupOptions() {
+ const defaultMode = 0;
+ const defaultTiming = 3;
+ const defaultRounds = 0;
+
+ setupState.mode = defaultMode;
+ setupState.timing = defaultTiming;
+ setupState.rounds = defaultRounds;
+
+ const modesDiv = document.getElementById('setup-modes');
+ modesDiv.innerHTML = '';
+ MODES.forEach((m, i) => {
+ const btn = document.createElement('button');
+ btn.className = 'option-btn' + (i === defaultMode ? ' selected' : '');
+ btn.textContent = m.label;
+ btn.addEventListener('click', () => {
+ modesDiv.querySelectorAll('.option-btn').forEach(b => b.classList.remove('selected'));
+ btn.classList.add('selected');
+ setupState.mode = i;
+ });
+ modesDiv.appendChild(btn);
+ });
+
+ const timingDiv = document.getElementById('setup-timing');
+ timingDiv.innerHTML = '';
+ TIMINGS.forEach((t, i) => {
+ const btn = document.createElement('button');
+ btn.className = 'option-btn' + (i === defaultTiming ? ' selected' : '');
+ btn.textContent = t.label;
+ btn.addEventListener('click', () => {
+ timingDiv.querySelectorAll('.option-btn').forEach(b => b.classList.remove('selected'));
+ btn.classList.add('selected');
+ setupState.timing = i;
+ });
+ timingDiv.appendChild(btn);
+ });
+
+ const roundsDiv = document.getElementById('setup-rounds');
+ roundsDiv.innerHTML = '';
+ ROUNDS.forEach((r, i) => {
+ const btn = document.createElement('button');
+ btn.className = 'option-btn' + (i === defaultRounds ? ' selected' : '');
+ btn.textContent = String(r);
+ btn.addEventListener('click', () => {
+ roundsDiv.querySelectorAll('.option-btn').forEach(b => b.classList.remove('selected'));
+ btn.classList.add('selected');
+ setupState.rounds = i;
+ });
+ roundsDiv.appendChild(btn);
+ });
+
+ showScreen('setup-options');
+}
+
+// ---- GAME ----
+function startGame() {
+ game.system = setupState.system;
+ game.range = setupState.rangeData || RANGES[setupState.range];
+ game.mode = MODES[setupState.mode]?.value ?? setupState.mode;
+ game.timing = TIMINGS[setupState.timing]?.value ?? setupState.timing;
+ game.rounds = ROUNDS[setupState.rounds];
+ game.currentRound = 0;
+ game.correctCount = 0;
+ game.mistakes = [];
+
+ document.getElementById('score-correct').textContent = '0';
+ document.getElementById('score-total').textContent = '0';
+ document.getElementById('score-system').textContent = SYSTEMS[game.system].name;
+
+ showScreen('game');
+ nextRound();
+}
+
+function nextRound() {
+ if (game.currentRound >= game.rounds) {
+ endGame();
+ return;
+ }
+
+ game.currentRound++;
+ game.answered = false;
+ game.correctAnswer = randInt(game.range.min, game.range.max);
+
+ document.getElementById('score-total').textContent = game.currentRound;
+ document.getElementById('score-correct').textContent = game.correctCount;
+
+ const display = SYSTEMS[game.system].toDisplay(game.correctAnswer);
+ const numEl = document.getElementById('number-display');
+ numEl.textContent = display;
+ numEl.classList.toggle('small-text', display.length > 12);
+ numEl.classList.toggle('slavonic-font', game.system === 'slavonic');
+
+ document.getElementById('answer-feedback').textContent = '';
+ document.getElementById('answer-feedback').className = 'answer-feedback';
+ document.getElementById('btn-next').classList.add('hidden');
+
+ if (game.mode === 'choice') {
+ renderChoices();
+ } else {
+ renderNumpad();
+ }
+
+ startTimer();
+}
+
+function renderChoices() {
+ const choiceArea = document.getElementById('choice-area');
+ const numpadArea = document.getElementById('numpad-area');
+ choiceArea.classList.remove('hidden');
+ numpadArea.classList.add('hidden');
+
+ const choices = generateChoices(game.correctAnswer, [game.range.min, game.range.max], 3);
+ choices.forEach((val, i) => {
+ const btn = document.getElementById('choice-' + i);
+ btn.textContent = val;
+ btn.className = 'choice-btn';
+ btn.disabled = false;
+ btn.onclick = () => handleChoice(i, val, choices);
+ });
+}
+
+function handleChoice(idx, val, choices) {
+ if (game.answered) return;
+ game.answered = true;
+ stopTimer();
+
+ const feedback = document.getElementById('answer-feedback');
+ const allBtns = choices.map((_, i) => document.getElementById('choice-' + i));
+
+ allBtns.forEach(b => b.disabled = true);
+
+ if (val === game.correctAnswer) {
+ game.correctCount++;
+ document.getElementById('score-correct').textContent = game.correctCount;
+ allBtns[idx].classList.add('correct-choice');
+ feedback.textContent = 'Correct!';
+ feedback.className = 'answer-feedback correct';
+ } else {
+ allBtns[idx].classList.add('incorrect-choice');
+ const correctIdx = choices.indexOf(game.correctAnswer);
+ allBtns[correctIdx].classList.add('correct-choice');
+ feedback.textContent = `Incorrect — answer: ${game.correctAnswer}`;
+ feedback.className = 'answer-feedback incorrect';
+ game.mistakes.push({
+ system: SYSTEMS[game.system].toDisplay(game.correctAnswer),
+ decimal: game.correctAnswer,
+ systemName: SYSTEMS[game.system].name,
+ });
+ }
+
+ document.getElementById('btn-next').classList.remove('hidden');
+}
+
+function renderNumpad() {
+ const choiceArea = document.getElementById('choice-area');
+ const numpadArea = document.getElementById('numpad-area');
+ choiceArea.classList.add('hidden');
+ numpadArea.classList.remove('hidden');
+
+ const inputEl = document.getElementById('numpad-input');
+ inputEl.textContent = '';
+
+ const clearBtn = numpadArea.querySelector('[data-key="clear"]');
+ if (game.system === 'ternary') {
+ clearBtn.textContent = '±';
+ clearBtn.dataset.key = 'sign';
+ } else {
+ clearBtn.textContent = 'C';
+ clearBtn.dataset.key = 'clear';
+ }
+
+ document.getElementById('btn-submit-answer').classList.remove('hidden');
+
+ numpadArea.querySelectorAll('.numpad-key').forEach(key => {
+ key.onclick = () => handleNumpadKey(key.dataset.key);
+ });
+
+ document.getElementById('btn-submit-answer').onclick = () => {
+ if (game.answered) return;
+ const val = parseInt(inputEl.textContent, 10);
+ if (isNaN(val)) return;
+ game.answered = true;
+ stopTimer();
+ document.getElementById('btn-submit-answer').classList.add('hidden');
+
+ const feedback = document.getElementById('answer-feedback');
+ if (val === game.correctAnswer) {
+ game.correctCount++;
+ document.getElementById('score-correct').textContent = game.correctCount;
+ feedback.textContent = 'Correct!';
+ feedback.className = 'answer-feedback correct';
+ } else {
+ feedback.textContent = `Incorrect — answer: ${game.correctAnswer}`;
+ feedback.className = 'answer-feedback incorrect';
+ game.mistakes.push({
+ system: SYSTEMS[game.system].toDisplay(game.correctAnswer),
+ decimal: game.correctAnswer,
+ systemName: SYSTEMS[game.system].name,
+ });
+ }
+
+ document.getElementById('btn-next').classList.remove('hidden');
+ };
+}
+
+function handleNumpadKey(key) {
+ const inputEl = document.getElementById('numpad-input');
+ if (game.answered) return;
+
+ if (key === 'clear') {
+ inputEl.textContent = '';
+ } else if (key === 'backspace') {
+ inputEl.textContent = inputEl.textContent.slice(0, -1);
+ } else if (key === 'sign') {
+ if (inputEl.textContent.startsWith('-')) {
+ inputEl.textContent = inputEl.textContent.slice(1);
+ } else {
+ inputEl.textContent = '-' + inputEl.textContent;
+ }
+ } else {
+ if (inputEl.textContent.length < 12) {
+ inputEl.textContent += key;
+ }
+ }
+}
+
+function startTimer() {
+ stopTimer();
+ const timerEl = document.getElementById('timer-display');
+ const timerArea = document.getElementById('timer-area');
+
+ if (game.timing === 0) {
+ timerEl.textContent = '';
+ timerEl.classList.remove('urgent');
+ timerArea.classList.remove('visible');
+ return;
+ }
+
+ timerArea.classList.add('visible');
+ game.timeLeft = game.timing;
+ timerEl.textContent = game.timeLeft + 's';
+ timerEl.classList.remove('urgent');
+
+ game.timerInterval = setInterval(() => {
+ game.timeLeft--;
+ timerEl.textContent = game.timeLeft + 's';
+
+ if (game.timeLeft <= 10) {
+ timerEl.classList.add('urgent');
+ }
+
+ if (game.timeLeft <= 0) {
+ stopTimer();
+ if (!game.answered) {
+ game.answered = true;
+ const feedback = document.getElementById('answer-feedback');
+ feedback.textContent = `Time's up! Answer: ${game.correctAnswer}`;
+ feedback.className = 'answer-feedback incorrect';
+ game.mistakes.push({
+ system: SYSTEMS[game.system].toDisplay(game.correctAnswer),
+ decimal: game.correctAnswer,
+ systemName: SYSTEMS[game.system].name,
+ });
+
+ document.getElementById('btn-next').classList.remove('hidden');
+
+ if (game.mode === 'choice') {
+ document.querySelectorAll('.choice-btn').forEach(b => b.disabled = true);
+ }
+ }
+ }
+ }, 1000);
+}
+
+function stopTimer() {
+ if (game.timerInterval) {
+ clearInterval(game.timerInterval);
+ game.timerInterval = null;
+ }
+ document.getElementById('timer-display').classList.remove('urgent');
+}
+
+function endGame() {
+ stopTimer();
+ showScreen('results');
+
+ const completed = game.currentRound >= game.rounds;
+ const total = completed ? game.rounds : game.currentRound;
+ const correct = game.correctCount;
+ const pct = total > 0 ? Math.round((correct / total) * 100) : 0;
+
+ const summary = document.getElementById('results-summary');
+ summary.innerHTML = `
+
${correct}/${total}
+ ${pct}% correct
+ `;
+
+ const congratsEl = document.getElementById('results-congrats');
+ const labelEl = document.getElementById('results-mistakes-label');
+ const listEl = document.getElementById('results-list');
+ congratsEl.innerHTML = '';
+ labelEl.textContent = 'Mistakes';
+ labelEl.style.display = '';
+ listEl.innerHTML = '';
+
+ if (game.mistakes.length > 0) {
+ game.mistakes.forEach(m => {
+ const item = document.createElement('div');
+ item.className = 'result-item';
+ item.innerHTML = `
+ ${m.system}
+ \u2192
+ ${m.decimal}
+ `;
+ listEl.appendChild(item);
+ });
+ } else if (completed) {
+ labelEl.style.display = 'none';
+ congratsEl.textContent = 'Congratulations!';
+ spawnConfetti();
+ } else {
+ labelEl.style.display = 'none';
+ }
+}
+
+function spawnConfetti() {
+ const chars = ['🎉', '🎊', '✨', '🎈', '🥳', '💫'];
+ const container = document.getElementById('results-congrats');
+
+ for (let i = 0; i < 3; i++) {
+ setTimeout(() => {
+ const burst = document.createElement('div');
+ burst.className = 'confetti-burst';
+ burst.style.left = 15 + Math.random() * 70 + '%';
+ burst.style.top = 20 + Math.random() * 40 + '%';
+
+ for (let j = 0; j < 12; j++) {
+ const particle = document.createElement('span');
+ particle.className = 'confetti-particle';
+ particle.textContent = chars[Math.floor(Math.random() * chars.length)];
+ particle.style.setProperty('--dx', (Math.random() - 0.5) * 120 + 'px');
+ particle.style.setProperty('--dy', -(40 + Math.random() * 80) + 'px');
+ particle.style.setProperty('--rot', Math.random() * 360 + 'deg');
+ particle.style.animationDuration = (0.6 + Math.random() * 0.4) + 's';
+ burst.appendChild(particle);
+ }
+
+ container.appendChild(burst);
+ setTimeout(() => burst.remove(), 1200);
+ }, i * 500);
+ }
+}
+
+// ---- INIT ----
+
+const SUBSCRIPTS = { '0':'\u2080', '1':'\u2081', '2':'\u2082', '3':'\u2083',
+ '4':'\u2084', '5':'\u2085', '6':'\u2086', '7':'\u2087', '8':'\u2088', '9':'\u2089' };
+
+function toSubscript(n) {
+ return String(n).split('').map(d => SUBSCRIPTS[d]).join('');
+}
+
+const TICKER_SYSTEMS = [
+ { key: 'binary', min: 1, max: 255, prefix: toSubscript(2) },
+ { key: 'octal', min: 1, max: 511, prefix: toSubscript(8) },
+ { key: 'hex', min: 1, max: 255, prefix: '0x' },
+ { key: 'ternary', min: 1, max: 27, prefix: toSubscript(3) },
+ { key: 'braille', min: 1, max: 99, prefix: null },
+ { key: 'roman', min: 1, max: 3999, prefix: null },
+ { key: 'greek', min: 1, max: 999, prefix: null },
+ { key: 'slavonic', min: 1, max: 999, prefix: null },
+];
+
+function generateTickerText() {
+ const pairs = [];
+ for (let i = 0; i < 20; i++) {
+ const sys = TICKER_SYSTEMS[Math.floor(Math.random() * TICKER_SYSTEMS.length)];
+ const num = sys.min + Math.floor(Math.random() * (sys.max - sys.min + 1));
+ const display = SYSTEMS[sys.key].toDisplay(num);
+ const prefixed = sys.prefix ? sys.prefix + display : display;
+ const wrapped = sys.key === 'slavonic'
+ ? '' + prefixed + ''
+ : prefixed;
+ pairs.push(wrapped + ' = ' + num);
+ }
+ return pairs.join('\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0');
+}
+
+const tickerText = generateTickerText();
+document.getElementById('ticker').innerHTML = tickerText + '\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0' + tickerText;
+
+function showSetupMessage(msg) {
+ const containers = document.querySelectorAll('.setup-container');
+ let el = document.getElementById('setup-message');
+ if (!el) {
+ el = document.createElement('div');
+ el.id = 'setup-message';
+ el.className = 'setup-message';
+ }
+ containers.forEach(c => {
+ if (!c.contains(el)) c.prepend(el);
+ });
+ el.textContent = msg;
+ el.classList.add('visible');
+ setTimeout(() => el.classList.remove('visible'), 2500);
+}
+
+document.getElementById('btn-start').addEventListener('click', () => {
+ renderSetupSystem();
+});
+
+document.getElementById('btn-system-back').addEventListener('click', () => {
+ showScreen('menu');
+});
+
+document.getElementById('btn-range-back').addEventListener('click', () => {
+ renderSetupSystem();
+});
+
+document.getElementById('btn-options-back').addEventListener('click', () => {
+ renderSetupRange();
+});
+
+document.getElementById('btn-play').addEventListener('click', () => {
+ if (setupState.mode === undefined || setupState.timing === undefined || setupState.rounds === undefined) {
+ showSetupMessage('Please select all options');
+ return;
+ }
+ startGame();
+});
+
+document.getElementById('btn-next').addEventListener('click', () => {
+ nextRound();
+});
+
+document.getElementById('btn-play-again').addEventListener('click', () => {
+ renderSetupSystem();
+});
+
+document.getElementById('btn-results-menu').addEventListener('click', () => {
+ showScreen('menu');
+});
+
+document.getElementById('btn-stop').addEventListener('click', () => {
+ stopTimer();
+ endGame();
+});
+
+// Keyboard support for numpad
+document.addEventListener('keydown', (e) => {
+ if (!document.getElementById('screen-game').classList.contains('active')) return;
+ if (game.answered) {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ nextRound();
+ }
+ return;
+ }
+
+ if (game.mode === 'manual') {
+ if (e.key >= '0' && e.key <= '9') {
+ handleNumpadKey(e.key);
+ } else if (e.key === 'Backspace') {
+ handleNumpadKey('backspace');
+ } else if (e.key === 'Escape') {
+ handleNumpadKey('clear');
+ } else if (e.key === 'Enter') {
+ document.getElementById('btn-submit-answer').click();
+ }
+ } else if (game.mode === 'choice') {
+ if (e.key === '1' || e.key === '2' || e.key === '3') {
+ const idx = parseInt(e.key) - 1;
+ const btn = document.getElementById('choice-' + idx);
+ if (btn && !btn.disabled) btn.click();
+ }
+ }
+});
diff --git a/icon.svg b/icon.svg
new file mode 100644
index 0000000..ec5a81e
--- /dev/null
+++ b/icon.svg
@@ -0,0 +1,5 @@
+
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..10f17d3
--- /dev/null
+++ b/index.html
@@ -0,0 +1,160 @@
+
+
+
+
+
+
+
+
+ NumNum
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Choose numeral system
+
+
+
+
+
+
+
+
+
+
+
+
+
Number Range
+
+
+
+
+
+
+
+
+
+
+
Difficulty level
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Results
+
+
+
Mistakes
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/manifest.json b/manifest.json
new file mode 100644
index 0000000..75da91c
--- /dev/null
+++ b/manifest.json
@@ -0,0 +1,17 @@
+{
+ "name": "NumNum",
+ "short_name": "NumNum",
+ "description": "Train your numeric systems",
+ "start_url": "/",
+ "display": "standalone",
+ "orientation": "portrait",
+ "background_color": "#1a1a2e",
+ "theme_color": "#1a1a2e",
+ "icons": [
+ {
+ "src": "icon.svg",
+ "sizes": "any",
+ "type": "image/svg+xml"
+ }
+ ]
+}
diff --git a/numerals.js b/numerals.js
new file mode 100644
index 0000000..5c57c25
--- /dev/null
+++ b/numerals.js
@@ -0,0 +1,221 @@
+export const SYSTEMS = {
+ binary: {
+ name: 'Binary',
+ short: 'BIN',
+ toDisplay: (n) => n.toString(2),
+ },
+ octal: {
+ name: 'Octal',
+ short: 'OCT',
+ toDisplay: (n) => n.toString(8),
+ },
+ hex: {
+ name: 'Hexadecimal',
+ short: 'HEX',
+ toDisplay: (n) => n.toString(16).toUpperCase(),
+ },
+ ternary: {
+ name: 'Balanced Ternary',
+ short: 'BT3',
+ toDisplay: toBalTernary,
+ },
+ braille: {
+ name: 'Braille Decimal',
+ short: 'BRL',
+ toDisplay: toBraille,
+ },
+ roman: {
+ name: 'Roman',
+ short: 'ROM',
+ toDisplay: toRoman,
+ },
+ slavonic: {
+ name: 'Church Slavonic',
+ short: 'SLV',
+ toDisplay: toSlavonic,
+ },
+ greek: {
+ name: 'Greek',
+ short: 'GRK',
+ toDisplay: toGreek,
+ },
+ hebrew: {
+ name: 'Hebrew',
+ short: 'HEB',
+ toDisplay: toHebrew,
+ },
+};
+
+function toRoman(num) {
+ if (num <= 0 || num > 3999) return String(num);
+ const vals = [1000,900,500,400,100,90,50,40,10,9,5,4,1];
+ const syms = ['M','CM','D','CD','C','XC','L','XL','X','IX','V','IV','I'];
+ let result = '';
+ for (let i = 0; i < vals.length; i++) {
+ while (num >= vals[i]) {
+ result += syms[i];
+ num -= vals[i];
+ }
+ }
+ return result;
+}
+
+function toBalTernary(num) {
+ if (num === 0) return '0';
+
+ let sign = 1;
+ let n = num;
+ if (n < 0) {
+ sign = -1;
+ n = -n;
+ }
+
+ const digits = [];
+ while (n > 0) {
+ let rem = n % 3;
+ n = Math.floor(n / 3);
+ if (rem === 2) {
+ digits.unshift('\u2296');
+ n += 1;
+ } else if (rem === 1) {
+ digits.unshift('\u2295');
+ } else {
+ digits.unshift('0');
+ }
+ }
+
+ if (sign === -1) {
+ return digits.map(d => {
+ if (d === '\u2295') return '\u2296';
+ if (d === '\u2296') return '\u2295';
+ return d;
+ }).join('');
+ }
+
+ return digits.join('');
+}
+
+const BRAILLE_NUMS = ['\u281A','\u2801','\u2803','\u2809','\u2819','\u2811','\u280B','\u281B','\u2813','\u280A'];
+const BRAILLE_HASH = '\u283C';
+
+function toBraille(num) {
+ if (num < 0) return String(num);
+ if (num === 0) return BRAILLE_HASH + BRAILLE_NUMS[0];
+ return BRAILLE_HASH + String(num).split('').map(d => BRAILLE_NUMS[parseInt(d)]).join('');
+}
+
+const GRK_UNITS = ['\u03B1','\u03B2','\u03B3','\u03B4','\u03B5','\u03DB','\u03B6','\u03B7','\u03B8'];
+const GRK_TENS = ['\u03B9','\u03BA','\u03BB','\u03BC','\u03BD','\u03BE','\u03BF','\u03C0','\u03DF'];
+const GRK_HUNDREDS = ['\u03C1','\u03C3','\u03C4','\u03C5','\u03C6','\u03C7','\u03C8','\u03C9','\u03E1'];
+const GRK_THOU = '\u0375';
+const GRK_KER = '\u1FFD';
+
+function toGreek(num) {
+ if (num <= 0 || num > 9999) return String(num);
+ let result = '';
+ const thou = Math.floor(num / 1000);
+ num %= 1000;
+ const h = Math.floor(num / 100);
+ num %= 100;
+ const t = Math.floor(num / 10);
+ const u = num % 10;
+ if (thou > 0) result += GRK_THOU + GRK_UNITS[thou - 1];
+ if (h > 0) result += GRK_HUNDREDS[h - 1];
+ if (t > 0) result += GRK_TENS[t - 1];
+ if (u > 0) result += GRK_UNITS[u - 1];
+ return result ? result + GRK_KER : '0';
+}
+
+const SLV_UNITS = ['а','в','г','д','є','ѕ','з','и','ѳ'];
+const SLV_TENS = ['і','к','л','м','н','ѯ','ѻ','п','ч'];
+const SLV_HUNDREDS = ['р','с','т','у','ф','х','ѱ','ѿ','ц'];
+const SLV_THOU = '\u0482';
+const SLV_TITLO = '\u0483';
+
+function toSlavonic(num) {
+ if (num <= 0 || num > 9999) return String(num);
+
+ const groups = [];
+ const thou = Math.floor(num / 1000);
+ num %= 1000;
+ const h = Math.floor(num / 100);
+ num %= 100;
+ const t = Math.floor(num / 10);
+ const u = num % 10;
+
+ if (thou > 0) groups.push({ p: SLV_THOU, l: SLV_UNITS[thou - 1] });
+
+ if (h > 0) groups.push({ p: '', l: SLV_HUNDREDS[h - 1] });
+
+ const twoDigit = t * 10 + u;
+ if (twoDigit >= 11 && twoDigit <= 19) {
+ if (u > 0) groups.push({ p: '', l: SLV_UNITS[u - 1] });
+ if (t > 0) groups.push({ p: '', l: SLV_TENS[t - 1] });
+ } else {
+ if (t > 0) groups.push({ p: '', l: SLV_TENS[t - 1] });
+ if (u > 0) groups.push({ p: '', l: SLV_UNITS[u - 1] });
+ }
+
+ if (groups.length === 0) return '0';
+
+ const titloIdx = groups.length >= 2 ? groups.length - 2 : 0;
+ groups[titloIdx].l += SLV_TITLO;
+
+ return groups.map(g => g.p + g.l).join('') + '.';
+}
+
+const HEB_UNITS = ['\u05D0','\u05D1','\u05D2','\u05D3','\u05D4','\u05D5','\u05D6','\u05D7','\u05D8'];
+const HEB_TENS = ['\u05D9','\u05DB','\u05DC','\u05DE','\u05E0','\u05E1','\u05E2','\u05E4','\u05E6'];
+const HEB_HUNDREDS = ['\u05E7','\u05E8','\u05E9','\u05EA'];
+const HEB_GERESH = '\u05F3';
+const HEB_GERSHAYIM = '\u05F4';
+
+function toHebrew(num) {
+ if (num <= 0 || num > 499) return String(num);
+
+ if (num === 15) return '\u05D8' + HEB_GERSHAYIM + '\u05D5';
+ if (num === 16) return '\u05D8' + HEB_GERSHAYIM + '\u05D6';
+
+ const letters = [];
+ const h = Math.floor(num / 100);
+ num %= 100;
+ const t = Math.floor(num / 10);
+ const u = num % 10;
+
+ if (h > 0) letters.push(HEB_HUNDREDS[h - 1]);
+ if (t > 0) letters.push(HEB_TENS[t - 1]);
+ if (u > 0) letters.push(HEB_UNITS[u - 1]);
+
+ if (letters.length === 0) return '0';
+ if (letters.length === 1) return letters[0] + HEB_GERESH;
+ return letters.slice(0, -1).join('') + HEB_GERSHAYIM + letters[letters.length - 1];
+}
+
+export function generateChoices(correctAnswer, allRanges, count = 3) {
+ const choices = new Set([correctAnswer]);
+ const [min, max] = allRanges;
+ let attempts = 0;
+ while (choices.size < count && attempts < 100) {
+ let wrong;
+ const offset = Math.floor(Math.random() * 20) + 1;
+ if (Math.random() < 0.5) {
+ wrong = correctAnswer + offset;
+ } else {
+ wrong = correctAnswer - offset;
+ }
+ if (wrong >= min && wrong <= max && wrong !== correctAnswer) {
+ choices.add(wrong);
+ }
+ attempts++;
+ }
+ while (choices.size < count) {
+ const r = min + Math.floor(Math.random() * (max - min + 1));
+ choices.add(r);
+ }
+ const arr = Array.from(choices);
+ for (let i = arr.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [arr[i], arr[j]] = [arr[j], arr[i]];
+ }
+ return arr;
+}
diff --git a/styles.css b/styles.css
new file mode 100644
index 0000000..37066e6
--- /dev/null
+++ b/styles.css
@@ -0,0 +1,616 @@
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+:root {
+ --bg: #1a1a2e;
+ --bg-light: #16213e;
+ --bg-card: #0f3460;
+ --accent: #e94560;
+ --accent-hover: #ff6b81;
+ --text: #eaeaea;
+ --text-dim: #a0a0b0;
+ --correct: #2ecc71;
+ --incorrect: #e74c3c;
+ --border-radius: 12px;
+ --safe-top: env(safe-area-inset-top);
+ --safe-bottom: env(safe-area-inset-bottom);
+}
+
+@font-face {
+ font-family: 'Ponomar';
+ src: url('Ponomar-Regular.woff') format('woff');
+ font-weight: normal;
+ font-style: normal;
+ font-display: swap;
+}
+
+html, body {
+ height: 100%;
+ overflow: hidden;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ background: var(--bg);
+ color: var(--text);
+ -webkit-tap-highlight-color: transparent;
+ user-select: none;
+ -webkit-user-select: none;
+}
+
+#app {
+ height: 100%;
+ padding-top: var(--safe-top);
+ padding-bottom: var(--safe-bottom);
+}
+
+.screen {
+ display: none;
+ height: 100%;
+ overflow-y: auto;
+}
+
+.screen.active {
+ display: flex;
+ flex-direction: column;
+}
+
+/* MENU */
+.menu-container {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 16px;
+ padding: 32px;
+}
+
+.logo {
+ font-size: 3.5rem;
+ font-weight: 800;
+ background: linear-gradient(135deg, var(--accent), #ff9a56);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+ margin-bottom: 8px;
+}
+
+.subtitle {
+ color: var(--text-dim);
+ font-size: 1.1rem;
+ margin-bottom: 16px;
+}
+
+.ticker-wrap {
+ overflow: hidden;
+ width: 100%;
+ margin-bottom: 32px;
+}
+
+.ticker {
+ display: inline-block;
+ white-space: nowrap;
+ color: var(--text-dim);
+ font-size: 1.05rem;
+ font-family: monospace;
+ animation: ticker-scroll 40s linear infinite;
+ padding-left: 100%;
+}
+
+@keyframes ticker-scroll {
+ 0% { transform: translateX(0); }
+ 100% { transform: translateX(-50%); }
+}
+
+/* BUTTONS */
+.btn {
+ display: block;
+ width: 100%;
+ padding: 14px 24px;
+ border: none;
+ border-radius: var(--border-radius);
+ font-size: 1rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: transform 0.1s, opacity 0.1s;
+ text-align: center;
+}
+
+.btn:active {
+ transform: scale(0.97);
+ opacity: 0.9;
+}
+
+.btn-primary {
+ background: var(--accent);
+ color: white;
+}
+
+.btn-secondary {
+ background: var(--bg-card);
+ color: var(--text);
+}
+
+.btn-large {
+ padding: 18px 32px;
+ font-size: 1.2rem;
+ max-width: 320px;
+ margin: 0 auto;
+}
+
+/* SETUP */
+.setup-container {
+ padding: 24px;
+ max-width: 480px;
+ margin: 0 auto;
+ width: 100%;
+ padding-bottom: 100px;
+}
+
+.setup-container h2 {
+ margin-bottom: 20px;
+}
+
+.setup-section {
+ margin-bottom: 28px;
+}
+
+.setup-section h3 {
+ color: var(--text-dim);
+ font-size: 0.9rem;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ margin-bottom: 12px;
+}
+
+.system-group {
+ margin-bottom: 28px;
+}
+
+.system-group h3 {
+ color: var(--text-dim);
+ font-size: 0.9rem;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ margin-bottom: 12px;
+}
+
+.option-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 8px;
+}
+
+.option-btn {
+ padding: 14px 12px;
+ background: var(--bg-light);
+ border: 2px solid transparent;
+ border-radius: 8px;
+ color: var(--text);
+ font-size: 0.95rem;
+ font-weight: 500;
+ cursor: pointer;
+ text-align: center;
+ transition: border-color 0.15s, background 0.15s;
+}
+
+.option-btn.selected {
+ border-color: var(--accent);
+ background: rgba(233, 69, 96, 0.15);
+}
+
+.option-btn:active {
+ transform: scale(0.97);
+}
+
+#btn-play {
+ margin-top: 16px;
+ max-width: 320px;
+ margin-left: auto;
+ margin-right: auto;
+}
+
+#btn-options-back {
+ margin-top: 12px;
+}
+
+#btn-setup-back, #btn-config-back {
+ margin-top: 12px;
+ max-width: 320px;
+ margin-left: auto;
+ margin-right: auto;
+}
+
+/* GAME */
+.game-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 16px 20px;
+ background: var(--bg-light);
+ flex-shrink: 0;
+ position: relative;
+}
+
+.score-display {
+ font-size: 1.2rem;
+ font-weight: 700;
+}
+
+#score-system {
+ margin-left: 8px;
+ color: var(--text-dim);
+ font-size: 0.9rem;
+}
+
+.timer-area {
+ position: absolute;
+ left: 50%;
+ transform: translateX(-50%);
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ visibility: hidden;
+}
+
+.timer-area.visible {
+ visibility: visible;
+}
+
+.timer-label {
+ color: var(--text-dim);
+ font-size: 0.85rem;
+}
+
+.timer-display {
+ font-size: 1.3rem;
+ font-weight: 700;
+ color: var(--accent);
+ min-width: 60px;
+ text-align: right;
+}
+
+.header-right {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.btn-stop {
+ width: 36px;
+ height: 36px;
+ border: none;
+ border-radius: 50%;
+ background: var(--bg-card);
+ color: var(--text-dim);
+ font-size: 1.4rem;
+ line-height: 1;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background 0.15s;
+}
+
+.btn-stop:active {
+ background: var(--accent);
+ color: white;
+}
+
+.timer-display.urgent {
+ color: var(--incorrect);
+ animation: pulse 0.5s infinite;
+}
+
+@keyframes pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.5; }
+}
+
+.game-body {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 24px 20px;
+ overflow-y: auto;
+}
+
+.number-display {
+ font-size: 2.8rem;
+ font-weight: 800;
+ text-align: center;
+ padding: 32px 20px;
+ margin: 16px 0;
+ background: var(--bg-card);
+ border-radius: var(--border-radius);
+ width: 100%;
+ max-width: 400px;
+ word-break: break-all;
+ line-height: 1.3;
+ min-height: 100px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.number-display.small-text {
+ font-size: 1.6rem;
+}
+
+.slavonic-font {
+ font-family: 'Ponomar', serif;
+}
+
+.number-display.slavonic-font {
+ font-size: 3.2rem;
+}
+
+.answer-feedback {
+ min-height: 40px;
+ font-size: 1.1rem;
+ font-weight: 600;
+ text-align: center;
+ margin: 8px 0;
+ transition: opacity 0.2s;
+}
+
+.answer-feedback.correct {
+ color: var(--correct);
+}
+
+.answer-feedback.incorrect {
+ color: var(--incorrect);
+}
+
+/* CHOICES */
+.choice-area {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ width: 100%;
+ max-width: 400px;
+ margin-top: 16px;
+}
+
+.choice-btn {
+ padding: 18px 20px;
+ background: var(--bg-card);
+ border: 2px solid transparent;
+ border-radius: var(--border-radius);
+ color: var(--text);
+ font-size: 1.3rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: border-color 0.15s, background 0.15s;
+}
+
+.choice-btn:active {
+ transform: scale(0.98);
+}
+
+.choice-btn.correct-choice {
+ border-color: var(--correct);
+ background: rgba(46, 204, 113, 0.15);
+}
+
+.choice-btn.incorrect-choice {
+ border-color: var(--incorrect);
+ background: rgba(231, 76, 60, 0.15);
+}
+
+.choice-btn:disabled {
+ opacity: 0.7;
+ cursor: default;
+}
+
+/* NUMPAD */
+.numpad-area {
+ width: 100%;
+ max-width: 360px;
+ margin-top: 16px;
+}
+
+.numpad-input {
+ font-size: 2rem;
+ font-weight: 700;
+ text-align: center;
+ padding: 16px;
+ background: var(--bg-card);
+ border-radius: 8px;
+ min-height: 60px;
+ margin-bottom: 16px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ letter-spacing: 2px;
+ overflow: hidden;
+}
+
+.numpad {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 8px;
+ margin-bottom: 16px;
+}
+
+.numpad-key {
+ padding: 18px;
+ background: var(--bg-light);
+ border: none;
+ border-radius: 8px;
+ color: var(--text);
+ font-size: 1.4rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.1s;
+}
+
+.numpad-key:active {
+ background: var(--bg-card);
+}
+
+.numpad-action {
+ background: var(--bg-card);
+ color: var(--text-dim);
+}
+
+#btn-submit-answer {
+ max-width: 320px;
+ margin: 0 auto;
+}
+
+.hidden {
+ display: none !important;
+}
+
+#btn-next {
+ margin-top: 16px;
+ max-width: 320px;
+}
+
+/* RESULTS */
+.results-container {
+ padding: 24px;
+ max-width: 480px;
+ margin: 0 auto;
+ width: 100%;
+ padding-bottom: 100px;
+ position: relative;
+}
+
+.results-container h2 {
+ margin-bottom: 16px;
+}
+
+.results-summary {
+ font-size: 1.2rem;
+ margin-bottom: 24px;
+ padding: 20px;
+ background: var(--bg-card);
+ border-radius: var(--border-radius);
+ text-align: center;
+}
+
+.results-summary .big-number {
+ font-size: 2.5rem;
+ font-weight: 800;
+ color: var(--accent);
+}
+
+.results-congrats {
+ text-align: center;
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: var(--correct);
+ margin-bottom: 16px;
+ min-height: 48px;
+}
+
+.results-mistakes-label {
+ color: var(--text-dim);
+ font-size: 0.9rem;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ margin-bottom: 12px;
+}
+
+.results-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-bottom: 24px;
+}
+
+.result-item {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 14px 16px;
+ background: var(--bg-light);
+ border-radius: 8px;
+ border-left: 4px solid var(--incorrect);
+}
+
+.result-system {
+ font-size: 1.4rem;
+ font-weight: 700;
+ flex-shrink: 0;
+ min-width: 80px;
+ word-break: break-all;
+}
+
+.result-arrow {
+ color: var(--text-dim);
+ flex-shrink: 0;
+}
+
+.result-decimal {
+ font-size: 1.2rem;
+ font-weight: 600;
+ color: var(--correct);
+}
+
+.no-mistakes {
+ text-align: center;
+ color: var(--correct);
+ font-size: 1.2rem;
+ padding: 32px;
+}
+
+#btn-play-again {
+ max-width: 320px;
+ margin: 0 auto 12px;
+}
+
+#btn-results-menu {
+ max-width: 320px;
+ margin: 0 auto;
+}
+
+.setup-message {
+ display: none;
+ background: var(--accent);
+ color: white;
+ padding: 14px 20px;
+ border-radius: var(--border-radius);
+ font-weight: 600;
+ text-align: center;
+ margin-bottom: 16px;
+ animation: fadeIn 0.2s;
+}
+
+.setup-message.visible {
+ display: block;
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; transform: translateY(-8px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+.confetti-burst {
+ position: absolute;
+ pointer-events: none;
+}
+
+.confetti-particle {
+ position: absolute;
+ font-size: 1.4rem;
+ animation: confetti-fly 0.8s ease-out forwards;
+}
+
+@keyframes confetti-fly {
+ 0% {
+ transform: translate(0, 0) rotate(0deg);
+ opacity: 1;
+ }
+ 100% {
+ transform: translate(var(--dx), var(--dy)) rotate(var(--rot));
+ opacity: 0;
+ }
+}
diff --git a/sw.js b/sw.js
new file mode 100644
index 0000000..83ac76c
--- /dev/null
+++ b/sw.js
@@ -0,0 +1,32 @@
+const CACHE_NAME = 'numnum-v30';
+const ASSETS = [
+ '/',
+ '/index.html',
+ '/styles.css',
+ '/app.js',
+ '/numerals.js',
+ '/manifest.json',
+ '/icon.svg',
+];
+
+self.addEventListener('install', (e) => {
+ e.waitUntil(
+ caches.open(CACHE_NAME).then(cache => cache.addAll(ASSETS))
+ );
+ self.skipWaiting();
+});
+
+self.addEventListener('activate', (e) => {
+ e.waitUntil(
+ caches.keys().then(keys =>
+ Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
+ )
+ );
+ self.clients.claim();
+});
+
+self.addEventListener('fetch', (e) => {
+ e.respondWith(
+ caches.match(e.request).then(r => r || fetch(e.request))
+ );
+});