Files
geograsim/v2-modules/hallo-welt/public/game.html
T
Adminator 1e51ef7def Nachtrag: alle bisher untracked Ordner + hängende Änderungen mit-committen
- Konzept/, didaktik_geografie/, didaktik_simulation/, v2-modules/, v2-platform/
- 12 code-workspace-Files
- STATUS-*.md
- viele M/D/R-Änderungen an bereits getrackten Files
- .gitignore verstärkt: **/.humaninput/, **/secret_keys.txt

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-08 02:27:02 +02:00

518 lines
16 KiB
HTML

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Hallo Welt — V2-Beispiel</title>
<style>
/* ── Tokens (sollten aus design-system-v2.css kommen, hier inline für Standalone) ── */
:root {
--ggs-bg: #f5f2eb;
--ggs-white: #ffffff;
--ggs-text: #2a2a2a;
--ggs-text-muted: #8a8a8a;
--ggs-fjord: #4a7c8a;
--ggs-fjord-dark: #1f4e5a;
--ggs-moss: #5a8a5e;
--ggs-moss-dark: #3a6b3e;
--ggs-coral: #c85c4a;
--ggs-border: #e0ddd4;
--ggs-radius-md: 12px;
--ggs-radius-lg: 16px;
--ggs-shadow-md: 0 2px 8px rgba(0,0,0,.12);
/* Modul-lokale Tokens (siehe Spec: --<slug>-* erlaubt) */
--hw-fb-correct-bg: #dcead0;
--hw-fb-correct-text: #3a6b3e;
--hw-fb-correct-bd: transparent;
--hw-fb-wrong-bg: #f0d0ca;
--hw-fb-wrong-text: #862d20;
--hw-fb-wrong-bd: transparent;
--hw-stuck-bg: #fff3cd;
--hw-stuck-text: #8a5a00;
--hw-stuck-bd: #e8833a;
}
/* Hoher Kontrast — hell (schwarz auf weiß, dunkle saturierte Farben) */
body[data-high-contrast="light"] {
--ggs-bg: #ffffff;
--ggs-white: #ffffff;
--ggs-text: #000000;
--ggs-text-muted: #000000;
--ggs-fjord: #003366;
--ggs-fjord-dark: #001a4d;
--ggs-moss: #1a4d1a;
--ggs-moss-dark: #003300;
--ggs-coral: #800000;
--ggs-border: #000000;
--hw-fb-correct-bg: #ffffff;
--hw-fb-correct-text: #003300;
--hw-fb-correct-bd: #003300;
--hw-fb-wrong-bg: #ffffff;
--hw-fb-wrong-text: #800000;
--hw-fb-wrong-bd: #800000;
--hw-stuck-bg: #ffffff;
--hw-stuck-text: #5a3a00;
--hw-stuck-bd: #5a3a00;
}
/* Hoher Kontrast — dunkel (gelb/grün auf schwarz) */
body[data-high-contrast="dark"] {
--ggs-bg: #000000;
--ggs-white: #000000;
--ggs-text: #ffffff;
--ggs-text-muted: #e0e0e0;
--ggs-fjord: #ffff00;
--ggs-fjord-dark: #ffff00;
--ggs-moss: #00ff00;
--ggs-moss-dark: #00ff00;
--ggs-coral: #ff6060;
--ggs-border: #ffffff;
--hw-fb-correct-bg: #00ff00;
--hw-fb-correct-text: #000000;
--hw-fb-correct-bd: transparent;
--hw-fb-wrong-bg: #ff6060;
--hw-fb-wrong-text: #000000;
--hw-fb-wrong-bd: transparent;
--hw-stuck-bg: #ffaa00;
--hw-stuck-text: #000000;
--hw-stuck-bd: #ffff00;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background: var(--ggs-bg);
color: var(--ggs-text);
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
body {
display: flex;
flex-direction: column;
min-height: 100dvh;
padding: max(16px, env(safe-area-inset-top)) 16px max(16px, env(safe-area-inset-bottom)) 16px;
}
/* Reduced Motion: alle Animationen praktisch ausschalten */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
.topbar {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 12px;
border-bottom: 1px solid var(--ggs-border);
}
.avatar {
width: 40px; height: 40px;
border-radius: 50%;
background: var(--ggs-fjord-light, transparent);
object-fit: cover;
}
.greeting { font-size: 0.9rem; color: var(--ggs-text); }
.topbar-actions { margin-left: auto; display: flex; gap: 8px; }
.icon-btn {
width: 44px; height: 44px;
border: 2px solid transparent;
background: var(--ggs-white);
border-radius: 999px;
display: inline-flex; align-items: center; justify-content: center;
cursor: pointer; font-size: 1.1rem;
color: var(--ggs-text);
}
.icon-btn[aria-pressed="true"] { background: var(--ggs-fjord); color: var(--ggs-white); }
main {
flex: 1;
max-width: 720px;
margin: 0 auto;
width: 100%;
padding: 24px 0;
display: flex;
flex-direction: column;
justify-content: center;
}
.card {
background: var(--ggs-white);
border-radius: var(--ggs-radius-lg);
padding: 24px;
box-shadow: var(--ggs-shadow-md);
border: 1px solid var(--ggs-border);
}
h1 { font-size: 1.5rem; color: var(--ggs-fjord-dark); margin-bottom: 8px; }
p.lead { font-size: 1rem; color: var(--ggs-text); margin-bottom: 20px; line-height: 1.6; }
.answers { display: flex; flex-direction: column; gap: 12px; margin-top: 16px; }
.answer-btn {
text-align: left;
background: var(--ggs-bg);
border: 2px solid var(--ggs-border);
color: var(--ggs-text);
padding: 16px 20px;
border-radius: var(--ggs-radius-md);
font-size: 1rem;
cursor: pointer;
min-height: 48px;
font-family: inherit;
transition: border-color 0.15s, background 0.15s;
}
@media (hover: hover) {
.answer-btn:hover { border-color: var(--ggs-fjord); }
}
.answer-btn.correct {
border-color: var(--ggs-moss);
background: var(--ggs-moss);
color: var(--ggs-white);
}
.answer-btn.wrong {
border-color: var(--ggs-coral);
background: var(--ggs-coral);
color: var(--ggs-white);
}
.answer-btn .icon { margin-right: 8px; font-weight: 700; }
.actions {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 24px;
position: sticky;
bottom: 0;
padding: 12px 0;
background: linear-gradient(to top, var(--ggs-white) 70%, transparent);
}
.primary-btn {
background: var(--ggs-fjord-dark);
color: var(--ggs-white);
border: 2px solid var(--ggs-fjord-dark);
padding: 12px 24px;
border-radius: var(--ggs-radius-md);
font-size: 1rem;
font-weight: 700;
cursor: pointer;
min-height: 48px;
font-family: inherit;
}
/* HC-Modi werden über die Token-Umdefinition (oben) abgedeckt —
kein expliziter Selector-Override nötig. */
.secondary-btn {
background: var(--ggs-white);
color: var(--ggs-fjord-dark);
border: 2px solid var(--ggs-fjord-dark);
padding: 12px 24px;
border-radius: var(--ggs-radius-md);
font-size: 1rem;
cursor: pointer;
min-height: 48px;
font-family: inherit;
}
.feedback {
padding: 12px 16px;
border-radius: var(--ggs-radius-md);
margin-top: 16px;
font-weight: 600;
}
.feedback.correct {
background: var(--hw-fb-correct-bg);
color: var(--hw-fb-correct-text);
border: 2px solid var(--hw-fb-correct-bd);
}
.feedback.wrong {
background: var(--hw-fb-wrong-bg);
color: var(--hw-fb-wrong-text);
border: 2px solid var(--hw-fb-wrong-bd);
}
.feedback .icon { margin-right: 8px; font-weight: 900; }
.stuck-banner {
background: var(--hw-stuck-bg);
border-left: 4px solid var(--hw-stuck-bd);
padding: 12px 16px;
border-radius: var(--ggs-radius-md);
margin-top: 16px;
font-size: 0.9rem;
color: var(--hw-stuck-text);
}
.stuck-banner[hidden] { display: none; }
/* Visually-Hidden — nur für Screen-Reader sichtbar (Pflicht für A11y) */
.visually-hidden {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
/* Focus-Indikator — Pflicht für A11y. HC-Modi greifen automatisch,
weil --ggs-fjord-dark im HC-Token-Set umdefiniert wird. */
:focus-visible {
outline: 3px solid var(--ggs-fjord-dark);
outline-offset: 2px;
}
</style>
</head>
<body>
<div class="topbar">
<img class="avatar" id="avatar" src="" alt="">
<span class="greeting" id="greeting"></span>
<div class="topbar-actions">
<button class="icon-btn" id="hcToggle" aria-pressed="false" type="button" data-state="off" title="">
<span class="icon" aria-hidden="true"></span>
<span class="visually-hidden" id="hcLabel"></span>
</button>
<button class="icon-btn" id="exitBtn" aria-label="" type="button">
<span aria-hidden="true"></span>
</button>
</div>
</div>
<main>
<section class="card" aria-live="polite" id="mainCard">
<p id="loadingText">Wird geladen …</p>
</section>
<div class="stuck-banner" id="stuckBanner" hidden role="status"></div>
</main>
<script type="module">
'use strict';
import { TelemetryClient } from '../../../v2-platform/lib/telemetry-client.js';
// ── 1. i18n laden ──────────────────────────────────────────────────
const lang = new URLSearchParams(location.search).get('lang') || 'de-AT-standard';
const i18n = await fetch(`./i18n/${lang}.json`).then(r => r.json());
function t(key, vars = {}) {
let s = i18n[key] || `[${key}]`;
for (const [k, v] of Object.entries(vars)) s = s.replace(`{${k}}`, v);
return s;
}
document.title = t('title');
document.getElementById('hcLabel').textContent = t('highContrastLabel');
document.getElementById('exitBtn').setAttribute('aria-label', t('ariaCloseButton'));
// ── 2. TelemetryClient initialisieren ──────────────────────────────
let tc;
try {
tc = new TelemetryClient({
moduleSlug: 'hallo-welt',
moduleVersion: '1.0.0',
specVersion: '1.0',
onStuck: () => showStuckBanner(),
onUnstuck: () => hideStuckBanner(),
});
await tc.init();
} catch (e) {
document.getElementById('mainCard').innerHTML = `<p>${t('errorProfile')}</p>`;
console.error('[hallo-welt]', e);
throw e;
}
const profile = tc.profile;
const scoreMax = 10;
tc.updateScore(0, scoreMax);
// Avatar + Begrüßung
document.getElementById('avatar').src = profile.avatarUrl;
document.getElementById('avatar').alt = profile.displayName;
document.getElementById('greeting').textContent = profile.displayName;
// A11y-Profil-Settings übernehmen — Default: hell-HC, wenn Profil highContrast=true
if (profile.preferences?.highContrast) {
document.body.dataset.highContrast = 'light';
document.getElementById('hcToggle').setAttribute('aria-pressed', 'true');
document.getElementById('hcToggle').dataset.state = 'light';
}
function showStuckBanner() {
const b = document.getElementById('stuckBanner');
b.textContent = '💡 ' + t('stuckHint');
b.hidden = false;
}
function hideStuckBanner() {
document.getElementById('stuckBanner').hidden = true;
}
// ── 3. UI-Phasen ───────────────────────────────────────────────────
function renderWelcome() {
tc.setPhase('welcome', 'Begrüßung');
const card = document.getElementById('mainCard');
card.innerHTML = `
<h1>${escapeHtml(t('welcomeHeading', { name: profile.displayName }))}</h1>
<p class="lead">${escapeHtml(t('welcomeText'))}</p>
<div class="actions">
<button class="primary-btn" id="welcomeNext" type="button">${escapeHtml(t('welcomeButton'))}</button>
</div>
`;
document.getElementById('welcomeNext').focus();
document.getElementById('welcomeNext').addEventListener('click', () => {
tc.milestone('welcome-shown', 'Begrüßung gesehen', 1);
renderQuestion();
});
}
function renderQuestion() {
tc.setPhase('question', 'Frage');
const card = document.getElementById('mainCard');
const answers = [
{ key: 'answerEurope', correct: true },
{ key: 'answerAsia', correct: false },
{ key: 'answerAfrica', correct: false },
];
card.innerHTML = `
<h1>${escapeHtml(t('questionHeading'))}</h1>
<p class="lead">${escapeHtml(t('questionText'))}</p>
<div class="answers" role="radiogroup">
${answers.map((a) => `
<button class="answer-btn" type="button" data-correct="${a.correct}"
role="radio" aria-checked="false">
${escapeHtml(t(a.key))}
</button>`).join('')}
</div>
<div id="feedback" aria-live="polite"></div>
`;
card.querySelectorAll('.answer-btn').forEach(btn => {
btn.addEventListener('click', () => onAnswer(btn));
});
card.querySelector('.answer-btn').focus();
}
function onAnswer(btn) {
const correct = btn.dataset.correct === 'true';
document.querySelectorAll('.answer-btn').forEach(b => b.disabled = true);
btn.classList.add(correct ? 'correct' : 'wrong');
btn.setAttribute('aria-checked', 'true');
btn.setAttribute('aria-label',
btn.textContent.trim() + ' — ' + t(correct ? 'ariaCorrect' : 'ariaWrong'));
const fb = document.getElementById('feedback');
fb.className = 'feedback ' + (correct ? 'correct' : 'wrong');
fb.innerHTML = '<span class="icon" aria-hidden="true">' +
(correct ? '✓' : '✗') + '</span>' + escapeHtml(t(correct ? 'feedbackCorrect' : 'feedbackWrong'));
tc.milestone('question-answered', 'Frage beantwortet', correct ? 9 : 0);
setTimeout(() => {
const card = document.getElementById('mainCard');
const next = document.createElement('div');
next.className = 'actions';
next.innerHTML = `<button class="primary-btn" id="toDone" type="button">${escapeHtml(t('nextButton'))}</button>`;
card.appendChild(next);
document.getElementById('toDone').focus();
document.getElementById('toDone').addEventListener('click', renderDone);
}, 800);
}
async function renderDone() {
tc.setPhase('done', 'Abschluss');
tc.milestone('module-completed', 'Modul abgeschlossen', 0);
const card = document.getElementById('mainCard');
card.innerHTML = `
<h1>${escapeHtml(t('doneHeading'))}</h1>
<p class="lead">${escapeHtml(t('doneText', { score: tc.score, scoreMax }))}</p>
<div class="actions">
<button class="primary-btn" id="doneBtn" type="button">${escapeHtml(t('doneButton'))}</button>
</div>
`;
document.getElementById('doneBtn').focus();
// Result + Cleanup
await tc.complete({
lehrplanCoverage: { 'AT-GW-1.4.2': tc.score >= 9 ? 'full' : 'partial' },
milestonesReached: ['welcome-shown', 'question-answered', 'module-completed'],
});
document.getElementById('doneBtn').addEventListener('click', exitModule);
}
function exitModule() {
if (window.parent !== window) {
window.parent.postMessage({ type: 'module-exit', slug: 'hallo-welt' }, '*');
} else {
history.back();
}
}
// ── 4. Topbar-Aktionen ─────────────────────────────────────────────
// Tri-State-Toggle: aus → hell → dunkel → aus
document.getElementById('hcToggle').addEventListener('click', () => {
const btn = document.getElementById('hcToggle');
const cur = btn.dataset.state || 'off';
const next = cur === 'off' ? 'light' : cur === 'light' ? 'dark' : 'off';
btn.dataset.state = next;
if (next === 'off') {
delete document.body.dataset.highContrast;
btn.setAttribute('aria-pressed', 'false');
btn.querySelector('.icon').textContent = '◐';
} else {
document.body.dataset.highContrast = next;
btn.setAttribute('aria-pressed', 'true');
btn.querySelector('.icon').textContent = next === 'light' ? '☀' : '☾';
}
btn.setAttribute('aria-label',
t('highContrastLabel') + ' — ' +
(next === 'off' ? t('hcOff') : next === 'light' ? t('hcLight') : t('hcDark'))
);
});
document.getElementById('exitBtn').addEventListener('click', () => {
if (confirm(t('exitConfirm'))) {
tc.exit('user-cancelled');
exitModule();
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') document.getElementById('exitBtn').click();
});
// ── Helpers ────────────────────────────────────────────────────────
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
}
// Final-Heartbeat bei Tab-Close (best effort)
addEventListener('beforeunload', () => {
if (navigator.sendBeacon) {
navigator.sendBeacon(`${apiBase}/telemetry${ext}`,
new Blob([JSON.stringify({
type: 'heartbeat',
moduleSlug: 'hallo-welt',
moduleVersion: '1.0.0',
phase: 'tab-closed',
phaseLabel: 'Tab wurde geschlossen',
timeInPhaseS: elapsedPhase(),
totalTimeS: elapsedTotal(),
currentScore: score, scoreMax,
ts: new Date().toISOString(),
})], { type: 'application/json' }));
}
});
// Los geht's
renderWelcome();
</script>
</body>
</html>