Custom Code Experiment •
Satu Dua Tiga
Source Code View HTML / CSS / JavaScript
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Satu Dua Tiga (1,2,3) — counting in Indonesian</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Anton&family=Archivo:ital,wght@1,600;1,700;1,800;1,900&display=swap" rel="stylesheet">
<style>
:root {
--cream: #EFE9DF;
--cream-deep: #E4DCCE;
--pink: #EE6DB4;
--pink-press: #D8509C;
--cyan: #4FC3F2;
--yellow: #F7D14B;
--ink: #000;
--r: 9px;
--bd: 4px;
--shadow: 5px 5px 0 var(--ink);
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
}
body {
background: var(--cream);
color: var(--ink);
font-family: 'Archivo', 'Arial Narrow', Arial, sans-serif;
font-style: italic;
font-weight: 800;
-webkit-font-smoothing: antialiased;
min-height: 100vh;
padding: 26px 16px 60px;
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
opacity: .5;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='260' height='260'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='260' height='260' filter='url(%23n)' opacity='0.28'/%3E%3C/svg%3E");
}
.wrap {
position: relative;
z-index: 1;
max-width: 660px;
margin: 0 auto;
}
.eyebrow-row {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
margin-bottom: 2px;
}
.eyebrow {
font-size: clamp(13px, 3.2vw, 17px);
letter-spacing: .02em;
text-transform: uppercase;
font-weight: 900;
margin: 0;
}
.streak {
font-size: clamp(12px, 3vw, 15px);
text-transform: uppercase;
font-weight: 900;
white-space: nowrap;
}
.streak b {
background: var(--yellow);
border: 2px solid var(--ink);
padding: 1px 7px;
border-radius: 5px;
display: inline-block;
}
.btn-mute {
font-size: 11px;
padding: 3px 8px;
border-width: 2px;
border-radius: 5px;
box-shadow: 2px 2px 0 var(--ink);
margin-left: 7px;
vertical-align: 2px;
}
.btn-mute[aria-pressed="true"] {
background: var(--cream-deep);
opacity: .65;
}
.btn-mute:active {
transform: translate(2px, 2px);
}
.display {
font-family: 'Anton', Impact, 'Arial Narrow Bold', sans-serif;
font-style: normal;
font-weight: 400;
text-transform: uppercase;
color: var(--pink);
-webkit-text-stroke: 5px var(--ink);
paint-order: stroke fill;
text-shadow: 7px 8px 0 var(--ink);
transform: skewX(-8deg);
transform-origin: left bottom;
line-height: .82;
letter-spacing: -.01em;
margin: 0;
user-select: none;
}
.numeral {
font-size: clamp(120px, 30vw, 215px);
display: block;
padding-left: 6px;
}
.word {
font-size: clamp(34px, 10.5vw, 74px);
display: block;
padding-left: 6px;
margin-top: 6px;
min-height: 1.05em;
-webkit-text-stroke: 4px var(--ink);
text-shadow: 5px 6px 0 var(--ink);
color: var(--cream);
}
.word.solved {
color: var(--pink);
}
.word.blank {
color: transparent;
-webkit-text-stroke: 0;
text-shadow: none;
}
.bar {
display: flex;
gap: 5px;
border: var(--bd) solid var(--ink);
border-radius: var(--r);
background: var(--cream-deep);
padding: 6px;
margin: 22px 0 14px;
box-shadow: var(--shadow);
}
.sq {
flex: 1;
height: 26px;
border: 3px solid var(--ink);
border-radius: 4px;
background: transparent;
}
.sq.on {
background: var(--pink);
box-shadow: inset -4px -4px 0 rgba(0, 0, 0, .22), inset 4px 4px 0 rgba(255, 255, 255, .35);
}
.panel {
border: var(--bd) solid var(--ink);
border-radius: var(--r);
background: var(--cream);
box-shadow: var(--shadow);
overflow: hidden;
margin-bottom: 22px;
}
.panel-body {
padding: 18px;
}
.label {
display: block;
text-transform: uppercase;
font-size: 13px;
font-weight: 900;
margin: 0 0 8px;
}
input[type=text] {
width: 100%;
font-family: 'Archivo', Arial, sans-serif;
font-style: italic;
font-weight: 900;
font-size: clamp(20px, 5.4vw, 28px);
text-transform: lowercase;
color: var(--ink);
background: #fff;
border: var(--bd) solid var(--ink);
border-radius: var(--r);
padding: 12px 14px;
outline: none;
}
input[type=text]::placeholder {
color: #9a938a;
}
input[type=text]:focus {
background: #FFF8E6;
box-shadow: inset 0 0 0 3px var(--yellow);
}
input[type=text].wrong {
animation: shake .32s;
}
input[type=text].right {
background: var(--cyan);
}
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
20% {
transform: translateX(-9px);
}
45% {
transform: translateX(8px);
}
70% {
transform: translateX(-5px);
}
}
.feedback {
text-transform: uppercase;
font-size: clamp(13px, 3.4vw, 15px);
font-weight: 900;
margin: 10px 2px 0;
min-height: 1.3em;
letter-spacing: .01em;
}
.feedback.ok {
color: #1B7FAE;
}
.feedback.no {
color: var(--pink-press);
}
.row {
display: flex;
gap: 10px;
margin-top: 14px;
flex-wrap: wrap;
}
button {
font-family: 'Archivo', Arial, sans-serif;
font-style: italic;
font-weight: 900;
text-transform: uppercase;
color: var(--ink);
border: var(--bd) solid var(--ink);
border-radius: var(--r);
background: var(--cream);
cursor: pointer;
padding: 11px 16px;
font-size: 15px;
box-shadow: 4px 4px 0 var(--ink);
transition: transform .06s, box-shadow .06s;
}
button:hover {
background: var(--cream-deep);
}
button:active {
transform: translate(4px, 4px);
box-shadow: 0 0 0 var(--ink);
}
button:focus-visible {
outline: 4px solid var(--yellow);
outline-offset: 3px;
}
.btn-audio {
background: var(--cyan);
flex: 1;
min-width: 150px;
}
.btn-audio:hover {
background: #37B4E8;
}
.btn-audio.speaking {
background: var(--yellow);
}
.strip {
background: var(--cyan);
border-top: var(--bd) solid var(--ink);
padding: 18px;
display: flex;
gap: 12px;
}
.btn-check {
flex: 1;
background: var(--pink);
font-size: clamp(20px, 5vw, 27px);
padding: 14px 10px;
letter-spacing: .01em;
}
.btn-check:hover {
background: var(--pink-press);
}
.btn-skip {
background: var(--cream);
flex: 0 0 auto;
}
.sets {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.set {
display: flex;
align-items: center;
gap: 11px;
border: var(--bd) solid var(--ink);
border-radius: var(--r);
padding: 12px 14px;
cursor: pointer;
background: var(--cream);
text-transform: uppercase;
font-size: 14px;
font-weight: 900;
line-height: 1.15;
user-select: none;
}
.set:hover {
background: var(--cream-deep);
}
.set input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.dot {
flex: 0 0 auto;
width: 19px;
height: 19px;
border-radius: 50%;
border: 4px solid var(--ink);
background: var(--cream);
}
.set input:checked~.dot {
background: var(--pink);
box-shadow: inset 0 0 0 2px var(--cream);
}
.set:has(input:checked) {
box-shadow: var(--shadow);
}
.set:has(input:focus-visible) {
outline: 4px solid var(--yellow);
outline-offset: 3px;
}
.chart-toggle {
display: block;
margin: 0 auto;
background: none;
border: none;
box-shadow: none;
text-decoration: underline;
text-underline-offset: 4px;
text-decoration-thickness: 3px;
font-size: clamp(14px, 3.6vw, 17px);
padding: 6px;
}
.chart-toggle:hover {
background: none;
color: var(--pink-press);
}
.chart-toggle:active {
transform: none;
}
.chart {
margin-top: 16px;
border: var(--bd) solid var(--ink);
border-radius: var(--r);
background: var(--cream);
box-shadow: var(--shadow);
padding: 14px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
gap: 8px;
}
.chart[hidden] {
display: none;
}
.chip {
display: flex;
align-items: baseline;
gap: 8px;
border: 3px solid var(--ink);
border-radius: 6px;
background: var(--cream);
box-shadow: none;
padding: 8px 10px;
font-size: 14px;
text-transform: lowercase;
width: 100%;
}
.chip:active {
transform: translate(2px, 2px);
}
.chip b {
font-family: 'Anton', Impact, sans-serif;
font-style: normal;
font-size: 17px;
min-width: 32px;
text-align: right;
}
.note {
text-transform: uppercase;
font-size: 12px;
font-weight: 900;
text-align: center;
margin: 16px auto 0;
max-width: 520px;
line-height: 1.45;
opacity: .75;
}
@media (max-width:520px) {
.sets {
grid-template-columns: 1fr;
}
.strip {
flex-direction: column;
}
.btn-skip {
width: 100%;
}
}
@media (prefers-reduced-motion:reduce) {
* {
animation: none !important;
transition: none !important;
}
}
</style>
</head>
<body>
<div class="wrap">
<div class="eyebrow-row">
<p class="eyebrow">Counting in Indonesian</p>
<p class="streak">Streak <b id="streak">0</b>
<button class="btn-mute" id="mute" type="button" aria-pressed="false" title="Turn sound effects off">Sound on</button>
</p>
</div>
<h1 class="display"><span class="numeral" id="numeral">1</span></h1>
<p class="display word blank" id="word" aria-live="polite">satu</p>
<div class="bar" id="bar" aria-hidden="true"></div>
<div class="panel">
<div class="panel-body">
<label class="label" for="answer">Write the number in Indonesian</label>
<input id="answer" type="text" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" placeholder="type it here">
<p class="feedback" id="feedback"></p>
<div class="row">
<button class="btn-audio" id="hear" type="button">🔊 Hear it</button>
<button id="hint" type="button">Hint</button>
</div>
</div>
<div class="strip">
<button class="btn-check" id="check" type="button">Check</button>
<button class="btn-skip" id="skip" type="button">Skip</button>
</div>
</div>
<div class="panel">
<div class="panel-body">
<span class="label">Pick a set</span>
<div class="sets" id="sets"></div>
</div>
</div>
<button class="chart-toggle" id="chartToggle" type="button" aria-expanded="false">Number chart + pronunciation</button>
<div class="chart" id="chart" hidden></div>
<p class="note" id="note"></p>
</div>
<script>
(function() {
'use strict';
var UNITS = ['', 'satu', 'dua', 'tiga', 'empat', 'lima', 'enam', 'tujuh', 'delapan', 'sembilan'];
function toIndo(n) {
if (n === 0) return 'nol';
if (n < 10) return UNITS[n];
if (n === 10) return 'sepuluh';
if (n === 11) return 'sebelas';
if (n < 20) return UNITS[n - 10] + ' belas';
if (n < 100) {
var t = Math.floor(n / 10),
r = n % 10;
return UNITS[t] + ' puluh' + (r ? ' ' + UNITS[r] : '');
}
if (n === 100) return 'seratus';
if (n < 200) return 'seratus' + (n % 100 ? ' ' + toIndo(n % 100) : '');
if (n < 1000) {
var h = Math.floor(n / 100),
rr = n % 100;
return UNITS[h] + ' ratus' + (rr ? ' ' + toIndo(rr) : '');
}
if (n === 1000) return 'seribu';
return String(n);
}
function range(a, b) {
var out = [];
for (var i = a; i <= b; i++) out.push(i);
return out;
}
function randInt(a, b) {
return a + Math.floor(Math.random() * (b - a + 1));
}
var SETS = [{
id: '1-10',
label: '1 – 10',
build: function() {
return range(1, 10);
}
}, {
id: '11-20',
label: '11 – 20',
build: function() {
return range(11, 20);
}
}, {
id: 'tens',
label: 'Tens to 100',
build: function() {
return [10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
}
}, {
id: '21-99',
label: '21 – 99',
build: function() {
var o = [];
while (o.length < 10) {
var n = randInt(21, 99);
if (o.indexOf(n) < 0) o.push(n);
}
return o;
}
}, {
id: '100s',
label: '100 – 1000',
build: function() {
var o = [100, 1000];
while (o.length < 10) {
var n = randInt(101, 999);
if (o.indexOf(n) < 0) o.push(n);
}
return o;
}
}, {
id: 'mixed',
label: 'Mixed 1 – 1000',
build: function() {
var o = [];
while (o.length < 10) {
var r = Math.random(),
n;
if (r < .35) n = randInt(1, 20);
else if (r < .55) n = randInt(2, 10) * 10;
else if (r < .85) n = randInt(21, 99);
else n = randInt(100, 1000);
if (o.indexOf(n) < 0) o.push(n);
}
return o;
}
}];
var state = {
set: SETS[0],
queue: [],
current: 1,
solved: false,
hints: 0,
tries: 0,
streak: 0,
filled: 0,
barNo: 1,
pending: null,
advance: null
};
var $ = function(id) { return document.getElementById(id); };
var elNumeral = $('numeral'),
elWord = $('word'),
elAnswer = $('answer'),
elFeedback = $('feedback'),
elBar = $('bar'),
elStreak = $('streak'),
elHear = $('hear'),
elHint = $('hint'),
elCheck = $('check'),
elSkip = $('skip'),
elSets = $('sets'),
elChart = $('chart'),
elChartToggle = $('chartToggle'),
elNote = $('note'),
elMute = $('mute');
var actx = null,
muted = false;
function audio() {
if (muted) return null;
if (actx === null) {
var AC = window.AudioContext || window.webkitAudioContext;
actx = AC ? new AC() : false;
}
if (actx && actx.state === 'suspended') actx.resume();
return actx || null;
}
function tone(opts) {
var ctx = audio();
if (!ctx) return;
var osc = ctx.createOscillator();
var gain = ctx.createGain();
var t0 = ctx.currentTime + (opts.at || 0);
var dur = opts.dur || 0.16;
osc.type = opts.type || 'triangle';
osc.frequency.setValueAtTime(opts.freq, t0);
if (opts.to) osc.frequency.exponentialRampToValueAtTime(opts.to, t0 + dur);
gain.gain.setValueAtTime(0.0001, t0);
gain.gain.exponentialRampToValueAtTime(opts.vol || 0.18, t0 + 0.012);
gain.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(t0);
osc.stop(t0 + dur + 0.03);
}
function playWin() {
var notes = [1046.5, 1318.5, 1568.0, 2093.0];
for (var i = 0; i < notes.length; i++) {
tone({
freq: notes[i],
at: i * 0.075,
dur: i === 3 ? 0.36 : 0.15,
vol: i === 3 ? 0.2 : 0.15,
type: 'triangle'
});
}
}
function playFail() {
tone({ freq: 196, to: 150, dur: 0.16, vol: 0.16, type: 'square' });
tone({ freq: 147, to: 104, dur: 0.30, vol: 0.16, type: 'square', at: 0.13 });
}
var voice = null,
voiceChecked = false;
function pickVoice() {
if (!('speechSynthesis' in window)) return;
var voices = window.speechSynthesis.getVoices() || [];
for (var i = 0; i < voices.length; i++) {
var l = (voices[i].lang || '').toLowerCase();
if (l.indexOf('id') === 0 || l.indexOf('in-id') === 0) {
voice = voices[i];
break;
}
}
if (voices.length && !voiceChecked) {
voiceChecked = true;
if (!voice) {
elNote.textContent =
'No Indonesian voice installed on this device, so the audio uses your default voice and the pronunciation will be rough. Adding an Indonesian language pack in your system settings fixes it.';
}
}
}
if ('speechSynthesis' in window) {
pickVoice();
window.speechSynthesis.onvoiceschanged = pickVoice;
} else {
elNote.textContent =
'This browser has no speech support, so the audio button will not play anything. Chrome, Edge, or Safari will.';
elHear.disabled = true;
}
function speak(text, rate, done) {
if (!('speechSynthesis' in window)) {
if (done) done();
return;
}
window.speechSynthesis.cancel();
var u = new SpeechSynthesisUtterance(text);
u.lang = 'id-ID';
u.rate = rate || 0.82;
u.pitch = 1;
if (voice) u.voice = voice;
u.onstart = function() {
elHear.classList.add('speaking');
};
u.onend = u.onerror = function() {
elHear.classList.remove('speaking');
if (done) done();
};
window.speechSynthesis.speak(u);
}
function normalise(s) {
return String(s).toLowerCase()
.replace(/[^a-z\s]/g, '')
.replace(/\s+/g, ' ')
.trim();
}
function loose(s) {
return normalise(s).replace(/\s/g, '');
}
function isCorrect(input, answer) {
return loose(input) === loose(answer) && loose(input) !== '';
}
function drawBar() {
elBar.innerHTML = '';
for (var i = 0; i < 10; i++) {
var d = document.createElement('div');
d.className = 'sq' + (i < state.filled ? ' on' : '');
elBar.appendChild(d);
}
}
function drawSets() {
elSets.innerHTML = '';
SETS.forEach(function(s) {
var lab = document.createElement('label');
lab.className = 'set';
var input = document.createElement('input');
input.type = 'radio';
input.name = 'set';
input.value = s.id;
input.checked = (s.id === state.set.id);
input.addEventListener('change', function() {
state.set = s;
state.queue = [];
nextCard();
});
var dot = document.createElement('span');
dot.className = 'dot';
var span = document.createElement('span');
span.textContent = s.label;
lab.appendChild(input);
lab.appendChild(dot);
lab.appendChild(span);
elSets.appendChild(lab);
});
}
function drawChart() {
elChart.innerHTML = '';
var list = range(1, 20).concat([30, 40, 50, 60, 70, 80, 90, 100, 1000]);
list.forEach(function(n) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'chip';
b.innerHTML = '<b></b><span></span>';
b.querySelector('b').textContent = n;
b.querySelector('span').textContent = toIndo(n);
b.addEventListener('click', function() {
speak(toIndo(n));
});
elChart.appendChild(b);
});
}
function refillQueue() {
var items = state.set.build();
for (var i = items.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var t = items[i];
items[i] = items[j];
items[j] = t;
}
if (items.length > 1 && items[0] === state.current) items.push(items.shift());
state.queue = items;
}
function nextCard() {
if (!state.queue.length) refillQueue();
state.current = state.queue.shift();
state.solved = false;
state.hints = 0;
state.tries = 0;
clearPending();
elNumeral.textContent = state.current;
elWord.textContent = toIndo(state.current);
elWord.className = 'display word blank';
elAnswer.value = '';
elAnswer.className = '';
elFeedback.textContent = '';
elFeedback.className = 'feedback';
elCheck.textContent = 'Check';
elHint.textContent = 'Hint';
elHint.disabled = false;
elAnswer.focus();
}
function clearPending() {
if (state.pending) {
clearTimeout(state.pending);
state.pending = null;
}
state.advance = null;
}
function reveal(solvedByUser) {
state.solved = true;
elWord.className = 'display word' + (solvedByUser ? ' solved' : '');
elCheck.textContent = 'Next number';
elHint.disabled = true;
}
function advanceAfterWin() {
clearPending();
var done = false;
var go = function() {
if (done) return;
done = true;
clearPending();
nextCard();
};
state.advance = go;
state.pending = setTimeout(go, 2800);
setTimeout(function() {
if (done) return;
speak(toIndo(state.current), 0.82, function() {
if (!done) state.pending = setTimeout(go, 500);
});
if (!('speechSynthesis' in window)) setTimeout(go, 600);
}, 340);
}
function markRight() {
playWin();
state.streak++;
elStreak.textContent = state.streak;
if (state.hints === 0) {
state.filled++;
if (state.filled >= 10) {
state.filled = 0;
state.barNo++;
elFeedback.textContent = 'Bar ' + (state.barNo - 1) + ' finished. Ten in a row, clean.';
}
drawBar();
}
if (!elFeedback.textContent) {
elFeedback.textContent = state.hints ? 'Correct, with a hint.' : 'Correct.';
}
elFeedback.className = 'feedback ok';
elAnswer.className = 'right';
reveal(true);
advanceAfterWin();
}
function markWrong() {
playFail();
state.tries++;
state.streak = 0;
elStreak.textContent = '0';
elAnswer.classList.remove('wrong');
void elAnswer.offsetWidth;
elAnswer.classList.add('wrong');
elFeedback.className = 'feedback no';
elFeedback.textContent = state.tries >= 2 ?
'Still not it. Press hint, or listen again.' :
'Not that one. Check your spelling and try again.';
elAnswer.select();
}
function onCheck() {
if (state.solved) {
nextCard();
return;
}
var val = elAnswer.value;
if (!normalise(val)) {
elFeedback.className = 'feedback no';
elFeedback.textContent = 'Type the word first.';
elAnswer.focus();
return;
}
if (isCorrect(val, toIndo(state.current))) markRight();
else markWrong();
}
function onHint() {
if (state.solved) return;
state.hints++;
var answer = toIndo(state.current);
if (state.hints === 1) {
var masked = answer.split(' ').map(function(w) {
return w.charAt(0) + ' ' + new Array(w.length).join('_ ').trim();
}).join(' ');
elFeedback.className = 'feedback';
elFeedback.textContent = masked;
elAnswer.focus();
} else {
state.streak = 0;
elStreak.textContent = '0';
elFeedback.className = 'feedback';
elFeedback.textContent = 'The answer is "' + answer + '". Type it once to lock it in.';
elAnswer.value = '';
elWord.className = 'display word';
elHint.disabled = true;
elAnswer.focus();
}
}
function onSkip() {
if (!state.solved) {
state.streak = 0;
elStreak.textContent = '0';
}
nextCard();
}
elCheck.addEventListener('click', onCheck);
elHint.addEventListener('click', onHint);
elSkip.addEventListener('click', onSkip);
elHear.addEventListener('click', function() {
speak(toIndo(state.current));
});
elMute.addEventListener('click', function() {
muted = !muted;
elMute.textContent = muted ? 'Sound off' : 'Sound on';
elMute.setAttribute('aria-pressed', String(muted));
elMute.title = muted ? 'Turn sound effects on' : 'Turn sound effects off';
if (!muted) playWin();
});
elAnswer.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
onCheck();
}
});
elAnswer.addEventListener('input', function() {
elAnswer.classList.remove('wrong');
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && state.solved && document.activeElement !== elAnswer) {
e.preventDefault();
nextCard();
}
});
elChartToggle.addEventListener('click', function() {
var open = elChart.hasAttribute('hidden');
if (open) {
elChart.removeAttribute('hidden');
} else {
elChart.setAttribute('hidden', '');
}
elChartToggle.setAttribute('aria-expanded', String(open));
});
drawSets();
drawBar();
drawChart();
nextCard();
})();
</script>
</body>
</html>