1e51ef7def
- 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>
518 lines
16 KiB
HTML
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 => ({
|
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
}[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>
|