Storybook — Star-reward Quiz
A gentle children's quiz built in the storybook style, with one picture question at a time and four big tappable answer cards drawn entirely as inline SVG. Tapping the right card sends a star flying into a five-slot meter, pops a confetti burst, and cheers the reader on; a wrong tap simply wobbles and offers a kind try-again, never a harsh fail. A progress bar tracks all five questions and a final score screen shows the earned stars with a play-again button, plus easy-read and sound toggles for friendly motion and accessibility.
MCP
Code
/* ============================================================
Storybook — Star-reward Quiz
============================================================ */
:root {
--bg: #fff8ef;
--bg-2: #fef0dd;
--sky-top: #cdeefb;
--sky-bot: #fff8ef;
--ink: #2c2350;
--ink-soft: #6b6191;
--primary: #ff8a3d;
--primary-dark: #ef6f1c;
--secondary: #5ec5d6;
--accent: #ffd23f;
--pink: #ff6f9c;
--green: #7bd389;
--green-dark: #57b369;
--card: #ffffff;
--line: #ffe2c2;
--r: 22px;
--r-lg: 30px;
--r-pill: 999px;
--shadow-soft: 0 14px 30px -16px rgba(44, 35, 80, 0.35);
--shadow-pop: 0 10px 0 0 var(--primary-dark);
--shadow-card: 0 22px 50px -28px rgba(44, 35, 80, 0.5);
--font-display: "Baloo 2", system-ui, -apple-system, "Segoe UI", sans-serif;
--font-body: "Nunito", system-ui, -apple-system, "Segoe UI", sans-serif;
--tap: 48px;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
min-height: 100vh;
font-family: var(--font-body);
font-size: clamp(1rem, 0.95rem + 0.3vw, 1.12rem);
line-height: 1.5;
color: var(--ink);
background:
radial-gradient(120% 80% at 50% -10%, var(--sky-top) 0%, transparent 55%),
linear-gradient(180deg, var(--bg) 0%, var(--bg-2) 100%);
background-attachment: fixed;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
position: relative;
overflow-x: hidden;
}
/* Easy-read / dyslexia-friendly mode */
body.easy-read {
--font-body: "Comic Sans MS", "Trebuchet MS", system-ui, sans-serif;
letter-spacing: 0.03em;
word-spacing: 0.12em;
line-height: 1.75;
}
/* ---------- Skip link ---------- */
.skip-link {
position: absolute;
left: 12px;
top: -60px;
z-index: 50;
background: var(--ink);
color: #fff;
padding: 10px 18px;
border-radius: var(--r-pill);
font-family: var(--font-display);
font-weight: 700;
text-decoration: none;
transition: top 0.18s ease;
}
.skip-link:focus-visible {
top: 12px;
}
/* ---------- Decorative sky ---------- */
.sky {
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
overflow: hidden;
}
.cloud {
position: absolute;
background: #fff;
border-radius: var(--r-pill);
opacity: 0.85;
filter: blur(0.3px);
box-shadow:
32px 8px 0 -6px #fff,
66px 2px 0 -2px #fff,
-28px 10px 0 -8px #fff;
}
.cloud--1 { width: 70px; height: 26px; top: 8%; left: 6%; animation: drift 26s linear infinite; }
.cloud--2 { width: 54px; height: 20px; top: 18%; right: 10%; opacity: 0.6; animation: drift 34s linear infinite reverse; }
.cloud--3 { width: 60px; height: 22px; bottom: 12%; left: 12%; opacity: 0.5; animation: drift 30s linear infinite; }
.twinkle {
position: absolute;
color: var(--accent);
font-size: 22px;
animation: twinkle 3.4s ease-in-out infinite;
}
.twinkle--1 { top: 14%; left: 22%; }
.twinkle--2 { top: 26%; right: 24%; animation-delay: 1.1s; color: var(--pink); }
.twinkle--3 { bottom: 20%; right: 16%; animation-delay: 2s; color: var(--secondary); }
@keyframes drift { from { transform: translateX(-10px); } to { transform: translateX(24px); } }
@keyframes twinkle {
0%, 100% { opacity: 0.25; transform: scale(0.85); }
50% { opacity: 1; transform: scale(1.15); }
}
/* ---------- Layout shell ---------- */
.quiz {
width: min(680px, 100% - 28px);
margin: clamp(18px, 4vw, 40px) auto;
display: grid;
gap: clamp(14px, 2.4vw, 20px);
}
/* ---------- Topbar ---------- */
.topbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
}
.brand__mark {
display: grid;
place-items: center;
filter: drop-shadow(0 6px 0 rgba(239, 111, 28, 0.25));
animation: bob 4s ease-in-out infinite;
}
@keyframes bob { 0%, 100% { transform: translateY(0) rotate(-3deg); } 50% { transform: translateY(-5px) rotate(3deg); } }
.brand__kicker {
margin: 0;
font-family: var(--font-display);
font-weight: 600;
font-size: 0.78rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--primary-dark);
}
.brand__title {
margin: 0;
font-family: var(--font-display);
font-weight: 800;
font-size: clamp(1.5rem, 1.2rem + 1.4vw, 2rem);
line-height: 1.05;
color: var(--ink);
}
.topbar__tools {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
/* ---------- Chip toggle buttons ---------- */
.chip-btn {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: var(--tap);
padding: 8px 16px;
border: 3px solid var(--line);
border-radius: var(--r-pill);
background: var(--card);
color: var(--ink);
font-family: var(--font-display);
font-weight: 700;
font-size: 0.92rem;
cursor: pointer;
box-shadow: var(--shadow-soft);
transition: transform 0.12s ease, border-color 0.12s ease, background 0.12s ease;
}
.chip-btn:hover { transform: translateY(-2px); border-color: var(--secondary); }
.chip-btn:active { transform: translateY(0) scale(0.96); }
.chip-btn[aria-pressed="true"] {
background: var(--secondary);
border-color: var(--secondary);
color: #07343c;
}
/* ---------- Star meter ---------- */
.meter {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
flex-wrap: wrap;
background: var(--card);
border: 3px solid var(--line);
border-radius: var(--r-lg);
padding: 12px 18px;
box-shadow: var(--shadow-soft);
}
.meter__track {
display: flex;
gap: 4px;
font-size: clamp(1.6rem, 1.3rem + 1.5vw, 2.2rem);
line-height: 1;
}
.meter__slot {
color: #f0dcb8;
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.meter__slot.filled {
color: var(--accent);
filter: drop-shadow(0 3px 0 var(--primary));
animation: pop 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes pop {
0% { transform: scale(0.2) rotate(-30deg); }
60% { transform: scale(1.35) rotate(10deg); }
100% { transform: scale(1) rotate(0); }
}
.meter__count {
margin: 0;
font-family: var(--font-display);
font-weight: 700;
color: var(--ink-soft);
}
.meter__count strong { color: var(--primary-dark); font-size: 1.15em; }
/* ---------- Card ---------- */
.card {
background: var(--card);
border: 4px solid #fff;
border-radius: var(--r-lg);
padding: clamp(18px, 3.5vw, 30px);
box-shadow: var(--shadow-card);
position: relative;
}
/* ---------- Progress ---------- */
.progress { margin-bottom: clamp(16px, 3vw, 24px); }
.progress__label {
margin: 0 0 8px;
font-family: var(--font-display);
font-weight: 700;
font-size: 0.95rem;
color: var(--ink-soft);
}
.progress__label span { color: var(--primary-dark); }
.progress__bar {
height: 16px;
border-radius: var(--r-pill);
background: #ffeede;
overflow: hidden;
border: 2px solid #ffe2c2;
}
.progress__fill {
display: block;
height: 100%;
width: 0%;
border-radius: var(--r-pill);
background: linear-gradient(90deg, var(--accent), var(--primary));
transition: width 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* ---------- Prompt ---------- */
.prompt {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: clamp(16px, 3vw, 24px);
}
.prompt__bubble {
flex: 0 0 auto;
display: grid;
place-items: center;
animation: bob 4.5s ease-in-out infinite;
}
.prompt__bubble.cheer { animation: cheer 0.6s ease; }
.prompt__bubble.oops { animation: wobble 0.6s ease; }
@keyframes cheer {
0%, 100% { transform: translateY(0) rotate(0); }
30% { transform: translateY(-8px) rotate(-8deg); }
60% { transform: translateY(-8px) rotate(8deg); }
}
.prompt__text {
margin: 0;
font-family: var(--font-display);
font-weight: 700;
font-size: clamp(1.2rem, 1rem + 1.2vw, 1.55rem);
line-height: 1.25;
color: var(--ink);
}
/* ---------- Options ---------- */
.options {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: clamp(10px, 2vw, 16px);
}
.option {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 140px;
padding: 16px 12px;
border: 4px solid var(--line);
border-radius: var(--r);
background: #fffdf8;
color: var(--ink);
font-family: var(--font-display);
font-weight: 700;
font-size: clamp(1rem, 0.92rem + 0.4vw, 1.18rem);
cursor: pointer;
text-align: center;
transition: transform 0.14s ease, border-color 0.14s ease, box-shadow 0.14s ease, background 0.14s ease;
box-shadow: 0 6px 0 0 #f3e3cb;
}
.option:hover:not(:disabled) {
transform: translateY(-4px);
border-color: var(--secondary);
box-shadow: 0 10px 0 0 #cfe9ee;
}
.option:active:not(:disabled) { transform: translateY(0) scale(0.97); }
.option:focus-visible {
outline: 4px solid var(--pink);
outline-offset: 3px;
}
.option__art {
width: 64px;
height: 64px;
display: grid;
place-items: center;
}
.option__art svg { width: 100%; height: 100%; }
.option.is-correct {
border-color: var(--green);
background: #effaf0;
box-shadow: 0 6px 0 0 var(--green-dark);
animation: correctPop 0.55s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.option.is-wrong {
border-color: var(--pink);
background: #fff0f5;
box-shadow: 0 6px 0 0 #e85a86;
animation: wobble 0.6s ease;
}
@keyframes correctPop {
0% { transform: scale(1); }
40% { transform: scale(1.06); }
100% { transform: scale(1); }
}
@keyframes wobble {
0%, 100% { transform: translateX(0) rotate(0); }
20% { transform: translateX(-7px) rotate(-3deg); }
40% { transform: translateX(7px) rotate(3deg); }
60% { transform: translateX(-5px) rotate(-2deg); }
80% { transform: translateX(5px) rotate(2deg); }
}
.option:disabled { cursor: default; }
.option .badge {
position: absolute;
display: none;
}
/* ---------- Feedback line ---------- */
.feedback {
min-height: 1.6em;
margin: clamp(14px, 2.5vw, 20px) 0 0;
text-align: center;
font-family: var(--font-display);
font-weight: 700;
font-size: 1.1rem;
}
.feedback.good { color: var(--green-dark); }
.feedback.try { color: var(--pink); }
/* ---------- Result view ---------- */
.result { text-align: center; }
.result__burst {
display: grid;
place-items: center;
margin-bottom: 6px;
animation: cheer 1s ease infinite;
}
.result__title {
margin: 0;
font-family: var(--font-display);
font-weight: 800;
font-size: clamp(1.6rem, 1.3rem + 1.6vw, 2.2rem);
color: var(--primary-dark);
}
.result__line {
margin: 6px 0 16px;
color: var(--ink-soft);
font-weight: 600;
font-size: 1.05rem;
}
.result__stars {
font-size: clamp(2rem, 1.6rem + 2vw, 2.8rem);
letter-spacing: 4px;
line-height: 1;
margin-bottom: 12px;
min-height: 1em;
}
.result__stars .r-star {
display: inline-block;
color: var(--accent);
filter: drop-shadow(0 4px 0 var(--primary));
animation: pop 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) backwards;
}
.result__stars .r-star.empty { color: #f0dcb8; filter: none; animation: none; }
.result__score {
margin: 0 0 20px;
font-family: var(--font-display);
font-weight: 700;
color: var(--ink);
}
.result__score strong { color: var(--primary-dark); font-size: 1.3em; }
/* ---------- Big button ---------- */
.big-btn {
min-height: 58px;
padding: 0 32px;
border: none;
border-radius: var(--r-pill);
background: var(--primary);
color: #fff;
font-family: var(--font-display);
font-weight: 800;
font-size: 1.2rem;
cursor: pointer;
box-shadow: var(--shadow-pop);
transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.12s ease;
}
.big-btn:hover { background: var(--primary-dark); transform: translateY(-2px); }
.big-btn:active { transform: translateY(6px); box-shadow: 0 4px 0 0 var(--primary-dark); }
.big-btn:focus-visible { outline: 4px solid var(--pink); outline-offset: 3px; }
/* ---------- FX layer (confetti + flying star) ---------- */
.fx-layer {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 40;
overflow: hidden;
}
.confetti {
position: fixed;
top: -16px;
width: 12px;
height: 16px;
border-radius: 3px;
will-change: transform, opacity;
animation: fall linear forwards;
}
@keyframes fall {
0% { transform: translateY(0) rotate(0); opacity: 1; }
100% { transform: translateY(110vh) rotate(720deg); opacity: 0.9; }
}
.fly-star {
position: fixed;
font-size: 30px;
color: var(--accent);
filter: drop-shadow(0 3px 0 var(--primary));
will-change: transform, opacity;
z-index: 41;
}
/* ---------- Toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 22px;
transform: translate(-50%, 140%);
background: var(--ink);
color: #fff;
font-family: var(--font-display);
font-weight: 700;
padding: 12px 22px;
border-radius: var(--r-pill);
box-shadow: var(--shadow-card);
z-index: 60;
transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
max-width: calc(100% - 32px);
text-align: center;
}
.toast.show { transform: translate(-50%, 0); }
/* ---------- Responsive ---------- */
@media (max-width: 460px) {
.options { grid-template-columns: 1fr; }
.option { min-height: 96px; flex-direction: row; gap: 14px; justify-content: flex-start; padding: 14px 18px; }
.option__art { width: 52px; height: 52px; flex: 0 0 auto; }
.chip-btn__label { display: none; }
.chip-btn { padding: 8px; }
.brand__title { font-size: 1.4rem; }
}
@media (max-width: 360px) {
.meter { gap: 8px; padding: 10px; }
.quiz { width: calc(100% - 18px); }
}
/* ---------- Reduced motion ---------- */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
.confetti, .fly-star { display: none !important; }
}/* ============================================================
Storybook — Star-reward Quiz
Vanilla JS, no libs. Fictional, kid-friendly content.
============================================================ */
(function () {
"use strict";
/* ---------- Inline SVG picture answers ---------- */
const ART = {
sun: `<svg viewBox="0 0 64 64" role="img" aria-hidden="true"><circle cx="32" cy="32" r="14" fill="#ffd23f"/><g stroke="#ff8a3d" stroke-width="4" stroke-linecap="round">${rays()}</g></svg>`,
moon: `<svg viewBox="0 0 64 64" role="img" aria-hidden="true"><path d="M44 32a18 18 0 1 1-18-18 14 14 0 0 0 18 18z" fill="#cdb8ff"/><circle cx="30" cy="24" r="2.5" fill="#a98ff0"/><circle cx="38" cy="36" r="2" fill="#a98ff0"/></svg>`,
frog: `<svg viewBox="0 0 64 64" role="img" aria-hidden="true"><ellipse cx="32" cy="42" rx="20" ry="16" fill="#7bd389"/><circle cx="22" cy="22" r="8" fill="#7bd389"/><circle cx="42" cy="22" r="8" fill="#7bd389"/><circle cx="22" cy="21" r="4" fill="#fff"/><circle cx="42" cy="21" r="4" fill="#fff"/><circle cx="23" cy="22" r="2" fill="#2c2350"/><circle cx="41" cy="22" r="2" fill="#2c2350"/><path d="M22 44 Q32 50 42 44" stroke="#2c2350" stroke-width="2.4" fill="none" stroke-linecap="round"/></svg>`,
cat: `<svg viewBox="0 0 64 64" role="img" aria-hidden="true"><path d="M18 18l6 10h16l6-10-3 14a14 14 0 0 1-28 0z" fill="#ff8a3d"/><circle cx="32" cy="36" r="14" fill="#ffb066"/><circle cx="26" cy="34" r="2.4" fill="#2c2350"/><circle cx="38" cy="34" r="2.4" fill="#2c2350"/><path d="M30 40 l2 2 2-2" stroke="#2c2350" stroke-width="2" fill="none" stroke-linecap="round"/><path d="M22 40h-8M22 43h-8M42 40h8M42 43h8" stroke="#2c2350" stroke-width="1.6" stroke-linecap="round"/></svg>`,
star: `<svg viewBox="0 0 64 64" role="img" aria-hidden="true"><path d="M32 8l7 15 16 2-12 11 3 16-14-8-14 8 3-16L9 25l16-2z" fill="#ffd23f" stroke="#ff8a3d" stroke-width="2.5" stroke-linejoin="round"/></svg>`,
heart: `<svg viewBox="0 0 64 64" role="img" aria-hidden="true"><path d="M32 52S10 38 10 24a11 11 0 0 1 22-3 11 11 0 0 1 22 3c0 14-22 28-22 28z" fill="#ff6f9c"/></svg>`,
apple: `<svg viewBox="0 0 64 64" role="img" aria-hidden="true"><path d="M32 18c-8-6-22-2-22 12 0 12 9 22 14 22 3 0 4-1.5 8-1.5s5 1.5 8 1.5c5 0 14-10 14-22 0-14-14-18-22-12z" fill="#ff5a5f"/><path d="M32 18c0-5 4-8 8-9" stroke="#7bd389" stroke-width="4" fill="none" stroke-linecap="round"/></svg>`,
fish: `<svg viewBox="0 0 64 64" role="img" aria-hidden="true"><path d="M14 32c8-12 26-12 34 0-8 12-26 12-34 0z" fill="#5ec5d6"/><path d="M48 24l10-6v28l-10-6z" fill="#3fa9bb"/><circle cx="24" cy="30" r="2.6" fill="#2c2350"/></svg>`,
tree: `<svg viewBox="0 0 64 64" role="img" aria-hidden="true"><rect x="29" y="36" width="6" height="18" rx="2" fill="#b07a45"/><circle cx="32" cy="26" r="16" fill="#7bd389"/><circle cx="22" cy="32" r="9" fill="#8fdc9b"/><circle cx="42" cy="32" r="9" fill="#67c47a"/></svg>`,
rainbow: `<svg viewBox="0 0 64 64" role="img" aria-hidden="true"><g fill="none" stroke-width="5" stroke-linecap="round"><path d="M10 48a22 22 0 0 1 44 0" stroke="#ff6f9c"/><path d="M16 48a16 16 0 0 1 32 0" stroke="#ffd23f"/><path d="M22 48a10 10 0 0 1 20 0" stroke="#5ec5d6"/></g></svg>`,
cloud: `<svg viewBox="0 0 64 64" role="img" aria-hidden="true"><ellipse cx="26" cy="36" rx="14" ry="11" fill="#e6f3fb"/><circle cx="38" cy="30" r="12" fill="#e6f3fb"/><circle cx="44" cy="38" r="9" fill="#e6f3fb"/></svg>`,
boat: `<svg viewBox="0 0 64 64" role="img" aria-hidden="true"><path d="M14 40h36l-6 12H20z" fill="#ff8a3d"/><path d="M32 12v26M32 12l14 14H32z" fill="#5ec5d6" stroke="#3fa9bb" stroke-width="2" stroke-linejoin="round"/></svg>`,
};
function rays() {
let out = "";
for (let i = 0; i < 8; i++) {
const a = (i * Math.PI) / 4;
const x1 = 32 + Math.cos(a) * 20;
const y1 = 32 + Math.sin(a) * 20;
const x2 = 32 + Math.cos(a) * 28;
const y2 = 32 + Math.sin(a) * 28;
out += `<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}"/>`;
}
return out;
}
/* ---------- Fictional storybook quiz data ---------- */
const QUESTIONS = [
{
q: "Pip the firefly only glows at night. What does Pip wait for in the sky?",
options: [
{ art: "moon", label: "The moon", correct: true },
{ art: "sun", label: "The sun", correct: false },
{ art: "cloud", label: "A cloud", correct: false },
{ art: "rainbow", label: "A rainbow", correct: false },
],
},
{
q: "In Mossy Pond, who can hop the very highest of all?",
options: [
{ art: "fish", label: "The fish", correct: false },
{ art: "frog", label: "The frog", correct: true },
{ art: "boat", label: "The boat", correct: false },
{ art: "tree", label: "The tree", correct: false },
],
},
{
q: "Whiskers the cat picked one snack from the orchard. Which one?",
options: [
{ art: "star", label: "A star", correct: false },
{ art: "heart", label: "A heart", correct: false },
{ art: "apple", label: "An apple", correct: true },
{ art: "cloud", label: "A cloud", correct: false },
],
},
{
q: "After the rainstorm passed, what shone over Bramble Wood?",
options: [
{ art: "rainbow", label: "A rainbow", correct: true },
{ art: "moon", label: "The moon", correct: false },
{ art: "fish", label: "A fish", correct: false },
{ art: "cat", label: "A cat", correct: false },
],
},
{
q: "Little Tug sailed across the bay. Which one is Little Tug?",
options: [
{ art: "tree", label: "The tree", correct: false },
{ art: "apple", label: "The apple", correct: false },
{ art: "boat", label: "The boat", correct: true },
{ art: "sun", label: "The sun", correct: false },
],
},
];
const CHEERS = ["Great job! ⭐", "You got it! 🎉", "Brilliant! 🌟", "Wonderful! 💫", "Hooray! 🥳"];
const TRYS = ["Almost! Try again 💛", "Good guess — try once more!", "So close! Tap another card", "Keep going, you can do it!"];
/* ---------- DOM refs ---------- */
const $ = (id) => document.getElementById(id);
const playView = $("play-view");
const resultView = $("result-view");
const optionsEl = $("options");
const questionEl = $("question-text");
const feedbackEl = $("feedback");
const qNowEl = $("q-now");
const qTotalEl = $("q-total");
const progressFill = $("progress-fill");
const progressbar = $("progressbar");
const starCountEl = $("star-count");
const meterSlots = Array.from(document.querySelectorAll(".meter__slot"));
const meterTrack = $("meter-track");
const mascot = $("mascot-bubble");
const fxLayer = $("fx-layer");
const toastEl = $("toast");
const fontToggle = $("font-toggle");
const soundToggle = $("sound-toggle");
/* ---------- State ---------- */
let current = 0; // current question index
let stars = 0; // correct answers
let locked = false; // prevents double-tap during transition
let soundOn = true;
const prefersReduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
qTotalEl.textContent = String(QUESTIONS.length);
meterSlots.forEach((_, i) => {});
/* ---------- Sound (tiny WebAudio beeps, no assets) ---------- */
let audioCtx = null;
function tone(freq, dur, type) {
if (!soundOn) return;
try {
audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)();
const o = audioCtx.createOscillator();
const g = audioCtx.createGain();
o.type = type || "sine";
o.frequency.value = freq;
o.connect(g);
g.connect(audioCtx.destination);
const t = audioCtx.currentTime;
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(0.12, t + 0.01);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
o.start(t);
o.stop(t + dur);
} catch (e) {
/* audio not available — silent */
}
}
function chime() {
tone(660, 0.16, "triangle");
setTimeout(() => tone(990, 0.22, "triangle"), 110);
}
function buzz() {
tone(200, 0.18, "sine");
}
/* ---------- Toast helper ---------- */
let toastTimer;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastEl.classList.remove("show"), 2200);
}
/* ---------- Render a question ---------- */
function renderQuestion() {
const data = QUESTIONS[current];
locked = false;
questionEl.textContent = data.q;
qNowEl.textContent = String(current + 1);
feedbackEl.textContent = "";
feedbackEl.className = "feedback";
optionsEl.innerHTML = "";
data.options.forEach((opt, i) => {
const li = document.createElement("li");
const btn = document.createElement("button");
btn.type = "button";
btn.className = "option";
btn.setAttribute("data-index", String(i));
btn.setAttribute("aria-label", opt.label);
btn.innerHTML = `<span class="option__art">${ART[opt.art] || ""}</span><span class="option__label">${opt.label}</span>`;
btn.addEventListener("click", () => handleAnswer(btn, opt, data));
li.appendChild(btn);
optionsEl.appendChild(li);
});
updateProgress();
}
function updateProgress() {
const pct = (current / QUESTIONS.length) * 100;
progressFill.style.width = pct + "%";
progressbar.setAttribute("aria-valuenow", String(current));
}
/* ---------- Handle an answer ---------- */
function handleAnswer(btn, opt, data) {
if (locked) return;
if (opt.correct) {
locked = true;
btn.classList.add("is-correct");
btn.setAttribute("aria-pressed", "true");
mascot.classList.remove("oops");
mascot.classList.add("cheer");
feedbackEl.textContent = CHEERS[current % CHEERS.length];
feedbackEl.className = "feedback good";
chime();
// disable all option buttons
optionsEl.querySelectorAll("button").forEach((b) => (b.disabled = true));
// reward animations
flyStarToMeter(btn);
burstConfetti();
stars++;
starCountEl.textContent = String(stars);
setTimeout(() => {
fillNextStar(); // pop the star into the meter
}, prefersReduce ? 0 : 520);
setTimeout(() => {
mascot.classList.remove("cheer");
current++;
if (current >= QUESTIONS.length) {
showResult();
} else {
renderQuestion();
}
}, prefersReduce ? 350 : 1250);
} else {
// gentle, no harsh fail
btn.classList.remove("is-wrong");
// reflow so animation can replay
void btn.offsetWidth;
btn.classList.add("is-wrong");
mascot.classList.remove("cheer");
mascot.classList.add("oops");
feedbackEl.textContent = TRYS[Math.floor(Math.random() * TRYS.length)];
feedbackEl.className = "feedback try";
buzz();
setTimeout(() => {
btn.classList.remove("is-wrong");
mascot.classList.remove("oops");
}, 650);
}
}
/* ---------- Star meter fill ---------- */
function fillNextStar() {
const slot = meterSlots[stars - 1];
if (slot) {
slot.textContent = "★";
slot.classList.add("filled");
}
const label = `Star meter, ${stars} of ${QUESTIONS.length} stars earned`;
meterTrack.setAttribute("aria-label", label);
}
/* ---------- Flying star animation ---------- */
function flyStarToMeter(fromEl) {
if (prefersReduce) return;
const targetSlot = meterSlots[stars] || meterSlots[meterSlots.length - 1];
const from = fromEl.getBoundingClientRect();
const to = targetSlot.getBoundingClientRect();
const star = document.createElement("span");
star.className = "fly-star";
star.textContent = "★";
const startX = from.left + from.width / 2 - 15;
const startY = from.top + from.height / 2 - 15;
star.style.left = startX + "px";
star.style.top = startY + "px";
fxLayer.appendChild(star);
const dx = to.left + to.width / 2 - 15 - startX;
const dy = to.top + to.height / 2 - 15 - startY;
const anim = star.animate(
[
{ transform: "translate(0,0) scale(0.4) rotate(0deg)", opacity: 0 },
{ transform: "translate(" + dx * 0.5 + "px," + (dy * 0.5 - 40) + "px) scale(1.5) rotate(180deg)", opacity: 1, offset: 0.5 },
{ transform: "translate(" + dx + "px," + dy + "px) scale(0.8) rotate(360deg)", opacity: 0.9 },
],
{ duration: 700, easing: "cubic-bezier(0.34, 1.2, 0.64, 1)", fill: "forwards" }
);
anim.onfinish = () => star.remove();
}
/* ---------- Confetti ---------- */
function burstConfetti() {
if (prefersReduce) return;
const colors = ["#ff8a3d", "#5ec5d6", "#ffd23f", "#ff6f9c", "#7bd389"];
const count = 26;
for (let i = 0; i < count; i++) {
const c = document.createElement("span");
c.className = "confetti";
c.style.left = 30 + Math.random() * 40 + "%";
c.style.background = colors[i % colors.length];
c.style.animationDuration = 1.6 + Math.random() * 1.4 + "s";
c.style.animationDelay = Math.random() * 0.25 + "s";
const w = 8 + Math.random() * 8;
c.style.width = w + "px";
c.style.height = w * (0.6 + Math.random() * 0.8) + "px";
if (Math.random() > 0.5) c.style.borderRadius = "50%";
fxLayer.appendChild(c);
setTimeout(() => c.remove(), 3400);
}
}
/* ---------- Result screen ---------- */
function showResult() {
progressFill.style.width = "100%";
progressbar.setAttribute("aria-valuenow", String(QUESTIONS.length));
playView.hidden = true;
resultView.hidden = false;
const total = QUESTIONS.length;
$("result-total").textContent = String(total);
$("result-score").textContent = String(stars);
let title, line;
if (stars === total) {
title = "Super Star Explorer! 🌟";
line = "You found every answer in the storybook. Amazing!";
} else if (stars >= Math.ceil(total / 2)) {
title = "Wonderful work! 🎉";
line = "You gathered lots of stars. Great reading!";
} else {
title = "Nice exploring! 💛";
line = "Every star counts — come adventure again!";
}
$("result-title").textContent = title;
$("result-line").textContent = line;
// render the star row
const row = $("result-stars");
row.innerHTML = "";
for (let i = 0; i < total; i++) {
const s = document.createElement("span");
const earned = i < stars;
s.className = "r-star" + (earned ? "" : " empty");
s.textContent = earned ? "★" : "☆";
if (earned && !prefersReduce) s.style.animationDelay = i * 0.12 + "s";
row.appendChild(s);
}
if (stars > 0) {
burstConfetti();
if (!prefersReduce) setTimeout(burstConfetti, 400);
chime();
}
resultView.scrollIntoView({ behavior: prefersReduce ? "auto" : "smooth", block: "nearest" });
$("play-again").focus();
}
/* ---------- Play again ---------- */
function resetQuiz() {
current = 0;
stars = 0;
starCountEl.textContent = "0";
meterSlots.forEach((s) => {
s.textContent = "☆";
s.classList.remove("filled");
});
meterTrack.setAttribute("aria-label", "Star meter, 0 of " + QUESTIONS.length + " stars earned");
resultView.hidden = true;
playView.hidden = false;
renderQuestion();
toast("New adventure! 🚀");
questionEl.focus && questionEl.setAttribute("tabindex", "-1");
}
/* ---------- Toggles ---------- */
fontToggle.addEventListener("click", () => {
const on = document.body.classList.toggle("easy-read");
fontToggle.setAttribute("aria-pressed", String(on));
fontToggle.querySelector(".chip-btn__label").textContent = on ? "Easy-read on" : "Easy-read";
toast(on ? "Easy-read text on 🔤" : "Standard text");
});
soundToggle.addEventListener("click", () => {
soundOn = !soundOn;
soundToggle.setAttribute("aria-pressed", String(soundOn));
soundToggle.querySelector(".chip-btn__icon").textContent = soundOn ? "🔊" : "🔇";
soundToggle.querySelector(".chip-btn__label").textContent = soundOn ? "Sound on" : "Sound off";
if (soundOn) chime();
toast(soundOn ? "Sound on 🔊" : "Sound off 🔇");
});
$("play-again").addEventListener("click", resetQuiz);
/* ---------- Boot ---------- */
questionEl.setAttribute("tabindex", "-1");
renderQuestion();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Storybook — Star-reward Quiz</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=Baloo+2:wght@500;600;700;800&family=Nunito:ital,wght@0,400;0,600;0,700;0,800;1,600&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<a class="skip-link" href="#quiz-main">Skip to the quiz</a>
<div class="sky" aria-hidden="true">
<span class="cloud cloud--1"></span>
<span class="cloud cloud--2"></span>
<span class="cloud cloud--3"></span>
<span class="twinkle twinkle--1">✦</span>
<span class="twinkle twinkle--2">✧</span>
<span class="twinkle twinkle--3">✦</span>
</div>
<main id="quiz-main" class="quiz" role="main">
<header class="topbar">
<div class="brand">
<span class="brand__mark" aria-hidden="true">
<svg viewBox="0 0 48 48" width="44" height="44" role="img" aria-label="">
<circle cx="24" cy="24" r="22" fill="#ffd23f" />
<path d="M24 9l4.2 8.5 9.4 1.4-6.8 6.6 1.6 9.3L24 30.9l-8.4 4.4 1.6-9.3-6.8-6.6 9.4-1.4z" fill="#ff8a3d" />
</svg>
</span>
<div class="brand__text">
<p class="brand__kicker">Storybook Quiz</p>
<h1 class="brand__title">Star Explorers</h1>
</div>
</div>
<div class="topbar__tools">
<button id="font-toggle" class="chip-btn" type="button" aria-pressed="false">
<span aria-hidden="true">🔤</span>
<span class="chip-btn__label">Easy-read</span>
</button>
<button id="sound-toggle" class="chip-btn" type="button" aria-pressed="true">
<span class="chip-btn__icon" aria-hidden="true">🔊</span>
<span class="chip-btn__label">Sound on</span>
</button>
</div>
</header>
<!-- Star meter -->
<section class="meter" aria-label="Stars earned">
<div class="meter__track" role="img" aria-label="Star meter, 0 of 5 stars earned" id="meter-track">
<span class="meter__slot" data-slot="0">☆</span>
<span class="meter__slot" data-slot="1">☆</span>
<span class="meter__slot" data-slot="2">☆</span>
<span class="meter__slot" data-slot="3">☆</span>
<span class="meter__slot" data-slot="4">☆</span>
</div>
<p class="meter__count"><strong id="star-count">0</strong> / 5 stars</p>
</section>
<!-- QUESTION VIEW -->
<section id="play-view" class="card" aria-live="polite">
<div class="progress">
<p class="progress__label">
Question <span id="q-now">1</span> of <span id="q-total">5</span>
</p>
<div class="progress__bar" role="progressbar" aria-valuemin="0" aria-valuemax="5"
aria-valuenow="0" aria-label="Quiz progress" id="progressbar">
<span class="progress__fill" id="progress-fill"></span>
</div>
</div>
<div class="prompt">
<span class="prompt__bubble" id="mascot-bubble" aria-hidden="true">
<svg viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="">
<ellipse cx="32" cy="40" rx="22" ry="20" fill="#7bd389" />
<circle cx="32" cy="22" r="16" fill="#7bd389" />
<circle cx="25" cy="20" r="4.5" fill="#fff" />
<circle cx="39" cy="20" r="4.5" fill="#fff" />
<circle cx="26" cy="21" r="2.2" fill="#2c2350" />
<circle cx="40" cy="21" r="2.2" fill="#2c2350" />
<path d="M25 30 Q32 36 39 30" stroke="#2c2350" stroke-width="2.4" fill="none" stroke-linecap="round" />
<circle cx="20" cy="27" r="3" fill="#ff6f9c" opacity="0.6" />
<circle cx="44" cy="27" r="3" fill="#ff6f9c" opacity="0.6" />
</svg>
</span>
<h2 class="prompt__text" id="question-text">Loading question…</h2>
</div>
<ul class="options" id="options" role="group" aria-label="Choose an answer"></ul>
<p class="feedback" id="feedback" role="status" aria-live="assertive"></p>
</section>
<!-- RESULT VIEW -->
<section id="result-view" class="card result" hidden>
<div class="result__burst" aria-hidden="true">
<svg viewBox="0 0 80 80" width="96" height="96" role="img" aria-label="">
<path d="M40 6l9 18 20 3-14.5 14 3.4 19.8L40 52.5 22.1 60.8 25.5 41 11 27l20-3z"
fill="#ffd23f" stroke="#ff8a3d" stroke-width="3" stroke-linejoin="round" />
<circle cx="40" cy="34" r="5" fill="#ff8a3d" />
</svg>
</div>
<h2 class="result__title" id="result-title">Wonderful work!</h2>
<p class="result__line" id="result-line">You collected your stars.</p>
<div class="result__stars" id="result-stars" aria-hidden="true"></div>
<p class="result__score">
<strong id="result-score">0</strong> of <span id="result-total">5</span> stars
</p>
<button id="play-again" class="big-btn" type="button">
<span aria-hidden="true">🔁</span> Play again
</button>
</section>
</main>
<!-- confetti + flying star layer -->
<div class="fx-layer" id="fx-layer" aria-hidden="true"></div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Star-reward Quiz
A warm, low-pressure quiz for early readers, set in a soft storybook sky. Each round shows one question from a tiny fictional tale and four oversized answer cards — every picture is drawn as inline SVG (a moon, a frog, an apple, a little tug boat), so there are no external images. A cheerful frog mascot, a five-slot star meter, and a chunky progress bar sit above the question so a child always knows where they are.
Tapping the correct card launches a star that flies from the answer up into the meter, pops into place with a spring, and triggers a quick confetti burst while the mascot cheers and a soft chime plays. A wrong tap is never punished: the card gives a gentle wobble, the mascot looks surprised for a beat, and an encouraging “try again” line invites another go — no streaks are lost and the question stays open. After five questions a result screen tallies the earned stars, swaps in a kind headline (from “Nice exploring” up to “Super Star Explorer”), and offers a big play-again button that resets everything for a new adventure.
Two pill toggles round out the demo: an easy-read switch that swaps in a more legible font with
roomier letter and line spacing, and a sound switch that mutes the tiny WebAudio chimes. The
layout uses large 48px-plus touch targets, visible focus rings, live-region feedback for screen
readers, and collapses from a two-by-two grid to a single stacked column down to 360px. All
motion respects prefers-reduced-motion.
Illustrative kids’ UI only — fictional stories, characters, and audio.