Improve mobile UI responsiveness, touch tap-to-translate, and PWA icon

This commit is contained in:
chrisf committed 2026-10-21 04:58:22 +00:00
1 parent 5f013dbdfe
commit c3e66b4c98
6 files changed
+442 -28

No files matched your search

+1 -1
View File
@@ -9,7 +9,7 @@ ENV DATA_DIR=/app/store
COPY package*.json ./ COPY package*.json ./
RUN npm install --omit=dev RUN npm install --omit=dev
COPY index.html styles.css app.js ./ COPY index.html styles.css app.js icon.svg ./
COPY data/ ./data/ COPY data/ ./data/
COPY server/ ./server/ COPY server/ ./server/
+46 -16
View File
@@ -218,38 +218,65 @@ class WortKraftApp {
setupHoverListeners() { setupHoverListeners() {
let lastHoveredWord = null; let lastHoveredWord = null;
document.addEventListener("mousemove", e => { const handlePointLookup = (clientX, clientY, targetEl) => {
if (!this.hoverTranslateEnabled) return; if (!this.hoverTranslateEnabled) return false;
// 1. Check if directly over an explicit .word-token span // 1. Check if directly over/tapping an explicit .word-token span
const tokenEl = e.target.closest?.(".word-token"); const tokenEl = targetEl?.closest?.(".word-token");
if (tokenEl) { if (tokenEl) {
const word = tokenEl.dataset.word || tokenEl.textContent.trim(); const word = tokenEl.dataset.word || tokenEl.textContent.trim();
if (word) { if (word) {
this.showHoverTooltipForRect(tokenEl.getBoundingClientRect(), word, true); this.showHoverTooltipForRect(tokenEl.getBoundingClientRect(), word, true);
lastHoveredWord = word; lastHoveredWord = word;
return; return true;
} }
} }
// 2. Universal German word detector under cursor (works on headings, cards, buttons, chips, etc.) // 2. Universal German word detector under cursor/tap
const hit = this.getWordAtPoint(e.clientX, e.clientY); const hit = this.getWordAtPoint(clientX, clientY);
if (hit && hit.word) { if (hit && hit.word) {
const info = this.lookupWordInfo(hit.word, null); const info = this.lookupWordInfo(hit.word, null);
// Show tooltip if word is in our German dictionaries
if (!info.needsLiveTranslate) { if (!info.needsLiveTranslate) {
this.showHoverTooltipForRect(hit.rect, hit.word, false); this.showHoverTooltipForRect(hit.rect, hit.word, false);
lastHoveredWord = hit.word; lastHoveredWord = hit.word;
return; return true;
} }
} }
return false;
};
if (lastHoveredWord) { document.addEventListener("mousemove", e => {
if (!this.hoverTranslateEnabled) return;
const matched = handlePointLookup(e.clientX, e.clientY, e.target);
if (!matched && lastHoveredWord) {
lastHoveredWord = null; lastHoveredWord = null;
this.hideHoverTooltip(); this.hideHoverTooltip();
} }
}); });
// Mobile & Touch support: tap a word to show translation tooltip, tap elsewhere to dismiss
document.addEventListener(
"touchstart",
e => {
if (!this.hoverTranslateEnabled) return;
const touch = e.touches?.[0];
if (!touch) return;
// Ignore taps on interactive controls like buttons/inputs unless it's a .word-token
const isToken = e.target?.closest?.(".word-token");
const isInteractiveControl = e.target?.closest?.("button, input, select, textarea, a");
if (isInteractiveControl && !isToken) {
this.hideHoverTooltip();
return;
}
const matched = handlePointLookup(touch.clientX, touch.clientY, e.target);
if (!matched) {
lastHoveredWord = null;
this.hideHoverTooltip();
}
},
{ passive: true }
);
window.addEventListener("scroll", () => this.hideHoverTooltip(), { passive: true }); window.addEventListener("scroll", () => this.hideHoverTooltip(), { passive: true });
} }
@@ -353,11 +380,14 @@ class WortKraftApp {
${info.plural ? `<div class="hover-tt-note" style="color:#a1a1aa;font-size:0.74rem;margin-top:0.15rem;">Plural: ${info.plural}</div>` : ""} ${info.plural ? `<div class="hover-tt-note" style="color:#a1a1aa;font-size:0.74rem;margin-top:0.15rem;">Plural: ${info.plural}</div>` : ""}
`; `;
const vw = window.innerWidth || 360;
const maxW = Math.min(300, vw - 24);
const halfW = Math.round(maxW / 2) + 12;
const left = Math.min( const left = Math.min(
window.innerWidth - 160, Math.max(halfW, vw - halfW),
Math.max(160, rect.left + rect.width / 2) Math.max(halfW, rect.left + rect.width / 2)
); );
const top = Math.max(55, rect.top); const top = Math.max(64, rect.top);
// Apply explicit inline critical styles in addition to CSS class so it is 100% immune to stale CSS cache // Apply explicit inline critical styles in addition to CSS class so it is 100% immune to stale CSS cache
Object.assign(tooltip.style, { Object.assign(tooltip.style, {
@@ -374,7 +404,7 @@ class WortKraftApp {
transform: "translate(-50%, -100%) translateY(-8px)", transform: "translate(-50%, -100%) translateY(-8px)",
opacity: "1", opacity: "1",
display: "block", display: "block",
maxWidth: "310px", maxWidth: `${maxW}px`,
fontSize: "0.84rem", fontSize: "0.84rem",
lineHeight: "1.35", lineHeight: "1.35",
border: "1px solid rgba(255,255,255,0.16)" border: "1px solid rgba(255,255,255,0.16)"
@@ -2368,7 +2398,7 @@ class WortKraftApp {
</div> </div>
</div> </div>
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.75rem; margin: 1.25rem 0;"> <div class="prep-options-grid">
${optionsHtml} ${optionsHtml}
</div> </div>
@@ -2416,7 +2446,7 @@ class WortKraftApp {
<div class="cloze-en" style="margin-top: 0.3rem;">Gesuchtes Verb: <strong>${d.verb} (${d.english})</strong></div> <div class="cloze-en" style="margin-top: 0.3rem;">Gesuchtes Verb: <strong>${d.verb} (${d.english})</strong></div>
</div> </div>
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.65rem; margin: 1.25rem 0;"> <div class="dawo-options-grid">
${daChoices ${daChoices
.map( .map(
ch => ` ch => `
+23
View File
@@ -0,0 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="WortKraft">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1E2633"/>
<stop offset="100%" stop-color="#0F141C"/>
</linearGradient>
<linearGradient id="gold" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#FBBF24"/>
<stop offset="100%" stop-color="#D97706"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="16" fill="url(#bg)"/>
<rect x="1" y="1" width="62" height="62" rx="15" fill="none" stroke="#FFFFFF" stroke-opacity="0.08" stroke-width="1"/>
<path d="M42 4 H50 V16 L46 13 L42 16 Z" fill="#DC2626"/>
<path d="M42 4 H50 V8 H42 Z" fill="#18181B"/>
<path d="M42 11 H50 V16 L46 13 L42 16 Z" fill="#F59E0B"/>
<path d="M12 20 C18 18 26 19 32 23 C38 19 46 18 52 20 V47 C46 45 38 46 32 50 C26 46 18 45 12 47 Z" fill="#FFFBEB" stroke="#FDE68A" stroke-width="1.5" stroke-linejoin="round"/>
<line x1="32" y1="23" x2="32" y2="50" stroke="#D97706" stroke-width="2" stroke-linecap="round"/>
<line x1="17" y1="27" x2="27" y2="28" stroke="#2563EB" stroke-width="2.5" stroke-linecap="round"/>
<line x1="17" y1="33" x2="27" y2="34" stroke="#E11D48" stroke-width="2.5" stroke-linecap="round"/>
<line x1="17" y1="39" x2="25" y2="40" stroke="#059669" stroke-width="2.5" stroke-linecap="round"/>
<polygon points="43,24 36,34 41,34 39,44 48,32 42,32" fill="url(#gold)" stroke="#B45309" stroke-width="0.8" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+16 -11
View File
@@ -2,12 +2,17 @@
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#18181b" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>WortKraft — Beyond Duolingo German A2/B1</title> <title>WortKraft — Beyond Duolingo German A2/B1</title>
<link rel="icon" type="image/svg+xml" href="icon.svg" />
<link rel="apple-touch-icon" href="icon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css?v=5" /> <link rel="stylesheet" href="styles.css?v=6" />
</head> </head>
<body> <body>
@@ -54,11 +59,11 @@
</nav> </nav>
<div class="header-actions"> <div class="header-actions">
<button id="hover-translate-toggle" class="hover-toggle-btn active" onclick="app.toggleHoverTranslate()" title="Wörter beim Drüberfahren (Hover) automatisch ins Englische übersetzen"> <button id="hover-translate-toggle" class="hover-toggle-btn active" onclick="app.toggleHoverTranslate()" title="Wörter beim Drüberfahren (Hover) oder Antippen automatisch ins Englische übersetzen">
🔍 Hover-Übersetzer: <span id="hover-translate-status">AN</span> 🔍 <span class="hover-label-text">Hover-Übersetzer: </span><span id="hover-translate-status">AN</span>
</button> </button>
<div class="streak-chip" title="1400+ Days Foundation + Active Recall Streak"> <div class="streak-chip" title="1400+ Days Foundation + Active Recall Streak">
🔥 <span id="streak-count">1401</span> Tage 🔥 <span id="streak-count">1401</span> <span class="streak-days-word">Tage</span>
</div> </div>
<button class="icon-btn" onclick="app.openModal('add-word-modal')" title="Neues Wort hinzufügen (+)">➕</button> <button class="icon-btn" onclick="app.openModal('add-word-modal')" title="Neues Wort hinzufügen (+)">➕</button>
<button class="icon-btn" onclick="app.openModal('settings-modal')" title="KI & Einstellungen">⚙️</button> <button class="icon-btn" onclick="app.openModal('settings-modal')" title="KI & Einstellungen">⚙️</button>
@@ -491,11 +496,11 @@
<div id="toast" class="toast-notification"></div> <div id="toast" class="toast-notification"></div>
<!-- SCRIPTS --> <!-- SCRIPTS -->
<script src="data/vocabulary.js?v=5"></script> <script src="data/vocabulary.js?v=6"></script>
<script src="data/stories.js?v=5"></script> <script src="data/stories.js?v=6"></script>
<script src="data/scenarios.js?v=5"></script> <script src="data/scenarios.js?v=6"></script>
<script src="data/hover_dict.js?v=5"></script> <script src="data/hover_dict.js?v=6"></script>
<script src="data/labs.js?v=5"></script> <script src="data/labs.js?v=6"></script>
<script src="app.js?v=5"></script> <script src="app.js?v=6"></script>
</body> </body>
</html> </html>
+1
View File
@@ -196,6 +196,7 @@ app.use((req, res, next) => {
next(); next();
}); });
app.use('/data', express.static(path.join(PUBLIC_DIR, 'data'))); app.use('/data', express.static(path.join(PUBLIC_DIR, 'data')));
app.get('/icon.svg', (_req, res) => res.sendFile(path.join(PUBLIC_DIR, 'icon.svg')));
app.get('/styles.css', (_req, res) => res.sendFile(path.join(PUBLIC_DIR, 'styles.css'))); app.get('/styles.css', (_req, res) => res.sendFile(path.join(PUBLIC_DIR, 'styles.css')));
app.get('/app.js', (_req, res) => res.sendFile(path.join(PUBLIC_DIR, 'app.js'))); app.get('/app.js', (_req, res) => res.sendFile(path.join(PUBLIC_DIR, 'app.js')));
app.get('/', (_req, res) => res.sendFile(path.join(PUBLIC_DIR, 'index.html'))); app.get('/', (_req, res) => res.sendFile(path.join(PUBLIC_DIR, 'index.html')));
+355
View File
@@ -1261,4 +1261,359 @@ body.hover-translate-on .word-token:hover {
border-radius: 6px; border-radius: 6px;
} }
.prep-options-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
margin: 1.25rem 0;
}
.dawo-options-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.65rem;
margin: 1.25rem 0;
}
/* === MOBILE & TABLET RESPONSIVE CLEANUP === */
.nav-tabs::-webkit-scrollbar,
.mode-pills::-webkit-scrollbar {
display: none;
}
.nav-tabs,
.mode-pills {
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
}
@media (max-width: 940px) {
.header-inner {
padding: 0.6rem 0.9rem;
gap: 0.55rem;
}
.brand {
order: 1;
}
.header-actions {
order: 2;
margin-left: auto;
gap: 0.35rem;
}
.nav-tabs {
order: 3;
width: 100%;
padding: 0.25rem;
gap: 0.25rem;
}
.nav-tab {
padding: 0.42rem 0.7rem;
font-size: 0.82rem;
flex-shrink: 0;
}
/* Turn story and scenario lists into horizontal swipe strips on mobile/tablet so content is immediately visible */
#story-list-container,
#scenario-list-container {
flex-direction: row !important;
overflow-x: auto;
padding-bottom: 0.35rem;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
}
#story-list-container .story-card-item,
#scenario-list-container .story-card-item {
min-width: 245px;
max-width: 270px;
flex-shrink: 0;
scroll-snap-align: start;
padding: 0.8rem 0.95rem;
}
}
@media (max-width: 640px) {
.brand-subtitle {
display: none;
}
.brand-flag {
width: 30px;
height: 30px;
border-radius: 8px;
}
.brand-title {
font-size: 1.02rem;
}
.hover-toggle-btn .hover-label-text {
display: none;
}
.hover-toggle-btn {
padding: 0.36rem 0.6rem;
font-size: 0.78rem;
}
.streak-chip {
padding: 0.34rem 0.55rem;
font-size: 0.76rem;
}
.streak-chip .streak-days-word {
display: none;
}
.icon-btn {
width: 34px;
height: 34px;
border-radius: 8px;
font-size: 0.9rem;
}
.main-container {
padding: 0.9rem 0.75rem 4.5rem;
}
.hero-banner {
padding: 1.15rem;
margin-bottom: 1rem;
border-radius: var(--radius-md);
gap: 1rem;
}
.hero-text h1 {
font-size: 1.22rem;
line-height: 1.3;
}
.hero-text p {
font-size: 0.86rem;
}
.gender-legend-bar {
gap: 0.35rem;
margin-top: 0.75rem;
}
.gender-badge {
font-size: 0.72rem;
padding: 0.12rem 0.45rem;
}
.hero-cta-group {
width: 100%;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.hero-cta-group .btn {
justify-content: center;
padding: 0.6rem 0.5rem;
font-size: 0.82rem;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
gap: 0.6rem;
margin-bottom: 1.1rem;
}
.stat-card {
padding: 0.85rem 0.9rem;
}
.stat-label {
font-size: 0.68rem;
}
.stat-value {
font-size: 1.35rem;
}
.stat-sub {
font-size: 0.72rem;
}
.panel-card,
.srs-card-stage,
.reader-main {
padding: 1.1rem 0.95rem;
border-radius: var(--radius-md);
}
.launcher-item {
padding: 0.8rem 0.85rem;
gap: 0.65rem;
}
.launcher-icon {
width: 38px;
height: 38px;
font-size: 1.1rem;
}
.launcher-title {
font-size: 0.9rem;
}
.launcher-desc {
font-size: 0.78rem;
line-height: 1.35;
}
/* Horizontal scrollable mode pills on mobile */
.trainer-controls {
gap: 0.65rem;
margin-bottom: 1rem;
}
.mode-pills {
flex-wrap: nowrap;
overflow-x: auto;
width: 100%;
padding-bottom: 0.15rem;
}
.mode-pill {
flex-shrink: 0;
font-size: 0.8rem;
padding: 0.45rem 0.8rem;
}
#trainer-category-select {
width: 100%;
}
.prompt-main {
font-size: 1.25rem;
}
.cloze-box {
padding: 0.8rem 0.95rem;
}
.cloze-de {
font-size: 1.02rem;
}
/* Stack input and buttons cleanly on mobile; 16px prevents iOS Safari auto-zoom */
.answer-input-row {
flex-wrap: wrap;
gap: 0.5rem;
}
.german-input {
width: 100%;
flex: 1 1 100%;
font-size: 16px !important;
padding: 0.68rem 0.85rem;
}
.answer-input-row .btn {
flex: 1;
justify-content: center;
padding: 0.65rem 0.75rem;
}
.umlaut-bar {
gap: 0.3rem;
margin-bottom: 0.9rem;
}
.umlaut-key {
min-width: 36px;
height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.92rem;
padding: 0;
}
/* 2x2 thumb-friendly grids on mobile */
.gender-choice-grid,
.srs-grade-buttons {
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
}
.gender-choice-btn {
padding: 0.8rem;
font-size: 1rem;
}
.prep-options-grid {
grid-template-columns: 1fr;
gap: 0.5rem;
}
.dawo-options-grid {
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
}
/* Compound blocks wrap cleanly on mobile */
.compound-blocks-row {
flex-direction: column;
gap: 0.35rem;
}
.compound-plus {
justify-content: center;
font-size: 1rem;
line-height: 1;
}
.compound-block {
width: 100%;
padding: 0.6rem 0.8rem;
}
.story-de-line {
font-size: 1.05rem;
line-height: 1.75;
}
.chat-bubble {
max-width: 94%;
padding: 0.8rem 0.95rem;
font-size: 0.9rem;
}
.vocab-filter-bar {
flex-direction: column;
gap: 0.5rem;
}
.vocab-filter-bar .german-input {
max-width: 100% !important;
}
.vocab-grid,
.prefix-grid {
grid-template-columns: 1fr;
}
.modal-card {
padding: 1.2rem;
max-height: 90vh;
overflow-y: auto;
}
.toast-notification {
left: 0.75rem;
right: 0.75rem;
bottom: 0.75rem;
text-align: center;
}
}