Compare commits

..

2 Commits
main ... pages

6 changed files with 1616 additions and 0 deletions

173
AGENTS.md Normal file
View File

@ -0,0 +1,173 @@
<!-- vibe42-agents-version: v3-guided-2026-06-01 -->
# Vibe42 — учебная песочница для лендингов
Workspace юзера `Lyailyum_Saparova`. Это **учебная среда**, где обычные люди (не разработчики) пробуют сделать свой первый сайт.
---
## 🎯 ТВОЯ РОЛЬ
Ты — **гид и помощник**, а не слепой исполнитель. Цель сессии — чтобы юзер вышел с:
1. **рабочим лендингом**, опубликованным по адресу `https://pages.git.vibe42.kz/Lyailyum_Saparova/<repo>/`,
2. ощущением «это было легко» — без серверов, БД, токенов, конфигов.
Юзер не разработчик. Ему важен **результат, который видно в браузере**, а не код.
---
## 🗺 СЦЕНАРИЙ ПЕРВОГО ЗАХОДА (юзер только зашёл, ещё ничего нет)
1. Поздоровайся коротко: «Привет! Тут за 10 минут собираем лендинг и публикуем его в интернете. О чём хочешь сделать?»
2. Если он не знает — предложи **4 конкретных идеи** (выбирай близкие к нему, не абстрактные):
- Промо хобби (фотография / музыка / спорт)
- Резюме / personal page с контактами
- Афиша мероприятия (концерт, день рождения, мастер-класс)
- Меню заведения / прайс услуг
- Лендинг продукта или будущего проекта (waitlist)
3. Уточни **2 короткие детали**: стиль (тёмный/светлый/яркий) и главную цель (рассказать / собрать заявку / показать работы).
4. Сразу делай `./new-project <name>` и собирай страницу. Не спрашивай разрешения на каждый шаг.
---
## 💬 ЕСЛИ ЮЗЕР ОТВЕЧАЕТ РАСПЛЫВЧАТО
Юзер говорит «сделай что-нибудь» / «ну хз» / «сюрприз» → **не делай ничего абстрактного**.
Скажи: «Давай определимся, я задам 3 коротких вопроса:
1. Это для тебя лично, для проекта/бизнеса, или для события?
2. Главная цель — рассказать о чём-то / собрать заявку / показать портфолио?
3. Любимое настроение — строгое тёмное, лёгкое светлое, яркое цветное?»
После ответов **сразу** предложи 2 конкретных варианта названия+структуры. Дай выбрать и иди делать.
---
## 🚫 ЕСЛИ ЮЗЕР ХОЧЕТ СЛОЖНОЕ — ПЕРЕФОРМУЛИРУЙ В ЛЕНДИНГ
| Запрос | Что делаем вместо |
|--------|-------------------|
| «магазин с корзиной» | лендинг с товарами + кнопка «купить» = ссылка на WhatsApp / Telegram |
| «соцсеть» | лендинг будущего проекта + waitlist-форма (Formspree / Getform) |
| «блог с админкой» | personal-page + ссылки на статьи в Telegram/Medium |
| «приложение для записи» | лендинг услуги + ссылка на Calendly / WhatsApp |
| «сайт с входом юзеров» | публичный лендинг без логина (нам логин не нужен) |
| «бот в Telegram» | лендинг с описанием бота + кнопка `t.me/...` |
**Не говори «это невозможно».** Скажи: «У нас песочница только для статических сайтов. Давай сделаем лендинг, который покажет твою идею — а кнопки/формы свяжем с готовыми сервисами (WhatsApp, Telegram, Formspree)». Юзер счастлив, результат за 15 минут.
---
## 📐 ШАБЛОНЫ СТРАНИЦ (выбирай под идею юзера)
### A — Промо продукта/услуги
**Секции:** Hero (заголовок + подзаголовок + CTA-кнопка) → 3-4 преимущества (иконка emoji + текст) → социальное доказательство (отзыв или цифра) → CTA (кнопка/телефон/мессенджер).
### B — Personal / резюме
**Секции:** Hero (фото-аватарка + имя + одна фраза «кто я») → О себе (1-2 абзаца) → 3-5 карточек проектов/опыта → Контакты (email, telegram, github как ссылки-кнопки).
### C — Афиша мероприятия
**Секции:** Hero (название + дата + место крупно) → Программа (список с временем) → Локация (картинка-placeholder + адрес) → Регистрация (форма Formspree или контакт).
### D — Меню / прайс
**Секции:** Hero (название + слоган) → Меню/прайс (категории с ценами) → Контакты (телефон, адрес, часы работы, карта-картинка).
### E — Waitlist для будущего проекта
**Секции:** Hero (название проекта + одна фраза + email-форма) → 3 фичи «что будет» → FAQ (3 пункта) → CTA (та же email-форма).
Все шаблоны — **одна страница, прокрутка вниз**. Никаких роутов, ничего динамического.
---
## ⚡ РИТУАЛ ПОСЛЕ ПЕРВОГО ЗАПУСКА
Как только готов первый рабочий вариант (даже грубый):
1. **Сразу запушь:**
```bash
git add -A
git commit -m "v1"
git push origin HEAD:pages
```
2. **ОБЯЗАТЕЛЬНО** дай юзеру ссылку **жирно**:
> 🎉 Готово! Твой лендинг здесь: **https://pages.git.vibe42.kz/Lyailyum_Saparova/<repo>/**
3. Скажи: «Открой в новой вкладке, посмотри. Что хочешь поменять?»
4. Дальше короткие итерации: правка → push → новый URL-показ. Каждые 2-3 правки — push.
---
## ⚠️ ЖЕЛЕЗНЫЕ ПРАВИЛА (НЕ нарушать никогда)
1. **Только статика — HTML + CSS + JS в браузере.**
2. **Никакого бэкенда.** Никаких Node/Express/FastAPI/Django/PHP/Go-серверов. Никаких БД. Никакого Redis.
3. **Никакой аутентификации / OAuth / JWT.**
4. **Никакого Docker, nginx, sudo, системных настроек.**
5. **Никаких тяжёлых сборщиков** (`npm install` дерево на 500МБ). Tailwind — только через CDN.
6. **НИКОГДА `git init` в workspace root (`/workspaces/Lyailyum_Saparova`)** — это папка-контейнер юзера, не репозиторий.
---
## ✅ ВСЕГДА работай через `./new-project`
Если юзер сказал «сделай сайт NAME» / «создай проект NAME»:
```bash
cd /workspaces/Lyailyum_Saparova
./new-project NAME # создаёт repo в Gitea + клонит локально в ./NAME/
cd NAME
# теперь создавай index.html / style.css / script.js внутри ./NAME
```
`./new-project` сам создаёт repo, клонит, и копирует туда `AGENTS.md` + `design.md`.
---
## 🌐 Git и публикация
**НЕТ GitHub.** Self-hosted git: **https://git.vibe42.kz**
- Профиль юзера: https://git.vibe42.kz/Lyailyum_Saparova
- Pages (живые лендинги): https://pages.git.vibe42.kz/Lyailyum_Saparova/<repo>/
- Креды уже в `/workspaces/Lyailyum_Saparova/.git-credentials` — git push/clone работают без пароля
- **НЕ спрашивай юзера про GitHub URL / токен** — их не нужно
### Опубликовать лендинг
```bash
git add -A
git commit -m "site"
git push origin HEAD:pages
```
Ветка **`pages`** (Caddy её обслуживает; `gh-pages` тоже работает как fallback). Push → лендинг доступен мгновенно.
---
## 🔧 Когда что-то идёт не так
- **Pages 404** → запушь ветку `pages` снова: `git push origin HEAD:pages -f`
- **Не дёргай Gitea API типа `/repos/.../pages`, `/settings/pages`, `/deploy_keys`** — их нет
- **Не пытайся «настроить Pages через UI Gitea»** — Pages у нас работают только через push в ветку `pages`
- Запуталось — сделай новый чистый проект через `./new-project NAME-v2`, перенеси туда работающий index.html
---
## ❌ Чего НЕ делать НИКОГДА
- ❌ `git init` в workspace root
- ❌ `npm install` с прод-зависимостями (express/mongoose/pg/prisma/next/nuxt)
- ❌ Создавать `server.js` / `app.py` / `main.go` как backend
- ❌ Использовать `gh` CLI или GitHub API
- ❌ Вызывать Gitea Pages-API (его нет)
- ❌ Долгое отлаживание Pages — почти всегда решение «push HEAD:pages»
- ❌ Просить юзера ввести токен/URL/пароль — всё уже настроено
- ❌ Задавать юзеру 10 вопросов подряд (максимум 2-3 за раз)
- ❌ Показывать юзеру голый код больше 1 раза — ему важен результат, а не как написано
- ❌ Предлагать «давай сначала дизайн в Figma» — мы делаем сразу в HTML
- ❌ Говорить «это сложно» — переформулируй в простое
- ❌ Зависать в обсуждениях — сделай первый вариант грубо, потом итерируй
---
## 🎨 design.md
Рядом лежит `design.md` с готовой палитрой, типографикой и стартер-шаблоном `index.html`. **Начинай с него.** Не выдумывай новые цвета — модифицируй существующие.

669
app.js Normal file
View File

@ -0,0 +1,669 @@
(function(){
"use strict";
var CUR = window.CURRICULUM || [];
var N = CUR.length;
var SECTIONS = [
{id:"words", icon:"📖", label:"Слова"},
{id:"dialogue", icon:"💬", label:"Диалог"},
{id:"reading", icon:"📰", label:"Чтение"},
{id:"listening", icon:"🎧", label:"Аудио"},
{id:"speaking", icon:"🎤", label:"Говорю"},
{id:"writing", icon:"✍️", label:"Пишу"}
];
var ACTIVE = "words";
var curIdx = 0;
var todayContentIdx = 0;
var dayKey = 0;
var state = null;
/* ---------- storage ---------- */
function todayStr(){ var d=new Date(); return d.getFullYear()+"-"+String(d.getMonth()+1).padStart(2,"0")+"-"+String(d.getDate()).padStart(2,"0"); }
function dateStr(d){ return d.getFullYear()+"-"+String(d.getMonth()+1).padStart(2,"0")+"-"+String(d.getDate()).padStart(2,"0"); }
function load(){
try{ state = JSON.parse(localStorage.getItem("enTrainer")||"null"); }catch(e){ state=null; }
var today = todayStr();
if(!state){ state={startDate:today, streak:1, lastVisit:today, completed:{}, wordsLearned:[], reminderOn:false, reminderTime:"20:00", lastNotified:null, tests:{}}; }
if(state.reminderOn===undefined) state.reminderOn=false;
if(!state.reminderTime) state.reminderTime="20:00";
if(state.lastNotified===undefined) state.lastNotified=null;
if(!state.tests) state.tests={};
// streak
if(state.lastVisit !== today){
var y=new Date(); y.setDate(y.getDate()-1);
state.streak = (state.lastVisit===dateStr(y)) ? state.streak+1 : 1;
state.lastVisit = today;
}
if(!state.completed) state.completed={};
if(!state.completed[today]) state.completed[today]=[];
if(!state.wordsLearned) state.wordsLearned=[];
save();
}
function save(){ localStorage.setItem("enTrainer", JSON.stringify(state)); }
function completedToday(){ return state.completed[todayStr()] || (state.completed[todayStr()]=[]); }
function isDone(id){ return completedToday().indexOf(id)>=0; }
function setDone(id, on){
var c=completedToday();
var i=c.indexOf(id);
if(on && i<0){ c.push(id); }
if(!on && i>=0){ c.splice(i,1); }
save();
}
/* ---------- day math ---------- */
function computeDay(){
var s=new Date(state.startDate+"T00:00:00");
var t=new Date(); t.setHours(0,0,0,0);
dayKey = Math.max(0, Math.round((t-s)/86400000));
todayContentIdx = ((dayKey % N)+N)%N;
curIdx = todayContentIdx;
}
function day(){ return CUR[curIdx]; }
/* ---------- misc helpers ---------- */
function shuffle(a){ a=a.slice(); for(var i=a.length-1;i>0;i--){ var j=Math.floor(Math.random()*(i+1)); var t=a[i]; a[i]=a[j]; a[j]=t; } return a; }
function flatten(a){ var r=[]; a.forEach(function(x){ x.forEach(function(y){ r.push(y); }); }); return r; }
function currentMonth(){ return Math.min(5, Math.floor(dayKey/30)); }
function monthLabel(m){ return "Месяц "+(m+1)+" из 6"; }
/* ---------- speech ---------- */
var voices=[];
var synth = window.speechSynthesis;
function loadVoices(){ if(synth) voices = synth.getVoices(); }
if(synth){ loadVoices(); synth.onvoiceschanged=loadVoices; }
function enVoice(pref){
var en = voices.filter(function(v){ return /en(-|_)?US/i.test(v.lang) || /en(-|_)?GB/i.test(v.lang); });
if(!en.length) en = voices.filter(function(v){ return /^en/i.test(v.lang); });
if(pref) for(var i=0;i<en.length;i++){ if(new RegExp(pref,"i").test(en[i].name)) return en[i]; }
return en[0]||null;
}
function speak(text, opts){
opts=opts||{};
if(!synth) return Promise.resolve();
return new Promise(function(res){
var u=new SpeechSynthesisUtterance(text);
u.lang="en-US";
var v=enVoice(opts.voice);
if(v) u.voice=v;
u.rate=opts.rate||1; u.pitch=opts.pitch||1;
u.onend=res; u.onerror=res;
synth.speak(u);
});
}
function stopSpeak(){ if(synth) synth.cancel(); }
/* ---------- recognition ---------- */
var REC = window.SpeechRecognition || window.webkitSpeechRecognition || null;
var currentRec=null;
function norm(s){ return (s||"").toLowerCase().replace(/[^a-z0-9\s']/g," ").replace(/\s+/g," ").trim(); }
function similarity(spoken, target){
var a=norm(spoken).split(" ").filter(Boolean);
var b=norm(target).split(" ").filter(Boolean);
if(!b.length) return 0;
var set={}; b.forEach(function(w){ set[w]=1; });
var hit=0; a.forEach(function(w){ if(set[w]){ hit++; delete set[w]; } });
return hit/b.length;
}
function levelOf(sim){ return sim>=0.8?"good":sim>=0.5?"ok":"bad"; }
function stopRec(){ if(currentRec){ try{currentRec.stop();}catch(e){} currentRec=null; } }
function startRec(onResult, onEnd){
if(!REC){ toast("Микрофон не поддерживается в этом браузере 🙁 Попробуй Chrome"); return false; }
stopRec(); stopSpeak();
var r=new REC();
r.lang="en-US"; r.interimResults=false; r.maxAlternatives=3;
r.onresult=function(e){
var t=""; for(var i=0;i<e.results.length;i++){ t+=e.results[i][0].transcript; }
onResult(t);
};
r.onerror=function(){ onEnd&&onEnd(); };
r.onend=function(){ onEnd&&onEnd(); };
currentRec=r;
try{ r.start(); return true; }catch(e){ onEnd&&onEnd(); return false; }
}
/* ---------- utils ---------- */
function $(id){ return document.getElementById(id); }
function esc(s){ return (s||"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;"); }
var toastT;
function toast(msg){
var t=$("toast"); t.textContent=msg; t.classList.add("show");
clearTimeout(toastT); toastT=setTimeout(function(){ t.classList.remove("show"); },2200);
}
/* ---------- render: header + dashboard ---------- */
function renderTop(){
$("streak").innerHTML="🔥 "+state.streak;
$("level").textContent="Intermediate";
$("bellBtn").classList.toggle("on", !!state.reminderOn);
}
function renderDashboard(){
var elapsed = dayKey+1;
var total=180;
var pct = Math.min(100, Math.round(elapsed/total*100));
var left = Math.max(0, total-elapsed);
$("goalTitle").textContent = "Цель: свободный английский за 6 месяцев";
$("goalSub").textContent = "Ты на пути! Каждый день — новый шаг к свободной речи с коллегами.";
$("goalBar").style.width = pct+"%";
$("goalMetaL").innerHTML = "День <b>"+elapsed+"</b> из "+total;
$("goalMetaR").innerHTML = "Осталось <b>"+left+"</b> дн.";
// mission chips
var h="";
SECTIONS.forEach(function(s){
var d=isDone(s.id);
h+='<div class="mchip '+(d?"done":"")+'" data-sec="'+s.id+'"><span class="ic">'+s.icon+'</span>'+s.label+'</div>';
});
$("mission").innerHTML=h;
$("mission").querySelectorAll(".mchip").forEach(function(el){
el.addEventListener("click",function(){ switchTab(el.getAttribute("data-sec")); window.scrollTo({top:0,behavior:"smooth"}); });
});
}
/* ---------- render: tabs ---------- */
function renderTabs(){
var h="";
SECTIONS.forEach(function(s){
h+='<button class="tab '+(ACTIVE===s.id?"active":"")+'" data-sec="'+s.id+'"><span class="dot"></span>'+s.icon+" "+s.label+'</button>';
});
h+='<button class="tab tab-test '+(ACTIVE==="test"?"active":"")+'" data-sec="test">🎯 Тест</button>';
$("tabrow").innerHTML=h;
$("tabrow").querySelectorAll(".tab").forEach(function(el){
el.addEventListener("click",function(){ switchTab(el.getAttribute("data-sec")); });
});
}
function switchTab(id){
stopSpeak(); stopRec();
ACTIVE=id;
document.querySelectorAll(".tab").forEach(function(el){ el.classList.toggle("active", el.getAttribute("data-sec")===id); });
document.querySelectorAll(".panel").forEach(function(p){ p.classList.remove("active"); });
$(id).classList.add("active");
renderPanel(id);
var isTest = id==="test";
$("dayBar").style.display = isTest ? "none" : "";
document.querySelector(".done-bar").style.display = isTest ? "none" : "";
document.body.style.paddingBottom = isTest ? "24px" : "84px";
if(!isTest) renderDoneBar();
}
/* ---------- day bar ---------- */
function renderDayBar(){
$("dayTag").innerHTML = 'День <b>'+(curIdx+1)+'</b> / '+N+(curIdx===todayContentIdx?' · сегодня':' · повторение');
}
function bindDayNav(){
$("prevDay").onclick=function(){ if(ACTIVE==="test") return; curIdx=(curIdx-1+N)%N; rerender(); };
$("nextDay").onclick=function(){ if(ACTIVE==="test") return; curIdx=(curIdx+1)%N; rerender(); };
}
/* ---------- panel router ---------- */
function renderPanel(id){
var d=day();
if(id==="words") renderWords(d);
if(id==="dialogue") renderDialogue(d);
if(id==="reading") renderReading(d);
if(id==="listening") renderListening(d);
if(id==="speaking") renderSpeaking(d);
if(id==="writing") renderWriting(d);
if(id==="test") renderTest();
}
function renderTopicCard(d){
return '<div class="topic-card"><div class="em">'+d.emoji+'</div><div><div class="t">'+esc(d.topic)+'</div><div class="s">Тема дня · Intermediate</div></div></div>';
}
/* ---------- words ---------- */
function renderWords(d){
var h=renderTopicCard(d);
h+='<div class="card"><h3>📖 Новые слова ('+d.words.length+')</h3><div class="hint">Нажми на карточку, чтобы увидеть значение. ▶ — послушать произношение.</div>';
d.words.forEach(function(w,i){
var learned = state.wordsLearned.indexOf(w.en)>=0;
h+='<div class="word" data-i="'+i+'"><div class="word-top"><div><div class="word-en">'+esc(w.en)+'</div><div class="word-ru">'+esc(w.ru)+'</div></div><button class="play" data-play="'+i+'">▶</button></div><div class="word-def">'+esc(w.def)+'</div><div class="word-ex">"'+esc(w.ex)+'"</div><div class="stars">'+(learned?"⭐ Изучено":"")+'</div></div>';
});
h+='<div class="btnrow" style="margin-top:14px"><button class="btn ghost" id="playAll">▶ Все слова</button></div>';
h+='</div>';
$("words").innerHTML=h;
$("words").querySelectorAll(".word").forEach(function(el){
el.addEventListener("click",function(e){
if(e.target.closest(".play")) return;
el.classList.toggle("open");
});
});
$("words").querySelectorAll(".play").forEach(function(el){
el.addEventListener("click",function(e){
e.stopPropagation();
var i=+el.getAttribute("data-play");
var w=d.words[i];
stopSpeak(); speak(w.en);
if(state.wordsLearned.indexOf(w.en)<0){ state.wordsLearned.push(w.en); save(); }
var card=el.closest(".word"); card.querySelector(".stars").textContent="⭐ Изучено";
});
});
$("playAll").onclick=function(){
stopSpeak();
var p=Promise.resolve();
d.words.forEach(function(w){ p=p.then(function(){ return speak(w.en,{rate:0.95}).then(function(){ return new Promise(function(r){ setTimeout(r,250); }); }); }); });
};
}
/* ---------- dialogue ---------- */
function renderDialogue(d){
var h=renderTopicCard(d);
h+='<div class="card"><h3>💬 Диалог дня</h3><div class="hint">▶ — проиграть. Нажми строку, чтобы увидеть перевод.</div>';
h+='<div class="btnrow" style="margin-bottom:14px"><button class="btn primary" id="playAllDlg">▶ Весь диалог</button><button class="btn ghost" id="toggleTr">Перевод</button></div>';
h+='<div class="lines" id="lines">';
d.dialogue.forEach(function(l,i){
h+='<div class="line '+l.who+'" data-i="'+i+'"><div class="bubble"><div class="who">'+esc(l.name)+'</div>'+esc(l.text)+'<div class="tr">'+esc(l.tr)+'</div></div></div>';
});
h+='</div>';
h+='<div class="tips">Совет: повторяй реплики вслух за диктором — это тренирует произношение и скорость речи.</div>';
h+='</div>';
$("dialogue").innerHTML=h;
$("lines").querySelectorAll(".line").forEach(function(el){
el.addEventListener("click",function(){ el.classList.toggle("show-tr"); });
});
$("toggleTr").onclick=function(){
var all=$("lines").querySelectorAll(".line");
var anyOff=false; all.forEach(function(el){ if(!el.classList.contains("show-tr")) anyOff=true; });
all.forEach(function(el){ el.classList.toggle("show-tr", anyOff); });
};
$("playAllDlg").onclick=function(){
stopSpeak(); stopRec();
var p=Promise.resolve();
d.dialogue.forEach(function(l){
p=p.then(function(){
return speak(l.text,{rate:0.95, pitch: l.who==="A"?1:1.15, voice: l.who==="A"?"Male":"Female"});
}).then(function(){ return new Promise(function(r){ setTimeout(r,350); }); });
});
};
}
/* ---------- reading ---------- */
function renderReading(d){
var r=d.reading;
var h=renderTopicCard(d);
h+='<div class="card"><h3>📰 '+esc(r.title)+'</h3><div class="hint">Прочитай текст и ответь на вопросы.</div>';
h+='<p style="margin-bottom:16px;line-height:1.7">'+esc(r.text)+'</p>';
r.questions.forEach(function(qi,idx){
h+='<div class="q" data-q="'+idx+'"><div class="qt">'+(idx+1)+'. '+esc(qi.q)+'</div><div class="opts">';
qi.options.forEach(function(op,oi){
h+='<button class="opt" data-o="'+oi+'">'+esc(op)+'</button>';
});
h+='</div><div class="explain">'+esc(qi.ex)+'</div></div>';
});
h+='<div class="score" id="rScore"></div></div>';
$("reading").innerHTML=h;
bindQuiz("reading", r.questions);
}
/* ---------- listening ---------- */
function renderListening(d){
var l=d.listening;
var h=renderTopicCard(d);
h+='<div class="card"><h3>🎧 Аудирование</h3><div class="hint">Послушай аудио и ответь на вопросы. Можно менять скорость.</div>';
h+='<div class="audio-controls"><button class="btn primary" id="playL">▶ Слушать</button><div class="speed"><button data-sp="0.7">0.7×</button><button class="on" data-sp="1">1×</button><button data-sp="1.2">1.2×</button></div></div>';
h+='<button class="btn outline" id="showText" style="margin-bottom:14px">Показать текст</button>';
h+='<div class="listen-text" id="lText">'+esc(l.text)+'</div>';
l.questions.forEach(function(qi,idx){
h+='<div class="q" data-q="'+idx+'"><div class="qt">'+(idx+1)+'. '+esc(qi.q)+'</div><div class="opts">';
qi.options.forEach(function(op,oi){
h+='<button class="opt" data-o="'+oi+'">'+esc(op)+'</button>';
});
h+='</div><div class="explain">'+esc(qi.ex)+'</div></div>';
});
h+='<div class="score" id="lScore"></div></div>';
$("listening").innerHTML=h;
var rate=1;
$("playL").onclick=function(){ stopSpeak(); speak(l.text,{rate:rate}); };
$("listening").querySelectorAll(".speed button").forEach(function(b){
b.onclick=function(){
rate=parseFloat(b.getAttribute("data-sp"));
$("listening").querySelectorAll(".speed button").forEach(function(x){ x.classList.remove("on"); });
b.classList.add("on");
};
});
$("showText").onclick=function(){ $("lText").classList.toggle("show"); this.textContent=$("lText").classList.contains("show")?"Скрыть текст":"Показать текст"; };
bindQuiz("listening", l.questions);
}
/* ---------- quiz shared ---------- */
function bindQuiz(panelId, questions){
var root=$(panelId);
root.querySelectorAll(".q").forEach(function(qEl, qIdx){
var answered=false;
qEl.querySelectorAll(".opt").forEach(function(oEl){
oEl.addEventListener("click",function(){
if(answered) return; answered=true;
var o=+oEl.getAttribute("data-o");
var correct=questions[qIdx].answer;
qEl.querySelectorAll(".opt").forEach(function(x){ x.classList.add("disabled"); });
if(o===correct){ oEl.classList.add("correct"); }
else{ oEl.classList.add("wrong"); var c=qEl.querySelector('.opt[data-o="'+correct+'"]'); if(c) c.classList.add("correct"); }
qEl.querySelector(".explain").classList.add("show");
updateScore(root, questions.length, panelId);
});
});
});
}
function updateScore(root, total, panelId){
var got=0;
root.querySelectorAll(".q").forEach(function(qEl){
if(qEl.querySelector(".opt.correct") && !qEl.querySelector(".opt.wrong")) got++;
});
var sc=$(panelId==="reading"?"rScore":"lScore");
if(sc){
sc.textContent="Результат: "+got+" / "+total+(got===total?" 🎉 Отлично!":"");
sc.classList.add("show");
}
}
/* ---------- speaking ---------- */
function renderSpeaking(d){
var s=d.speaking;
var sup = !!REC;
var h=renderTopicCard(d);
h+='<div class="card"><h3>🎤 Практика говорения</h3><div class="hint">Прочитай задание и ответь вслух. Я распознаю речь и сравню с образцом.</div>';
h+='<div class="tips" style="margin-bottom:14px"><b>Задание:</b> '+esc(s.prompt)+'</div>';
h+='<div class="speak-box">';
if(sup){
h+='<button class="mic" id="mic">🎤</button>';
h+='<div id="micState">Нажми на микрофон и говори</div>';
} else {
h+='<div style="font-size:40px">🎤</div>';
h+='<div class="rec-badge">Микрофон недоступен в этом браузере</div>';
h+='<div style="font-size:13px;color:var(--gray-500)">Открой в Chrome на телефоне или компьютере, чтобы практиковать речь.</div>';
}
h+='<div class="transcript" id="tr"><span class="ph">Твой ответ появится здесь…</span></div>';
h+='<div class="btnrow"><button class="btn ghost" id="hearModel">▶ Образец</button><button class="btn outline" id="showTarget">Показать ответ</button></div>';
h+='<div class="target" id="target"><b>Образцовый ответ:</b>'+esc(s.target)+'</div>';
h+='</div>';
h+='<div class="tips">💡 Не бойся ошибок — главное пробовать говорить вслух каждый день. Это ключ к свободной речи.</div>';
h+='</div>';
$("speaking").innerHTML=h;
$("hearModel").onclick=function(){ stopSpeak(); speak(s.target,{rate:0.9}); };
$("showTarget").onclick=function(){ $("target").classList.toggle("show"); };
if(sup){
var mic=$("mic"), state2=$("micState"), tr=$("tr");
mic.onclick=function(){
if(mic.classList.contains("rec")){ stopRec(); return; }
var ok=startRec(function(t){
tr.innerHTML=esc(t);
var sim=similarity(t, s.target);
var lv=levelOf(sim);
var label = lv==="good"?"Отлично! 🎉":lv==="ok"?"Неплохо, почти! 👍":"Попробуй ещё раз 💪";
var tr2=document.createElement("div"); tr2.className="match "+lv; tr2.textContent=label+" ("+Math.round(sim*100)+"% совпадение)";
tr.appendChild(tr2);
},function(){
mic.classList.remove("rec"); mic.textContent="🎤"; state2.textContent="Нажми на микрофон и говори";
});
if(ok){ mic.classList.add("rec"); mic.textContent="⏹"; state2.innerHTML='<span class="rec-badge"><span class="blink"></span>Слушаю…</span>'; }
};
}
}
/* ---------- writing ---------- */
function renderWriting(d){
var w=d.writing;
var key="w_"+todayStr()+"_"+curIdx;
var saved=localStorage.getItem(key)||"";
var h=renderTopicCard(d);
h+='<div class="card"><h3>✍️ Практика письма</h3><div class="hint">Напиши ответ и сравни с образцом.</div>';
h+='<div class="tips" style="margin-bottom:14px"><b>Задание:</b> '+esc(w.prompt)+'</div>';
h+='<textarea id="ta" placeholder="Пиши здесь на английском…">'+esc(saved)+'</textarea>';
h+='<div class="btnrow" style="margin-top:10px"><button class="btn primary" id="saveW">Сохранить</button><button class="btn ghost" id="showModel">Показать образец</button></div>';
h+='<div class="model" id="model"><b>Образцовый ответ:</b>'+esc(w.model)+'</div>';
h+='<div class="tips" style="margin-top:12px">💡 '+esc(w.tips)+'</div>';
h+='</div>';
$("writing").innerHTML=h;
$("saveW").onclick=function(){ localStorage.setItem(key, $("ta").value); toast("Сохранено ✅"); };
$("showModel").onclick=function(){ $("model").classList.toggle("show"); };
}
/* ---------- done bar ---------- */
function renderDoneBar(){
var sec=SECTIONS.filter(function(s){return s.id===ACTIVE;})[0];
var done=isDone(ACTIVE);
var c=completedToday();
$("doneInfo").innerHTML = 'Сегодня выполнено: <b>'+c.length+"/"+SECTIONS.length+'</b> · '+sec.label;
var b=$("doneBtn");
b.textContent=done?"✓ Выполнено":"Отметить выполненным";
b.classList.toggle("done", done);
}
function bindDoneBar(){
$("doneBtn").onclick=function(){
var on=!isDone(ACTIVE);
setDone(ACTIVE, on);
renderDoneBar();
renderDashboard();
if(on){
var c=completedToday();
if(c.length===SECTIONS.length) toast("Все задания дня готовы! Так держать 🚀");
else toast("Готово! +1 к прогрессу 💪");
}
};
}
/* ---------- rerender ---------- */
function rerender(){
renderDayBar();
renderPanel(ACTIVE);
renderDoneBar();
}
/* ---------- reminders ---------- */
function openReminder(){
$("remTime").value=state.reminderTime;
$("remOn").checked=!!state.reminderOn;
var perm = window.Notification ? Notification.permission : "unsupported";
$("remPerm").textContent = perm==="granted"?"Уведомления разрешены ✅":perm==="denied"?"Уведомления заблокированы в браузере":"Разрешение ещё не запрошено";
$("remModal").classList.add("show");
}
function closeReminder(){ $("remModal").classList.remove("show"); }
function saveReminder(){
state.reminderTime=$("remTime").value||"20:00";
var on=$("remOn").checked;
if(on && window.Notification && Notification.permission!=="granted" && Notification.permission!=="denied"){
Notification.requestPermission();
}
state.reminderOn=on; save();
renderTop();
scheduleReminder();
closeReminder();
toast(on ? ("Напоминания включены на "+state.reminderTime) : "Напоминания выключены");
}
function maybeNotify(){
if(!state.reminderOn) return;
var c=completedToday();
if(c.length>=SECTIONS.length) return;
var today=todayStr();
if(state.lastNotified===today) return;
var now=new Date();
var hh=+state.reminderTime.slice(0,2), mm=+state.reminderTime.slice(3,5);
if(now.getHours()*60+now.getMinutes() < hh*60+mm) return;
state.lastNotified=today; save();
showBanner();
if(window.Notification && Notification.permission==="granted"){
try{ new Notification("SpeakDaily 🔔",{body:"Пора заниматься английским! Осталось "+(SECTIONS.length-c.length)+" блок(ов) сегодня."}); }catch(e){}
}
}
function showBanner(){
var b=$("reminderBanner");
var c=completedToday();
b.innerHTML='<span>🔔 Пора заниматься! Осталось '+(SECTIONS.length-c.length)+' из '+SECTIONS.length+' блоков сегодня.</span><button class="link" id="banGo">Начать →</button>';
b.hidden=false;
$("banGo").onclick=function(){ switchTab("words"); window.scrollTo({top:0,behavior:"smooth"}); };
}
function scheduleReminder(){
clearTimeout(window._remT);
if(!state.reminderOn) return;
var now=new Date();
var hh=+state.reminderTime.slice(0,2), mm=+state.reminderTime.slice(3,5);
var target=new Date(now); target.setHours(hh,mm,0,0);
if(target<=now) target.setDate(target.getDate()+1);
window._remT=setTimeout(function(){ maybeNotify(); scheduleReminder(); }, target-now+1000);
}
/* ---------- monthly test ---------- */
var testState=null;
function genTest(){
var allWords=flatten(CUR.map(function(d){return d.words;}));
var wq=shuffle(allWords).slice(0,6).map(function(w){
var distr=shuffle(allWords.filter(function(x){return x.en!==w.en;})).slice(0,2).map(function(x){return x.ru;});
var opts=shuffle([w.ru].concat(distr));
return {q:'Перевод слова: "'+w.en+'"', options:opts, answer:opts.indexOf(w.ru), ex:'Правильно: '+w.ru};
});
var dq=[];
shuffle(CUR.slice()).slice(0,3).forEach(function(d){
var i=1+Math.floor(Math.random()*(d.dialogue.length-1));
var prev=d.dialogue[i-1], cur=d.dialogue[i];
var pool=flatten(CUR.map(function(x){return x.dialogue;})).filter(function(l){return l.text!==cur.text;});
var distr=shuffle(pool).slice(0,2).map(function(l){return l.text;});
var opts=shuffle([cur.text].concat(distr));
dq.push({q:'После реплики: "'+prev.text+'" что отвечает '+cur.name+'?', options:opts, answer:opts.indexOf(cur.text), ex:cur.text});
});
var rq=[]; shuffle(CUR.slice()).slice(0,2).forEach(function(d){ var qi=d.reading.questions[Math.floor(Math.random()*d.reading.questions.length)]; rq.push({title:d.reading.title, q:qi.q, options:qi.options, answer:qi.answer, ex:qi.ex}); });
var lq=[]; shuffle(CUR.slice()).slice(0,2).forEach(function(d){ var qi=d.listening.questions[Math.floor(Math.random()*d.listening.questions.length)]; lq.push({audio:d.listening.text, q:qi.q, options:qi.options, answer:qi.answer, ex:qi.ex}); });
var sq=shuffle(CUR.slice()).slice(0,2).map(function(d){return d.speaking;});
var wri=shuffle(CUR.slice())[0].writing;
return {words:wq, dialogue:dq, reading:rq, listening:lq, speaking:sq, writing:wri, answers:{speaking:{}, writing:0}};
}
function qhtml(block,i,qi){
var h='<div class="q" data-block="'+block+'" data-i="'+i+'"><div class="qt">'+(i+1)+'. '+esc(qi.q)+'</div><div class="opts">';
qi.options.forEach(function(op,oi){ h+='<button class="opt" data-o="'+oi+'">'+esc(op)+'</button>'; });
h+='</div><div class="explain">'+esc(qi.ex)+'</div></div>';
return h;
}
function speakTestHtml(i,s){
var sup=!!REC;
var h='<div class="speak-box" style="margin-bottom:12px" data-sp="'+i+'"><div class="tips" style="width:100%;margin:0 0 6px"><b>Задание:</b> '+esc(s.prompt)+'</div>';
if(sup){ h+='<button class="mic tmic" data-i="'+i+'">🎤</button><div class="tstate" data-i="'+i+'">Нажми и говори</div>'; }
else { h+='<div style="font-size:32px">🎤</div><div class="rec-badge">Микрофон недоступен</div>'; }
h+='<div class="transcript ttr" data-i="'+i+'"><span class="ph">Твой ответ…</span></div>';
h+='<div class="btnrow"><button class="btn ghost thear" data-i="'+i+'">▶ Образец</button><button class="btn outline tshow" data-i="'+i+'">Показать ответ</button></div>';
h+='<div class="target ttarget" data-i="'+i+'"><b>Образец:</b>'+esc(s.target)+'</div></div>';
return h;
}
function renderTest(){
var m=currentMonth();
var past=state.tests[m];
var h='<div class="topic-card"><div class="em">🎯</div><div><div class="t">Тест месяца</div><div class="s">'+monthLabel(m)+' · проверка по всем блокам</div></div></div>';
h+='<div class="card"><h3>📅 История тестов</h3>';
var any=false;
for(var i=0;i<=5;i++){
if(state.tests[i]){ any=true; h+='<div class="mrow"><span>'+monthLabel(i)+(i===m?' · текущий':'')+'</span><b>'+state.tests[i].pct+'%</b></div>'; }
else { h+='<div class="mrow"><span class="miss">'+monthLabel(i)+(i===m?' · текущий':'')+'</span><span class="miss">не пройден</span></div>'; }
}
if(!any) h+='<div class="hint" style="margin-top:6px">Пройди тест в конце месяца, чтобы проверить прогресс по каждому блоку.</div>';
h+='</div>';
if(testState){
h+='<div class="card"><h3>📖 Блок: Слова</h3>'; testState.words.forEach(function(qi,i){ h+=qhtml("words",i,qi); }); h+='</div>';
h+='<div class="card"><h3>💬 Блок: Диалог</h3>'; testState.dialogue.forEach(function(qi,i){ h+=qhtml("dialogue",i,qi); }); h+='</div>';
h+='<div class="card"><h3>📰 Блок: Чтение</h3>'; testState.reading.forEach(function(qi,i){ if(qi.title) h+='<div class="hint" style="margin-bottom:4px">Текст: '+esc(qi.title)+'</div>'; h+=qhtml("reading",i,qi); }); h+='</div>';
h+='<div class="card"><h3>🎧 Блок: Аудирование</h3>'; testState.listening.forEach(function(qi,i){ h+='<div class="btnrow" style="margin-bottom:8px"><button class="btn ghost tplay" data-i="'+i+'">▶ Прослушать</button></div>'; h+=qhtml("listening",i,qi); }); h+='</div>';
h+='<div class="card"><h3>🎤 Блок: Говорение</h3>'; testState.speaking.forEach(function(s,i){ h+=speakTestHtml(i,s); }); h+='</div>';
h+='<div class="card"><h3>✍️ Блок: Письмо</h3>';
h+='<div class="tips" style="margin-bottom:10px"><b>Задание:</b> '+esc(testState.writing.prompt)+'</div>';
h+='<textarea id="tw" placeholder="Пиши на английском…"></textarea>';
h+='<div class="btnrow" style="margin-top:8px"><button class="btn ghost" id="twModel">Показать образец</button></div>';
h+='<div class="model" id="twModelBox"><b>Образец:</b>'+esc(testState.writing.model)+'</div>';
h+='<div class="hint" style="margin-top:10px">Сравни с образцом и оцени себя:</div>';
h+='<div class="btnrow" style="margin-top:6px"><button class="btn outline wrate" data-r="0">0 — плохо</button><button class="btn outline wrate" data-r="1">1 — средне</button><button class="btn outline wrate" data-r="2">2 — хорошо</button></div>';
h+='</div>';
h+='<button class="btn primary block" id="finishTest" style="margin-top:6px">Завершить тест месяца</button>';
} else if(past){
h+='<div class="card"><h3>✅ Результат за '+monthLabel(m)+'</h3><div class="result-grid">';
SECTIONS.forEach(function(s){ var r=past[s.id]; var cls=r?(r.score/r.total>=0.7?"good":r.score/r.total>=0.4?"ok":"bad"):""; h+='<div class="rcell '+cls+'"><div class="ric">'+s.icon+'</div><div class="rlabel">'+s.label.toUpperCase()+'</div><div class="rscore">'+(r?r.score+'/'+r.total:'—')+'</div></div>'; });
h+='</div><div class="score show" style="margin-top:12px">Итог: '+past.pct+'%</div>';
h+='<div class="btnrow" style="margin-top:14px"><button class="btn primary" id="retakeTest">Пройти заново</button></div></div>';
} else {
h+='<div class="card"><h3>Готов к проверке?</h3><div class="hint">Тест по 6 блокам: слова, диалог, чтение, аудио, говорение и письмо. Вопросы собираются из всех тем курса.</div><button class="btn primary block" id="startTest">Начать тест месяца</button></div>';
}
$("test").innerHTML=h;
if($("startTest")) $("startTest").onclick=function(){ testState=genTest(); renderTest(); window.scrollTo({top:0,behavior:"smooth"}); };
if($("retakeTest")) $("retakeTest").onclick=function(){ testState=genTest(); renderTest(); window.scrollTo({top:0,behavior:"smooth"}); };
if($("finishTest")) $("finishTest").onclick=finishTest;
if(testState) bindTestInteractions();
}
function bindTestInteractions(){
var root=$("test");
// MCQ
root.querySelectorAll(".q").forEach(function(qEl){
var answered=false;
qEl.querySelectorAll(".opt").forEach(function(oEl){
oEl.addEventListener("click",function(){
if(answered) return; answered=true;
var block=qEl.getAttribute("data-block");
var o=+oEl.getAttribute("data-o");
var qs=testState[block][+qEl.getAttribute("data-i")];
var correct=qs.answer;
qEl.querySelectorAll(".opt").forEach(function(x){ x.classList.add("disabled"); });
if(o===correct){ oEl.classList.add("correct"); }
else{ oEl.classList.add("wrong"); var c=qEl.querySelector('.opt[data-o="'+correct+'"]'); if(c) c.classList.add("correct"); }
qEl.querySelector(".explain").classList.add("show");
});
});
});
// listening play
root.querySelectorAll(".tplay").forEach(function(b){
b.onclick=function(){ var i=+b.getAttribute("data-i"); stopSpeak(); speak(testState.listening[i].audio); };
});
// speaking
root.querySelectorAll(".tmic").forEach(function(mic){
mic.onclick=function(){ var i=+mic.getAttribute("data-i"); var s=testState.speaking[i];
if(mic.classList.contains("rec")){ stopRec(); return; }
var ok=startRec(function(t){
var tr=root.querySelector('.ttr[data-i="'+i+'"]'); tr.innerHTML=esc(t);
var sim=similarity(t,s.target); testState.answers.speaking[i]=sim;
var lv=levelOf(sim);
var d=document.createElement("div"); d.className="match "+lv; d.textContent=(lv==="good"?"Отлично!":lv==="ok"?"Неплохо!":"Ещё раз")+" ("+Math.round(sim*100)+"%)";
tr.appendChild(d);
},function(){ mic.classList.remove("rec"); mic.textContent="🎤"; root.querySelector('.tstate[data-i="'+i+'"]').textContent="Нажми и говори"; });
if(ok){ mic.classList.add("rec"); mic.textContent="⏹"; root.querySelector('.tstate[data-i="'+i+'"]').innerHTML='<span class="rec-badge"><span class="blink"></span>Слушаю…</span>'; }
};
});
root.querySelectorAll(".thear").forEach(function(b){ b.onclick=function(){ var i=+b.getAttribute("data-i"); stopSpeak(); speak(testState.speaking[i].target,{rate:0.9}); }; });
root.querySelectorAll(".tshow").forEach(function(b){ b.onclick=function(){ var i=+b.getAttribute("data-i"); root.querySelector('.ttarget[data-i="'+i+'"]').classList.toggle("show"); }; });
// writing
if($("twModel")) $("twModel").onclick=function(){ $("twModelBox").classList.toggle("show"); };
root.querySelectorAll(".wrate").forEach(function(b){
b.onclick=function(){ testState.answers.writing=+b.getAttribute("data-r");
root.querySelectorAll(".wrate").forEach(function(x){ x.classList.remove("primary"); x.classList.add("outline"); });
b.classList.remove("outline"); b.classList.add("primary");
};
});
}
function finishTest(){
var t=testState, ans=t.answers;
function blockScore(block){ var got=0,total=0; $("test").querySelectorAll('.q[data-block="'+block+'"]').forEach(function(qEl){ total++; if(qEl.querySelector(".opt.correct")&&!qEl.querySelector(".opt.wrong")) got++; }); return {score:got,total:total}; }
var res={};
res.words=blockScore("words");
res.dialogue=blockScore("dialogue");
res.reading=blockScore("reading");
res.listening=blockScore("listening");
var sg=0; t.speaking.forEach(function(s,i){ if((ans.speaking[i]||0)>=0.6) sg++; }); res.speaking={score:sg,total:t.speaking.length};
res.writing={score:ans.writing||0,total:2};
var ts=0,tt=0; SECTIONS.forEach(function(s){ ts+=res[s.id].score; tt+=res[s.id].total; });
res.pct=Math.round(ts/tt*100); res.date=todayStr();
var m=currentMonth(); state.tests[m]=res; save();
testState=null;
renderTest();
toast("Тест завершён! Итог: "+res.pct+"%");
window.scrollTo({top:0,behavior:"smooth"});
}
/* ---------- init ---------- */
function init(){
load();
computeDay();
renderTop();
renderDashboard();
renderTabs();
renderDayBar();
bindDayNav();
bindDoneBar();
$("bellBtn").onclick=openReminder;
$("remSave").onclick=saveReminder;
$("remCancel").onclick=closeReminder;
$("remModal").addEventListener("click",function(e){ if(e.target===$("remModal")) closeReminder(); });
scheduleReminder();
maybeNotify();
switchTab("words");
}
if(document.readyState!=="loading") init();
else document.addEventListener("DOMContentLoaded", init);
})();

333
data.js Normal file
View File

@ -0,0 +1,333 @@
// Curriculum data for the English trainer.
// 8 days of workplace-English content, intermediate level.
// The app rotates through these by day index: contentIndex = dayKey % CURRICULUM.length
window.CURRICULUM = [
{
topic: "Daily standup & status updates",
emoji: "🧭",
words: [
{ en: "standup", ru: "ежедневная короткая встреча (стендап)", def: "a short daily meeting to share progress", ex: "I'll give an update at the standup." },
{ en: "blocker", ru: "препятствие, блокер", def: "something that stops your progress", ex: "My main blocker is waiting for the design." },
{ en: "to be on track", ru: "идти по плану", def: "to be progressing as planned", ex: "We're on track to finish by Friday." },
{ en: "to wrap up", ru: "завершать", def: "to finish something", ex: "Let's wrap up this task today." },
{ en: "to follow up", ru: "напомнить/вернуться к вопросу", def: "to check back on something later", ex: "I'll follow up with the client tomorrow." },
{ en: "to escalate", ru: "эскалировать (поднять вопрос выше)", def: "to send a problem to a manager", ex: "We may need to escalate this issue." }
],
dialogue: [
{ who: "A", name: "You", text: "Hi team, what did you work on yesterday?", tr: "Привет, команда, над чем вы работали вчера?" },
{ who: "B", name: "Sara", text: "I finished the report and sent it to the client.", tr: "Я закончила отчёт и отправила клиенту." },
{ who: "A", name: "You", text: "Great. Anything blocking you today?", tr: "Отлично. Сегодня что-нибудь блокирует?" },
{ who: "B", name: "Sara", text: "Yes, I'm still waiting for the sales numbers.", tr: "Да, всё ещё жду цифры от отдела продаж." },
{ who: "A", name: "You", text: "OK, I'll follow up with them after the standup.", tr: "Ок, я свяжусь с ними после стендапа." },
{ who: "B", name: "Sara", text: "Thanks, that would help a lot.", tr: "Спасибо, это очень поможет." }
],
reading: {
title: "How to run a good standup",
text: "A daily standup is a short meeting, usually fifteen minutes, where each team member answers three questions: what they did yesterday, what they will do today, and whether anything is blocking them. The goal is not to solve problems in the meeting — it is to share information quickly. If a topic needs a longer discussion, the team should take it offline and meet separately. A good standup starts on time, stays short, and ends with clear next steps.",
questions: [
{ q: "How long is a typical standup?", options: ["5 minutes", "15 minutes", "1 hour"], answer: 1, ex: "Текст говорит: usually fifteen minutes." },
{ q: "What is the main goal of a standup?", options: ["To solve all problems", "To share information quickly", "To write a report"], answer: 1, ex: "Цель — быстро поделиться информацией." },
{ q: "What should happen to longer discussions?", options: ["They stay in the standup", "They are taken offline", "They are cancelled"], answer: 1, ex: "Команда берёт их 'offline' — отдельной встречей." }
]
},
listening: {
title: "Status update",
text: "Morning everyone. Yesterday I worked on the login page and fixed two bugs. Today I'm planning to start on the dashboard. The only blocker is that I need access to the test server, so I'll ask IT about that. I should be done by the end of the week.",
questions: [
{ q: "What did the speaker work on yesterday?", options: ["The dashboard", "The login page", "The test server"], answer: 1, ex: "'worked on the login page'." },
{ q: "What will they start today?", options: ["The dashboard", "A bug fix", "A report"], answer: 0, ex: "'start on the dashboard'." },
{ q: "What is the blocker?", options: ["No access to the test server", "Too many bugs", "No time"], answer: 0, ex: "Нужен доступ к тестовому серверу." }
]
},
speaking: { prompt: "Imagine you're at the standup. Say: yesterday you finished a task, today you'll start a new one, and you have no blockers.", target: "Yesterday I finished my task. Today I'll start working on the new feature. I don't have any blockers." },
writing: { prompt: "Write a short message to your team in a chat: report what you did today and that you're on track. (2-3 sentences)", model: "Hi team! Today I finished the client report and sent it over. I'm on track with the rest of my tasks and should wrap up by Friday. Let me know if anything urgent comes up.", tips: "Используй present perfect (have finished) для недавних результатов и present continuous (am working) для текущих задач." }
},
{
topic: "Asking for help & clarification",
emoji: "🤝",
words: [
{ en: "to clarify", ru: "уточнить, прояснить", def: "to make something clearer", ex: "Could you clarify what you mean?" },
{ en: "to figure out", ru: "разобраться, выяснить", def: "to understand or solve something", ex: "I can't figure out this error." },
{ en: "to walk someone through", ru: "провести по шагам, объяснить", def: "to explain step by step", ex: "Can you walk me through the process?" },
{ en: "to get stuck", ru: "застрять", def: "to be unable to continue", ex: "I got stuck on step three." },
{ en: "to make sense", ru: "иметь смысл, быть понятным", def: "to be logical and understandable", ex: "Does that make sense?" },
{ en: "to double-check", ru: "перепроверить", def: "to check again to be sure", ex: "Let me double-check the numbers." }
],
dialogue: [
{ who: "A", name: "You", text: "Hey, do you have a minute? I'm a bit stuck.", tr: "Есть минутка? Я немного застрял." },
{ who: "B", name: "Tom", text: "Sure, what's up?", tr: "Конечно, что случилось?" },
{ who: "A", name: "You", text: "I can't figure out this error. Could you walk me through it?", tr: "Не могу разобраться с ошибкой. Можешь объяснить по шагам?" },
{ who: "B", name: "Tom", text: "Of course. First, open the settings. Does that make sense so far?", tr: "Конечно. Сначала открой настройки. Пока понятно?" },
{ who: "A", name: "You", text: "Yes, got it. And then?", tr: "Да, понятно. А потом?" },
{ who: "B", name: "Tom", text: "Then just click 'reset'. Let me know if it still doesn't work.", tr: "Потом просто нажми 'reset'. Скажи, если не сработает." }
],
reading: {
title: "It's OK to ask questions",
text: "Many people feel uncomfortable asking for help at work because they don't want to look inexperienced. But asking good questions is actually a sign of professionalism. A clear question shows that you care about doing the job right. Before you ask, try to solve the problem yourself for about fifteen minutes. When you do ask, be specific: explain what you tried, what you expected, and what actually happened. This saves everyone's time and helps your colleague help you faster.",
questions: [
{ q: "How does the author feel about asking questions?", options: ["It's unprofessional", "It's a sign of professionalism", "It wastes time"], answer: 1, ex: "Asking good questions is a sign of professionalism." },
{ q: "What should you do before asking?", options: ["Nothing", "Try for about 15 minutes", "Ask immediately"], answer: 1, ex: "Try to solve it yourself ~15 min." },
{ q: "What makes a good question?", options: ["Being vague", "Being specific about what you tried", "Apologizing a lot"], answer: 1, ex: "Будь конкретным: что пробовал, что ожидал, что вышло." }
]
},
listening: {
title: "Asking for clarification",
text: "Sorry, could you repeat that? I didn't quite catch the last part. So, just to clarify — you want me to send the file by Friday, right? And should I copy the whole team on the email, or just you? I want to make sure I get this right.",
questions: [
{ q: "What does the speaker ask first?", options: ["To repeat", "To send a file", "To copy the team"], answer: 0, ex: "'could you repeat that?'." },
{ q: "When is the deadline?", options: ["Monday", "Friday", "Today"], answer: 1, ex: "by Friday." },
{ q: "What is the speaker unsure about?", options: ["Who to copy on the email", "The file name", "The meeting time"], answer: 0, ex: "Копировать всю команду или только собеседника." }
]
},
speaking: { prompt: "You're stuck on a task. Politely ask a colleague for help and explain what you tried. (Use: 'do you have a minute', 'I got stuck', 'could you walk me through')", target: "Hi, do you have a minute? I got stuck on this task. I tried restarting it but it didn't help. Could you walk me through it?" },
writing: { prompt: "Write a message asking a colleague to clarify a task. Say you didn't fully understand the instructions and ask one specific question.", model: "Hi Tom, thanks for the task earlier. I read through the instructions, but I'm not sure I fully understood them. Could you clarify whether I should include the data from last month or just this month? I want to make sure I get it right. Thanks!", tips: "Вежливая просьба: 'Could you clarify...?' / 'I'm not sure I fully understood...'." }
},
{
topic: "Giving & receiving feedback",
emoji: "💬",
words: [
{ en: "feedback", ru: "обратная связь", def: "opinion on how to improve something", ex: "Thanks for the feedback on my presentation." },
{ en: "constructive", ru: "конструктивный", def: "useful and helpful (not negative)", ex: "That was constructive feedback." },
{ en: "to improve", ru: "улучшать", def: "to make or become better", ex: "We can improve this process." },
{ en: "to suggest", ru: "предлагать", def: "to put forward an idea", ex: "I'd suggest trying a different approach." },
{ en: "to take into account", ru: "учитывать", def: "to consider something", ex: "I'll take your points into account." },
{ en: "to appreciate", ru: "ценить, быть благодарным", def: "to be grateful for something", ex: "I really appreciate your help." }
],
dialogue: [
{ who: "A", name: "You", text: "Could I get some feedback on the proposal?", tr: "Можешь дать обратную связь по предложению?" },
{ who: "B", name: "Emma", text: "Sure. Overall it's strong, but I have a couple of suggestions.", tr: "Конечно. В целом сильное, но пара предложений." },
{ who: "A", name: "You", text: "Go ahead, I appreciate it.", tr: "Давай, буду благодарен." },
{ who: "B", name: "Emma", text: "I'd suggest making the budget section clearer.", tr: "Предлагаю сделать раздел бюджета яснее." },
{ who: "A", name: "You", text: "That makes sense. I'll take that into account.", tr: "Логично. Учту это." },
{ who: "B", name: "Emma", text: "Great. The rest looks really good, nice work.", tr: "Отлично. Остальное выглядит хорошо, молодец." }
],
reading: {
title: "How to give constructive feedback",
text: "Good feedback focuses on the work, not the person. Instead of saying 'you did this wrong', try describing what you saw and its effect: 'when the report was late, the client got nervous'. A useful method is to start with something positive, then describe what could be improved, and end with a specific suggestion. This keeps the conversation friendly and productive. Remember that feedback should help the other person grow, not make them feel bad.",
questions: [
{ q: "What should feedback focus on?", options: ["The person", "The work", "The past"], answer: 1, ex: "Focus on the work, not the person." },
{ q: "What is a good structure for feedback?", options: ["Only negative points", "Positive → improvement → suggestion", "Only positive points"], answer: 1, ex: "Положительное → что улучшить → конкретное предложение." },
{ q: "What is the goal of feedback?", options: ["To make people feel bad", "To help the person grow", "To show who is boss"], answer: 1, ex: "Помочь человеку расти." }
]
},
listening: {
title: "Receiving feedback",
text: "Thanks for the feedback, I really appreciate it. You're right that the slides were a bit crowded. I'll make them simpler next time and leave more white space. I'll also practice the timing so I don't run over. Would you be able to look at the next draft before the meeting?",
questions: [
{ q: "What does the speaker agree to improve?", options: ["The slides", "The budget", "The report"], answer: 0, ex: "Сделать слайды проще." },
{ q: "What else will they work on?", options: ["Timing", "Grammar", "Design colors"], answer: 0, ex: "Practice the timing." },
{ q: "What does the speaker ask at the end?", options: ["To check the next draft", "To cancel the meeting", "To write the slides"], answer: 0, ex: "Посмотреть следующий черновик." }
]
},
speaking: { prompt: "Give a colleague constructive feedback: start positive, name one thing to improve, suggest a fix. (Use: 'overall', 'I'd suggest', 'take into account')", target: "Overall, your presentation was really clear. I'd suggest slowing down a bit on the numbers. If you take that into account, it'll be even better next time." },
writing: { prompt: "Reply to feedback you received. Thank the person, agree with one point, and say what you'll change.", model: "Hi Emma, thanks so much for the feedback, I really appreciate it. You're right that the budget section was unclear — I'll add a simple table to make it easier to read. I'll send you the updated version by tomorrow. Thanks again for taking the time to look!", tips: "Структура: благодарность → согласие с пунктом → что изменишь → повторная благодарность." }
},
{
topic: "Small talk before meetings",
emoji: "☕",
words: [
{ en: "small talk", ru: "светская беседа", def: "light casual conversation", ex: "We had some small talk before the meeting." },
{ en: "to catch up", ru: "наверстать, поговорить о новостях", def: "to share news with someone", ex: "Let's catch up after the call." },
{ en: "weekend", ru: "выходные", def: "Saturday and Sunday", ex: "Did you have a good weekend?" },
{ en: "to look forward to", ru: "ждать с нетерпением", def: "to be excited about something future", ex: "I'm looking forward to the trip." },
{ en: "to take it easy", ru: "отдыхать, не напрягаться", def: "to relax", ex: "I just took it easy this weekend." },
{ en: "by the way", ru: "кстати, между прочим", def: "used to introduce a new topic", ex: "By the way, did you see the email?" }
],
dialogue: [
{ who: "A", name: "You", text: "Morning! How was your weekend?", tr: "Утро! Как прошли выходные?" },
{ who: "B", name: "Leo", text: "Pretty good, just took it easy. You?", tr: "Нормально, просто отдыхал. У тебя?" },
{ who: "A", name: "You", text: "Nice. I went hiking, the weather was great.", tr: "Здорово. Я ходил в поход, погода была супер." },
{ who: "B", name: "Leo", text: "That sounds amazing. Anyway, shall we get started?", tr: "Звучит круто. Ну что, начнём?" },
{ who: "A", name: "You", text: "Sure. By the way, did you see the agenda?", tr: "Давай. Кстати, видел повестку?" },
{ who: "B", name: "Leo", text: "Yes, I've got it open. Let's dive in.", tr: "Да, открыта. Погнали." }
],
reading: {
title: "Why small talk matters at work",
text: "Small talk — the short, casual chats before a meeting starts — may seem unimportant, but it actually builds trust between colleagues. When you ask about someone's weekend or comment on the weather, you show that you see them as a person, not just a coworker. Keep it light and positive: avoid heavy topics like politics or complaints. A good rule is to match the other person's energy. If they give short answers, don't push; if they seem happy to chat, enjoy the moment before getting down to business.",
questions: [
{ q: "What does small talk build?", options: ["Trust", "Stress", "Reports"], answer: 0, ex: "It builds trust between colleagues." },
{ q: "What topics should you avoid?", options: ["Weather", "Heavy topics like politics", "Weekend plans"], answer: 1, ex: "Avoid heavy topics like politics or complaints." },
{ q: "What's a good rule?", options: ["Always talk a lot", "Match the other person's energy", "Never make small talk"], answer: 1, ex: "Match the other person's energy." }
]
},
listening: {
title: "Before the call",
text: "Hey, how's it going? Yeah, the weekend was nice — I finally tried that new coffee place downtown, it was really good. Anyway, I'm looking forward to today's session, I think we'll make some real progress. Oh, by the way, I might be five minutes late to the next meeting, I have another call right before. Just wanted to give you a heads-up.",
questions: [
{ q: "What did the speaker do on the weekend?", options: ["Went hiking", "Tried a new coffee place", "Stayed home"], answer: 1, ex: "Tried a new coffee place downtown." },
{ q: "How does the speaker feel about today's session?", options: ["Bored", "Looking forward to it", "Worried"], answer: 1, ex: "'looking forward to today's session'." },
{ q: "What heads-up does the speaker give?", options: ["They'll be late to the next meeting", "They'll skip the meeting", "They forgot the agenda"], answer: 0, ex: "Может опоздать на 5 минут." }
]
},
speaking: { prompt: "Start a meeting with small talk: greet, ask about the weekend, react to their answer, then move to the agenda. (Use: 'how was your weekend', 'that sounds', 'shall we get started')", target: "Morning! How was your weekend? That sounds great. Anyway, shall we get started? I sent the agenda earlier." },
writing: { prompt: "Write a friendly chat message to a colleague before a meeting: ask how they are, share one short thing about your day, then mention the meeting.", model: "Hey Leo! How's your day going? I just had a really good coffee this morning, so I'm feeling ready. Are we still on for the 2pm meeting? I'll share my screen when we start. See you soon!", tips: "Дружелюбный тон, present simple/continuous, короткие предложения." }
},
{
topic: "Scheduling & meetings",
emoji: "📅",
words: [
{ en: "to schedule", ru: "назначать (встречу)", def: "to set a time for an event", ex: "Let's schedule a call for Tuesday." },
{ en: "to reschedule", ru: "переносить", def: "to change the time of an event", ex: "Can we reschedule to Thursday?" },
{ en: "available", ru: "свободный, доступный", def: "free to meet", ex: "I'm available after 3pm." },
{ en: "to conflict", ru: "пересекаться (о времени)", def: "to happen at the same time", ex: "That conflicts with my other meeting." },
{ en: "to join", ru: "присоединиться", def: "to enter a meeting or call", ex: "I'll join in five minutes." },
{ en: "agenda", ru: "повестка", def: "a list of topics for a meeting", ex: "Please check the agenda beforehand." }
],
dialogue: [
{ who: "A", name: "You", text: "Hi, could we schedule a quick call this week?", tr: "Привет, можем созвониться на этой неделе?" },
{ who: "B", name: "Mia", text: "Sure. When works for you?", tr: "Конечно. Когда тебе удобно?" },
{ who: "A", name: "You", text: "I'm available Wednesday after 3pm.", tr: "Я свободен в среду после 15:00." },
{ who: "B", name: "Mia", text: "Hmm, that conflicts with my meeting. How about Thursday?", tr: "Хм, это пересекается с моей встречей. Как насчёт четверга?" },
{ who: "A", name: "You", text: "Thursday works. I'll send the invite and the agenda.", tr: "Четверг подходит. Пришлю приглашение и повестку." },
{ who: "B", name: "Mia", text: "Perfect, thanks. I'll join on time.", tr: "Отлично, спасибо. Буду вовремя." }
],
reading: {
title: "Making meetings shorter",
text: "Many companies have too many meetings, and people feel they have no time for actual work. One simple fix is to make meetings shorter by default: instead of sixty minutes, try forty-five, and instead of thirty, try fifteen. Always share an agenda in advance so people come prepared. During the meeting, one person should take notes and write down action items — the specific tasks people agreed to do. End every meeting by repeating who does what and by when. This small habit saves hours every week.",
questions: [
{ q: "What is one fix for too many meetings?", options: ["Make them longer", "Make them shorter by default", "Cancel all meetings"], answer: 1, ex: "Make meetings shorter by default." },
{ q: "What should be shared in advance?", options: ["An agenda", "Lunch", "The budget"], answer: 0, ex: "Always share an agenda in advance." },
{ q: "How should every meeting end?", options: ["With a coffee break", "Repeating who does what and by when", "With small talk"], answer: 1, ex: "End by repeating who does what and by when." }
]
},
listening: {
title: "Rescheduling a meeting",
text: "Hi Sam, I'm really sorry, but I need to reschedule our meeting tomorrow. Something urgent came up and I have to handle it. Would Thursday morning work for you instead? I'm available any time before noon. If that doesn't work, just let me know what suits you, and I'll move things around. Again, apologies for the change.",
questions: [
{ q: "Why does the speaker want to reschedule?", options: ["They're on holiday", "Something urgent came up", "They forgot"], answer: 1, ex: "Something urgent came up." },
{ q: "When do they suggest instead?", options: ["Thursday morning", "Friday evening", "Wednesday night"], answer: 0, ex: "Thursday morning." },
{ q: "What if the new time doesn't work?", options: ["They cancel forever", "They ask Sam to suggest a time", "They get angry"], answer: 1, ex: "Let me know what suits you." }
]
},
speaking: { prompt: "Ask a colleague to schedule a call, suggest a time, and offer to send the agenda. (Use: 'could we schedule', 'I'm available', 'I'll send')", target: "Hi Mia, could we schedule a quick call this week? I'm available on Wednesday after 3pm. If that works, I'll send the invite and the agenda." },
writing: { prompt: "Write an email to reschedule a meeting. Apologize, suggest a new time, and ask if it works.", model: "Hi Sam, I'm sorry, but I need to reschedule our meeting tomorrow — something urgent came up. Would Thursday at 10am work for you instead? If not, let me know what time suits you and I'll adjust. Sorry again for the short notice. Thanks for understanding!", tips: "Apologize → suggest new time → ask flexibility → polite closing." }
},
{
topic: "Reporting a problem",
emoji: "🚨",
words: [
{ en: "issue", ru: "проблема, вопрос", def: "a problem or concern", ex: "There's an issue with the system." },
{ en: "to break down", ru: "сломаться", def: "to stop working", ex: "The server broke down this morning." },
{ en: "to affect", ru: "влиять, затрагивать", def: "to have an effect on", ex: "This affects all our users." },
{ en: "urgent", ru: "срочный", def: "needing fast action", ex: "This is urgent, please look at it now." },
{ en: "to fix", ru: "чинить, исправлять", def: "to repair or solve", ex: "We're working to fix the issue." },
{ en: "workaround", ru: "временное решение", def: "a temporary way around a problem", ex: "There's a workaround for now." }
],
dialogue: [
{ who: "A", name: "You", text: "Hi, we have an urgent issue with the website.", tr: "Привет, у нас срочная проблема с сайтом." },
{ who: "B", name: "Dan", text: "Oh no, what's happening?", tr: "Ой, что происходит?" },
{ who: "A", name: "You", text: "It broke down and it's affecting all users.", tr: "Он сломался, это влияет на всех юзеров." },
{ who: "B", name: "Dan", text: "I'll look into it right away. How long has it been down?", tr: "Сейчас посмотрю. Сколько он уже не работает?" },
{ who: "A", name: "You", text: "About twenty minutes. Is there a workaround for now?", tr: "Минут двадцать. Есть временное решение?" },
{ who: "B", name: "Dan", text: "Yes, I'll share a workaround while we fix it.", tr: "Да, дам workaround, пока чиним." }
],
reading: {
title: "How to report a problem clearly",
text: "When something goes wrong at work, the way you report the problem matters. A clear report includes four things: what happened, when it started, who is affected, and what you have already tried. Avoid panicking or blaming people — focus on the facts. If the issue is urgent, say so directly so people know how fast to react. Offering a temporary solution, even a small one, shows that you're not just reporting problems but also helping solve them. Clear, calm reports get fixed faster.",
questions: [
{ q: "What four things should a clear report include?", options: ["What, when, who, what you tried", "Who, why, who to blame, cost", "When, where, weather, mood"], answer: 0, ex: "What happened, when, who is affected, what you tried." },
{ q: "What should you avoid?", options: ["Facts", "Panicking or blaming", "Being specific"], answer: 1, ex: "Avoid panicking or blaming people." },
{ q: "What does offering a temporary solution show?", options: ["That you don't care", "That you help solve problems", "That you're angry"], answer: 1, ex: "It shows you're helping solve them." }
]
},
listening: {
title: "Reporting an issue",
text: "Hi Dan, I need to report an issue. The payment page stopped working about an hour ago, and it's affecting all our customers in Europe. I already checked our end and everything looks fine, so it might be on the provider's side. I think this is urgent because we're losing orders. Could you take a look as soon as possible?",
questions: [
{ q: "What is the problem?", options: ["The payment page", "The login page", "The dashboard"], answer: 0, ex: "The payment page stopped working." },
{ q: "Who is affected?", options: ["Only one user", "All customers in Europe", "No one"], answer: 1, ex: "All customers in Europe." },
{ q: "Why is it urgent?", options: ["They're losing orders", "It's Friday", "It's a small bug"], answer: 0, ex: "They're losing orders." }
]
},
speaking: { prompt: "Report a problem to a colleague: say what happened, who is affected, and that it's urgent. (Use: 'urgent issue', 'affecting', 'look into it')", target: "Hi, we have an urgent issue. The website is down and it's affecting all users. Could you look into it right away?" },
writing: { prompt: "Write a short message reporting a problem: what happened, when it started, who is affected, and what you tried. Mark it as urgent.", model: "Hi team, this is urgent. The login system stopped working around 2pm and it's affecting all users. I already checked the server and restarted it, but the issue is still there. Could someone from IT look into it right away? Thanks!", tips: "Чёткая структура: что → когда → кто → что пробовал → просьба. Без паники, по фактам." }
},
{
topic: "Agreeing & disagreeing politely",
emoji: "⚖️",
words: [
{ en: "to agree", ru: "соглашаться", def: "to share the same opinion", ex: "I agree with your idea." },
{ en: "to disagree", ru: "не соглашаться", def: "to have a different opinion", ex: "I disagree, but I see your point." },
{ en: "fair point", ru: "справедливое замечание", def: "a good, reasonable argument", ex: "That's a fair point." },
{ en: "to see both sides", ru: "видеть обе стороны", def: "to understand both opinions", ex: "I can see both sides here." },
{ en: "to compromise", ru: "идти на компромисс", def: "to meet in the middle", ex: "Let's compromise on the deadline." },
{ en: "to push back", ru: "возражать, мягко сопротивляться", def: "to oppose an idea politely", ex: "I'd push back on that timeline." }
],
dialogue: [
{ who: "A", name: "You", text: "I think we should launch next week.", tr: "Думаю, надо запустить на следующей неделе." },
{ who: "B", name: "Kim", text: "I'm not sure. That feels a bit rushed.", tr: "Не уверен. Как-то спешно." },
{ who: "A", name: "You", text: "Fair point. What worries you most?", tr: "Резонно. Что беспокоит больше всего?" },
{ who: "B", name: "Kim", text: "We haven't tested it enough yet.", tr: "Мы ещё недостаточно протестировали." },
{ who: "A", name: "You", text: "I can see both sides. Let's compromise — soft launch?", tr: "Понимаю обе стороны. Давай компромисс — мягкий запуск?" },
{ who: "B", name: "Kim", text: "That works for me. Good idea.", tr: "Меня устраивает. Хорошая идея." }
],
reading: {
title: "Disagreeing without conflict",
text: "At work, you will often disagree with colleagues, and that is completely normal. The skill is to disagree without creating conflict. Start by showing you heard the other person: 'I see your point' or 'that's a fair point'. Then introduce your view with softer language — 'I'm not sure' or 'I'd push back on that a little'. Avoid words like 'you're wrong', which make people defensive. Focus on the idea, not the person. The goal is not to win the argument, but to find the best solution together, which often means compromising.",
questions: [
{ q: "Is disagreement at work normal?", options: ["No, never", "Yes, completely normal", "Only for managers"], answer: 1, ex: "It is completely normal." },
{ q: "What phrase helps soften disagreement?", options: ["'You're wrong'", "'I'm not sure'", "'Stop talking'"], answer: 1, ex: "'I'm not sure' смягчает." },
{ q: "What is the goal of a disagreement?", options: ["To win", "To find the best solution together", "To be loud"], answer: 1, ex: "Find the best solution together." }
]
},
listening: {
title: "A polite disagreement",
text: "I see your point about moving fast, and I agree that speed matters. But I'd push back a little on launching next week. From what I can see, we still have a few open bugs, and I'm worried that could hurt the first impression. What if we compromise and do a soft launch with a small group first? That way we move quickly but also stay safe.",
questions: [
{ q: "Does the speaker agree speed matters?", options: ["Yes", "No", "Not sure"], answer: 0, ex: "'I agree that speed matters'." },
{ q: "Why do they push back?", options: ["Open bugs", "No budget", "Bad weather"], answer: 0, ex: "Still a few open bugs." },
{ q: "What compromise do they suggest?", options: ["Cancel the launch", "A soft launch with a small group", "Launch immediately"], answer: 1, ex: "A soft launch with a small group." }
]
},
speaking: { prompt: "Politely disagree with a colleague who wants a very tight deadline, and suggest a compromise. (Use: 'I see your point', 'I'd push back', 'compromise')", target: "I see your point about finishing fast. But I'd push back a little — two days is too tight. What if we compromise and aim for Friday instead?" },
writing: { prompt: "Reply to a colleague's idea you partly disagree with. Acknowledge their point, share your concern politely, and suggest a compromise.", model: "Hi Kim, I see your point about launching next week, and I agree we shouldn't wait too long. But I'd push back a little — I'm worried we haven't tested enough. What if we compromise and do a soft launch with a small group first? That way we move fast but stay safe. What do you think?", tips: "Признай точку → мягко возрази ('I'd push back a little') → предложи компромисс." }
},
{
topic: "Wrapping up & follow-ups",
emoji: "✅",
words: [
{ en: "to summarize", ru: "подводить итог", def: "to give a short version of what was said", ex: "Let me summarize what we agreed." },
{ en: "action item", ru: "задача-результат встречи", def: "a task someone will do after a meeting", ex: "My action item is to send the report." },
{ en: "deadline", ru: "крайний срок", def: "the time something must be finished", ex: "The deadline is next Friday." },
{ en: "to assign", ru: "назначать (задачу)", def: "to give someone a task", ex: "I'll assign this task to you." },
{ en: "to deliver", ru: "доставлять, сдавать (результат)", def: "to produce the promised result", ex: "We'll deliver the update on Monday." },
{ en: "next steps", ru: "следующие шаги", def: "what happens after this", ex: "Let's agree on the next steps." }
],
dialogue: [
{ who: "A", name: "You", text: "OK, let me summarize what we agreed.", tr: "Ок, подведём итоги." },
{ who: "B", name: "Pat", text: "Sure, go ahead.", tr: "Давай." },
{ who: "A", name: "You", text: "You'll send the data, and I'll write the report.", tr: "Ты пришлёшь данные, я напишу отчёт." },
{ who: "B", name: "Pat", text: "Right. The deadline is Friday, correct?", tr: "Верно. Дедлайн — пятница, верно?" },
{ who: "A", name: "You", text: "Yes. I'll deliver the report by Friday morning.", tr: "Да. Отчёт сдам в пятницу утром." },
{ who: "B", name: "Pat", text: "Perfect. Clear next steps, thanks!", tr: "Отлично. Чёткие шаги, спасибо!" }
],
reading: {
title: "Why meetings end without results",
text: "Have you ever left a meeting feeling confused about what happens next? This is one of the most common problems at work. People discuss a lot, but nobody writes down the action items — the specific tasks, who owns them, and the deadlines. Without this, everyone assumes someone else will do it, and nothing gets delivered. The fix is simple: the last five minutes of every meeting should be used to summarize. Say each action item out loud: 'Pat will send the data by Wednesday.' When everyone agrees, the meeting is truly over.",
questions: [
{ q: "Why do meetings often end without results?", options: ["Nobody writes down action items", "Too many slides", "Wrong room"], answer: 0, ex: "Nobody writes down the action items." },
{ q: "What should the last five minutes be used for?", options: ["Small talk", "Summarizing action items", "Lunch plans"], answer: 1, ex: "Summarize action items." },
{ q: "What should be said out loud?", options: ["Each action item, owner, deadline", "The weather", "A joke"], answer: 0, ex: "Each action item: who does what by when." }
]
},
listening: {
title: "Wrapping up a meeting",
text: "OK everyone, let's wrap up. To summarize: Sara will finish the design by Wednesday and send it to the team. Tom will write the text by Thursday, and I'll put everything together for Friday's deadline. If anything changes, let's update each other in the group chat. Thanks for a productive meeting, and let's touch base again on Thursday. Have a good one!",
questions: [
{ q: "What will Sara do?", options: ["Finish the design", "Write the text", "Put everything together"], answer: 0, ex: "Sara will finish the design by Wednesday." },
{ q: "When is the final deadline?", options: ["Wednesday", "Thursday", "Friday"], answer: 2, ex: "Friday's deadline." },
{ q: "When will they meet again?", options: ["Thursday", "Next month", "Monday"], answer: 0, ex: "Touch base again on Thursday." }
]
},
speaking: { prompt: "Wrap up a meeting: summarize who does what, confirm the deadline, and thank the team. (Use: 'let me summarize', 'will deliver', 'next steps')", target: "OK, let me summarize. You'll send the data, and I'll write the report. The deadline is Friday, and I'll deliver it by Friday morning. Thanks everyone, clear next steps!" },
writing: { prompt: "Write a follow-up message after a meeting: list the action items with owners and deadlines, and thank everyone.", model: "Hi team, thanks for the great meeting. Quick summary of our action items: Sara will finish the design by Wednesday, Tom will write the text by Thursday, and I'll put it all together for Friday's deadline. Please update the group chat if anything changes. Let's touch base again on Thursday. Thanks everyone!", tips: "Список action items с именами и сроками + благодарность. Это самый полезный навык для рабочих чатов." }
}
];

110
design.md Normal file
View File

@ -0,0 +1,110 @@
<!-- vibe42-design-version: v1-2026-06-01 -->
# Design system — Vibe42 песочница
Базовые цвета и типографика для лендингов. Можно отклоняться, но начинай с этого.
## Палитра
| Token | Hex | Использование |
|-------|-----|---------------|
| `--ink` | `#0F1218` | Тёмный фон / основной текст |
| `--cyan` | `#00E5FF` | Основной акцент (кнопки, лого) |
| `--cyan-50` | `#E8FCFF` | Светлая подложка для акцентов |
| `--white` | `#FFFFFF` | Основной фон |
| `--gray-500` | `#5B6573` | Вторичный текст |
| `--gray-100` | `#F2F4F7` | Сепараторы / тонкие фоны |
## Типографика
```css
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, sans-serif;
```
| Уровень | Размер | Вес | line-height |
|---------|--------|-----|-------------|
| h1 (hero) | 56px | 800 | 1.05 |
| h2 (section) | 36px | 700 | 1.15 |
| h3 | 22px | 700 | 1.3 |
| body | 17px | 400 | 1.6 |
| small | 14px | 400 | 1.5 |
На мобиле — h1 уменьши до 36px, h2 до 28px.
## Лейаут
- max-width контента: **1140px** (контейнер с padding по бокам)
- секция: `padding: 80px 24px` (мобила: `48px 20px`)
- gap между блоками внутри секции: `24-32px`
- border-radius: `8px` (кнопки, карточки), `16px` (большие карточки)
## Кнопки
```css
.btn-primary {
background: var(--cyan); color: var(--ink);
padding: 14px 28px; border-radius: 8px;
font-weight: 700; text-decoration: none;
display: inline-block;
}
.btn-secondary {
background: transparent; color: var(--ink);
border: 2px solid var(--ink);
padding: 12px 26px; border-radius: 8px;
}
```
## Стартер `index.html`
```html
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Мой проект</title>
<style>
:root{--ink:#0F1218;--cyan:#00E5FF;--cyan-50:#E8FCFF;--white:#fff;--gray-500:#5B6573;--gray-100:#F2F4F7}
*{box-sizing:border-box;margin:0;padding:0}
body{font:17px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,sans-serif;color:var(--ink);background:var(--white)}
.container{max-width:1140px;margin:0 auto;padding:80px 24px}
.hero{background:var(--ink);color:var(--white)}
.hero h1{font-size:56px;font-weight:800;line-height:1.05;margin-bottom:24px}
.hero p{font-size:20px;color:#9aa3b2;max-width:600px;margin-bottom:32px}
.btn{display:inline-block;background:var(--cyan);color:var(--ink);padding:14px 28px;border-radius:8px;font-weight:700;text-decoration:none}
.btn:hover{background:#1be5ff}
.section h2{font-size:36px;font-weight:700;margin-bottom:24px}
.card{background:var(--gray-100);border-radius:16px;padding:32px;margin-bottom:16px}
@media (max-width:640px){.hero h1{font-size:36px}.section h2{font-size:28px}.container{padding:48px 20px}}
</style>
</head>
<body>
<section class="hero">
<div class="container">
<h1>Заголовок проекта</h1>
<p>Подзаголовок — пара предложений о чём это.</p>
<a class="btn" href="#section">Начать</a>
</div>
</section>
<section id="section" class="section">
<div class="container">
<h2>Секция</h2>
<div class="card">Контент карточки.</div>
<div class="card">Контент карточки.</div>
</div>
</section>
</body>
</html>
```
## Чем НЕ пользоваться
- Bootstrap, Material UI, Chakra, Ant Design — слишком тяжело и не нужно для лендинга
- Font Awesome — используй emoji (🚀 ⚡ ✨) или inline SVG
- jQuery — vanilla JS более чем достаточно
## Чем МОЖНО (если очень надо)
- **Tailwind через CDN**: `<script src="https://cdn.tailwindcss.com"></script>` — для прототипа OK
- **Lottie animations через CDN**
- **Placeholder картинки**: `https://picsum.photos/800/600`, `https://placehold.co/600x400`
- **Шрифты Google Fonts через `<link>`** в head

101
index.html Normal file
View File

@ -0,0 +1,101 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
<title>SpeakDaily — английский для работы</title>
<meta name="description" content="Ежедневный тренажёр английского для общения с коллегами: слова, диалоги, чтение, аудирование, говорение и письмо.">
<link rel="stylesheet" href="style.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🗣️</text></svg>">
</head>
<body>
<header class="top">
<div class="top-row">
<div class="brand">
<span class="logo">🗣️</span>
<span>SpeakDaily<small>английский для работы</small></span>
</div>
<div class="pills">
<button class="pill bell" id="bellBtn" title="Напоминания">🔔</button>
<span class="pill" id="streak">🔥 1</span>
<span class="pill" id="level">Intermediate</span>
</div>
</div>
</header>
<section class="hero">
<div class="wrap">
<div class="hello">Привет! Рад, что ты здесь 👋</div>
<div class="banner" id="reminderBanner" hidden></div>
<div class="goal-card">
<div class="goal-title" id="goalTitle">Цель: свободный английский за 6 месяцев</div>
<div class="goal-sub" id="goalSub"></div>
<div class="progress"><span id="goalBar" style="width:0%"></span></div>
<div class="goal-meta">
<span id="goalMetaL"></span>
<span id="goalMetaR"></span>
</div>
</div>
<div class="mission">
<h3>Задания на сегодня</h3>
<div class="mission-grid" id="mission"></div>
</div>
</div>
</section>
<nav class="tabs"><div class="tab-row" id="tabrow"></div></nav>
<main>
<div class="wrap">
<div class="day-bar" id="dayBar">
<span class="day-tag" id="dayTag"></span>
<div class="btnrow">
<button class="btn ghost" id="prevDay" style="padding:8px 14px"></button>
<button class="btn ghost" id="nextDay" style="padding:8px 14px"></button>
</div>
</div>
<section class="panel" id="words"></section>
<section class="panel" id="dialogue"></section>
<section class="panel" id="reading"></section>
<section class="panel" id="listening"></section>
<section class="panel" id="speaking"></section>
<section class="panel" id="writing"></section>
<section class="panel" id="test"></section>
</div>
</main>
<div class="done-bar">
<div class="wrap">
<div class="info" id="doneInfo"></div>
<button class="done-btn" id="doneBtn">Отметить выполненным</button>
</div>
</div>
<div class="toast" id="toast"></div>
<div class="modal" id="remModal">
<div class="modal-card">
<h3 class="modal-h">🔔 Напоминания</h3>
<p class="hint">Напоминание приходит в выбранное время, пока вкладка открыта. А при входе мы подскажем, если ты ещё не занимался сегодня.</p>
<div class="row-between">
<div>
<div class="rl">Ежедневное напоминание</div>
<div class="hint" id="remPerm" style="margin:0"></div>
</div>
<label class="switch"><input type="checkbox" id="remOn"><span class="sl"></span></label>
</div>
<div class="field">
<div class="rl" style="margin-bottom:8px">Время напоминания</div>
<input type="time" id="remTime" class="time-input">
</div>
<button class="btn primary block" id="remSave">Сохранить</button>
<button class="btn ghost block" id="remCancel" style="margin-top:8px">Отмена</button>
</div>
</div>
<script src="data.js"></script>
<script src="app.js"></script>
</body>
</html>

230
style.css Normal file
View File

@ -0,0 +1,230 @@
:root{
--ink:#0F1218;--cyan:#00E5FF;--cyan-50:#E8FCFF;--white:#fff;
--gray-500:#5B6573;--gray-100:#F2F4F7;--gray-200:#E6EAF0;
--green:#22C55E;--amber:#F59E0B;--pink:#FF6B9D;--violet:#8B5CF6;
--shadow:0 6px 24px rgba(15,18,24,.08);
--shadow-sm:0 2px 8px rgba(15,18,24,.06);
--radius:16px;
}
*{box-sizing:border-box;margin:0;padding:0}
html{scroll-behavior:smooth}
body{
font:17px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,sans-serif;
color:var(--ink);background:var(--gray-100);
padding-bottom:84px;-webkit-font-smoothing:antialiased;
}
button{font:inherit;cursor:pointer;border:none;background:none;color:inherit}
.wrap{max-width:760px;margin:0 auto;padding:0 16px}
/* ---------- Header ---------- */
header.top{
position:sticky;top:0;z-index:30;
background:var(--ink);color:var(--white);
height:54px;display:flex;align-items:center;padding:0 16px;
}
.top-row{width:100%;max-width:760px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;gap:12px}
.brand{display:flex;align-items:center;gap:8px;font-weight:800;font-size:18px}
.brand .logo{width:30px;height:30px;border-radius:8px;background:var(--cyan);color:var(--ink);display:grid;place-items:center;font-size:16px}
.brand small{display:block;font-weight:500;font-size:11px;color:#9aa3b2;letter-spacing:.5px}
.pills{display:flex;gap:8px}
.pill{background:rgba(255,255,255,.1);border-radius:999px;padding:6px 10px;font-size:12px;font-weight:700;display:flex;align-items:center;gap:4px;white-space:nowrap}
/* ---------- Dashboard ---------- */
.hero{background:var(--ink);color:var(--white);padding:8px 16px 22px}
.hero .wrap{padding:0}
.hello{font-size:14px;color:#9aa3b2;margin-bottom:2px}
.goal-card{background:linear-gradient(135deg,#16202e,#0F1218);border:1px solid #243040;border-radius:var(--radius);padding:18px;margin-top:8px}
.goal-title{font-size:18px;font-weight:800;margin-bottom:2px}
.goal-sub{font-size:13px;color:#9aa3b2;margin-bottom:14px}
.progress{height:12px;background:#243040;border-radius:999px;overflow:hidden}
.progress>span{display:block;height:100%;background:linear-gradient(90deg,var(--cyan),#7af0ff);border-radius:999px;transition:width .6s}
.goal-meta{display:flex;justify-content:space-between;font-size:12px;color:#9aa3b2;margin-top:8px;font-weight:600}
.goal-meta b{color:var(--cyan)}
.mission{margin-top:14px}
.mission h3{font-size:13px;color:#9aa3b2;text-transform:uppercase;letter-spacing:.6px;margin-bottom:8px;font-weight:700}
.mission-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
.mchip{background:rgba(255,255,255,.07);border:1px solid #243040;border-radius:12px;padding:10px 6px;text-align:center;font-size:12px;font-weight:600;color:#cfd6e0;display:flex;flex-direction:column;align-items:center;gap:4px}
.mchip .ic{font-size:20px}
.mchip.done{background:rgba(34,197,94,.15);border-color:rgba(34,197,94,.4);color:#bbf7d0}
.mchip.done .ic::after{content:"✓";font-size:11px;color:var(--green)}
/* ---------- Tabs ---------- */
nav.tabs{position:sticky;top:54px;z-index:20;background:var(--white);border-bottom:1px solid var(--gray-200);overflow-x:auto;scrollbar-width:none}
nav.tabs::-webkit-scrollbar{display:none}
.tab-row{max-width:760px;margin:0 auto;display:flex;gap:4px;padding:8px 12px;min-width:max-content}
.tab{padding:9px 14px;border-radius:999px;font-size:14px;font-weight:700;color:var(--gray-500);white-space:nowrap;display:flex;align-items:center;gap:6px}
.tab.active{background:var(--ink);color:var(--white)}
.tab .dot{width:6px;height:6px;border-radius:50%;background:var(--cyan);display:none}
.tab.active .dot{display:block}
/* ---------- Content ---------- */
main{padding:18px 0 30px}
.panel{display:none;animation:fade .25s ease}
.panel.active{display:block}
@keyframes fade{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
.day-bar{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:14px}
.day-tag{font-size:13px;font-weight:700;color:var(--gray-500)}
.day-tag b{color:var(--ink)}
.topic-card{background:var(--white);border-radius:var(--radius);padding:16px;box-shadow:var(--shadow-sm);display:flex;align-items:center;gap:12px;margin-bottom:16px}
.topic-card .em{font-size:30px}
.topic-card .t{font-weight:800;font-size:17px}
.topic-card .s{font-size:13px;color:var(--gray-500)}
.card{background:var(--white);border-radius:var(--radius);padding:18px;box-shadow:var(--shadow-sm);margin-bottom:14px}
.card h3{font-size:18px;font-weight:800;margin-bottom:4px;display:flex;align-items:center;gap:8px}
.card .hint{font-size:13px;color:var(--gray-500);margin-bottom:14px}
/* buttons */
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;background:var(--ink);color:var(--white);padding:12px 18px;border-radius:10px;font-weight:700;font-size:15px;transition:.15s}
.btn:hover{transform:translateY(-1px)}
.btn:active{transform:translateY(0)}
.btn.primary{background:var(--cyan);color:var(--ink)}
.btn.ghost{background:var(--gray-100);color:var(--ink)}
.btn.outline{background:transparent;border:2px solid var(--gray-200);color:var(--ink)}
.btn.block{width:100%}
.btn:disabled{opacity:.5;cursor:not-allowed;transform:none}
.btnrow{display:flex;gap:8px;flex-wrap:wrap}
/* words */
.word{border:1px solid var(--gray-200);border-radius:12px;padding:14px;margin-bottom:10px;cursor:pointer;transition:.15s}
.word:hover{border-color:var(--cyan)}
.word-top{display:flex;align-items:center;justify-content:space-between;gap:10px}
.word-en{font-weight:800;font-size:18px}
.word-ru{font-size:14px;color:var(--gray-500)}
.word-def{font-size:14px;color:var(--ink);margin-top:8px;display:none}
.word-ex{font-size:13px;color:var(--gray-500);margin-top:6px;font-style:italic;display:none}
.word.open .word-def,.word.open .word-ex{display:block}
.play{width:36px;height:36px;border-radius:50%;background:var(--cyan-50);color:var(--ink);display:grid;place-items:center;font-size:16px;flex-shrink:0}
.play:hover{background:var(--cyan)}
.stars{font-size:12px;color:var(--amber);margin-top:6px;display:none}
.word.open .stars{display:block}
/* dialogue */
.lines{display:flex;flex-direction:column;gap:8px;margin-bottom:14px}
.line{display:flex;gap:10px;max-width:88%}
.line.B{align-self:flex-end;flex-direction:row-reverse}
.bubble{background:var(--gray-100);border-radius:14px;padding:10px 14px;font-size:15px}
.line.B .bubble{background:var(--cyan-50)}
.bubble .who{font-size:11px;font-weight:800;color:var(--gray-500);margin-bottom:2px;text-transform:uppercase;letter-spacing:.4px}
.bubble .tr{font-size:12px;color:var(--gray-500);margin-top:6px;font-style:italic;display:none}
.line.show-tr .bubble .tr{display:block}
/* quiz */
.q{margin-bottom:16px}
.q .qt{font-weight:700;margin-bottom:8px;font-size:15px}
.opts{display:flex;flex-direction:column;gap:6px}
.opt{border:1.5px solid var(--gray-200);border-radius:10px;padding:11px 14px;font-size:14px;text-align:left;transition:.12s;font-weight:500}
.opt:hover{border-color:var(--cyan)}
.opt.correct{background:#dcfce7;border-color:var(--green);font-weight:700}
.opt.wrong{background:#fee2e2;border-color:#ef4444}
.opt.disabled{pointer-events:none}
.explain{font-size:13px;color:var(--gray-500);margin-top:6px;padding:8px 10px;background:var(--gray-100);border-radius:8px;display:none}
.explain.show{display:block}
.score{font-weight:800;text-align:center;padding:10px;border-radius:10px;background:var(--cyan-50);margin-top:10px;display:none}
.score.show{display:block}
/* listening */
.audio-controls{display:flex;align-items:center;gap:10px;margin-bottom:14px;flex-wrap:wrap}
.speed{display:flex;gap:4px;background:var(--gray-100);border-radius:999px;padding:4px}
.speed button{padding:6px 12px;border-radius:999px;font-size:13px;font-weight:700;color:var(--gray-500)}
.speed button.on{background:var(--white);color:var(--ink);box-shadow:var(--shadow-sm)}
.listen-text{background:var(--gray-100);border-radius:12px;padding:14px;font-size:15px;line-height:1.7;display:none;margin-bottom:14px}
.listen-text.show{display:block}
.rec-badge{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:700;color:#ef4444;padding:4px 10px;background:#fee2e2;border-radius:999px}
.rec-badge .blink{width:8px;height:8px;border-radius:50%;background:#ef4444;animation:blink 1s infinite}
@keyframes blink{50%{opacity:.3}}
/* speaking */
.speak-box{display:flex;flex-direction:column;align-items:center;gap:12px;text-align:center;padding:20px;background:var(--gray-100);border-radius:var(--radius);margin-bottom:14px}
.mic{width:72px;height:72px;border-radius:50%;background:var(--ink);color:var(--white);font-size:30px;display:grid;place-items:center;transition:.2s}
.mic.rec{background:#ef4444;animation:pulse 1.2s infinite}
@keyframes pulse{0%,100%{box-shadow:0 0 0 0 rgba(239,68,68,.4)}50%{box-shadow:0 0 0 16px rgba(239,68,68,0)}}
.transcript{width:100%;background:var(--white);border-radius:12px;padding:14px;min-height:60px;font-size:15px;text-align:left}
.transcript .ph{color:var(--gray-500)}
.match{font-size:14px;font-weight:700;padding:6px 14px;border-radius:999px}
.match.good{background:#dcfce7;color:#15803d}
.match.ok{background:#fef9c3;color:#a16207}
.match.bad{background:#fee2e2;color:#b91c1c}
.target{background:#fffbeb;border:1px solid #fde68a;border-radius:12px;padding:12px;font-size:14px;display:none}
.target.show{display:block}
.target b{display:block;margin-bottom:4px;color:#a16207}
/* writing */
textarea{width:100%;border:1.5px solid var(--gray-200);border-radius:12px;padding:12px;font:inherit;font-size:15px;resize:vertical;min-height:120px;transition:.15s}
textarea:focus{outline:none;border-color:var(--cyan)}
.model{background:#f0fdf4;border:1px solid #bbf7d0;border-radius:12px;padding:14px;font-size:14px;display:none;margin-top:10px}
.model.show{display:block}
.model b{display:block;color:#15803d;margin-bottom:6px}
.tips{font-size:13px;color:var(--gray-500);margin-top:10px;padding:10px 12px;background:var(--cyan-50);border-radius:8px}
/* done bar */
.done-bar{position:fixed;left:0;right:0;bottom:0;z-index:40;background:var(--white);border-top:1px solid var(--gray-200);padding:10px 16px;box-shadow:0 -4px 16px rgba(15,18,24,.06)}
.done-bar .wrap{display:flex;align-items:center;gap:12px;max-width:760px}
.done-bar .info{flex:1;font-size:13px;font-weight:600;color:var(--gray-500)}
.done-bar .info b{color:var(--ink)}
.done-btn{background:var(--cyan);color:var(--ink);padding:12px 22px;border-radius:10px;font-weight:800;font-size:15px}
.done-btn.done{background:var(--green);color:var(--white)}
/* toast */
.toast{position:fixed;bottom:100px;left:50%;transform:translateX(-50%) translateY(20px);background:var(--ink);color:var(--white);padding:12px 20px;border-radius:999px;font-weight:700;font-size:14px;z-index:60;opacity:0;pointer-events:none;transition:.3s}
.toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
@media(max-width:640px){
body{font-size:16px}
.mission-grid{grid-template-columns:repeat(3,1fr)}
.card{padding:16px}
.done-btn{padding:12px 18px}
}
@media(min-width:641px){
.hero{padding:12px 16px 26px}
}
/* ---------- bell ---------- */
.bell{cursor:pointer;border:none}
.bell.on::after{content:"";width:7px;height:7px;border-radius:50%;background:var(--cyan);display:inline-block;margin-left:4px;vertical-align:middle}
/* ---------- reminder banner ---------- */
.banner{background:var(--cyan);color:var(--ink);border-radius:12px;padding:11px 14px;margin:10px 0;font-weight:700;font-size:14px;display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}
.banner[hidden]{display:none}
.banner .link{background:var(--ink);color:var(--white);padding:7px 12px;border-radius:8px;font-weight:800;font-size:13px;border:none;cursor:pointer}
/* ---------- modal ---------- */
.modal{position:fixed;inset:0;z-index:70;background:rgba(15,18,24,.55);display:none;align-items:center;justify-content:center;padding:16px}
.modal.show{display:flex}
.modal-card{background:var(--white);width:100%;max-width:420px;border-radius:20px;padding:22px;max-height:90vh;overflow:auto;animation:slideup .25s}
@keyframes slideup{from{transform:translateY(24px);opacity:.5}to{transform:none;opacity:1}}
.modal-h{font-size:19px;font-weight:800;margin-bottom:8px}
.row-between{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:18px 0}
.rl{font-weight:700;font-size:15px}
.field{margin-bottom:18px}
.time-input{font:inherit;font-size:18px;padding:10px 12px;border:2px solid var(--gray-200);border-radius:10px;width:100%}
.time-input:focus{outline:none;border-color:var(--cyan)}
/* switch */
.switch{position:relative;width:48px;height:28px;display:inline-block;flex-shrink:0}
.switch input{opacity:0;width:0;height:0}
.switch .sl{position:absolute;inset:0;background:var(--gray-200);border-radius:999px;transition:.2s;cursor:pointer}
.switch .sl:before{content:"";position:absolute;width:22px;height:22px;left:3px;top:3px;background:#fff;border-radius:50%;transition:.2s;box-shadow:var(--shadow-sm)}
.switch input:checked+.sl{background:var(--cyan)}
.switch input:checked+.sl:before{transform:translateX(20px)}
/* ---------- test tab ---------- */
.tab-test{color:var(--violet)}
.tab-test.active{background:var(--violet);color:#fff}
.mrow{display:flex;justify-content:space-between;align-items:center;padding:10px 2px;border-bottom:1px solid var(--gray-100);font-weight:600;font-size:15px}
.mrow:last-child{border-bottom:none}
.mrow b{color:var(--cyan);font-size:16px}
.mrow .miss{color:var(--gray-500);font-size:13px;font-weight:500}
.result-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
.rcell{background:var(--gray-100);border-radius:12px;padding:12px 6px;text-align:center}
.rcell .ric{font-size:22px}
.rcell .rlabel{font-size:11px;color:var(--gray-500);font-weight:700;margin:3px 0;letter-spacing:.3px}
.rcell .rscore{font-size:15px;font-weight:800}
.rcell.good{background:#dcfce7}
.rcell.ok{background:#fef9c3}
.rcell.bad{background:#fee2e2}