Italiano Vivo MVP: spec, monorepo app+server, 18 missoes (bambino+viaggio), audio TTS edge-tts, judge 3-eixos CEFR, SRS, PWA offline

main
henrique 9 hours ago
parent aa4fe5865f
commit 7a5f5be5dc

@ -0,0 +1,12 @@
# Copie para .env (server/.env). NUNCA commite o .env real.
# LiteLLM proxy local (gratuito, dentro da assinatura Token Plan)
LITELLM_BASE_URL=http://10.8.0.1:4000/v1
LITELLM_API_KEY=coloqu…aqui
# Modelos
CHAT_MODEL=qwen3.8-max # juiz de feedback / geracao de missões
FAST_MODEL=qwen3.8-flash # drills e tarefas leves
OMNI_MODEL=qwen3-omni-flash # transcricao de audio do aluno (ASR tolerante a sotaque)
# Porta do server
PORT=8787

11
.gitignore vendored

@ -0,0 +1,11 @@
node_modules/
dist/
build/
.env
.env.local
*.log
.DS_Store
Thumbs.db
server/cache/
app/dist-ssr/
coverage/

@ -1,2 +1,151 @@
# cursoitaliano # 🇮🇹 Italiano Vivo
App de italiano **situacional-first** com voz livre e scoring de inteligibilidade.
Aprenda como uma criança: errando mas se comunicando.
## Visão
- **Scoring em 3 eixos**: Sentido transmitido / Naturalidade / Precisão gramatical
- **Tolerância estilo criança**: erro gramatical que não impede entendimento = sucesso comunicativo
- **Voz livre**: grave, receba feedback estruturado, nunca "Errado"
- **Salvavidas (Salvavita)**: treine estratégias de comunicação quando a palavra some
- **SRS de chunks**: revisão espaçada de frases, não palavras isoladas
- **PWA offline**: catálogo, áudio e SRS funcionam sem conexão
- **CEFR can-do**: badges baseados em competências reais ("você consegue reclamar de um item com defeito")
## Como Rodar
### Pré-requisitos
- Node.js ≥ 20
- LiteLLM proxy rodando (ou configure `LITELLM_BASE_URL` para outro endpoint OpenAI-compatible)
### Instalação
```bash
# Instalar dependências do monorepo
npm install
# Configurar variáveis de ambiente
cp .env.example server/.env
# Edite server/.env com sua chave da API
```
### Desenvolvimento
```bash
# Roda app + server simultaneamente
npm run dev
```
- App: http://localhost:5173
- Server: http://localhost:8787
- Health check: http://localhost:8787/health
### Build
```bash
npm run build # Build server + app
npm run typecheck # Verificação de tipos
```
## Endpoints do Server
| Método | Rota | Descrição |
|--------|------|-----------|
| GET | `/health` | Status do servidor |
| POST | `/api/judge` | Avalia fala do aluno em 3 eixos (sentido, naturalidade, gramática) |
| POST | `/api/transcribe` | Transcreve áudio via modelo omni (ASR tolerante a sotaque) |
| GET | `/api/tts-proxy?text=...&voice=elsa\|diego` | Serve áudio TTS pré-gerado do cache local |
### POST /api/judge
**Entrada:**
```json
{
"missionId": "bambino-m01",
"scenario": "Al bar",
"learnerGoalPt": "Pedir um café",
"expectedIt": "Vorrei un caffè, per favore",
"utteranceText": "io volere caffe",
"transcriptRaw": "io volere caffe",
"audioBase64": "..."
}
```
**Saída:**
```json
{
"meaning_conveyed": "full",
"naturalness": 45,
"natural_version": "Vorrei un caffè, per favore",
"grammar": [
{
"it_said": "io volere",
"problem_pt": "Infinitivo em vez de conjugação",
"fix_it": "vorrei / voglio",
"severity": "minor"
}
],
"next_move": {
"italian": "Certo! Un caffè. Lo vuole al banco o al tavolo?",
"portuguese": "Claro! Um café. Quer no balcão ou na mesa?"
},
"praise_or_push": "Quase lá! Um italiano diria: Vorrei un caffè, per favore"
}
```
## Estrutura de Pastas
```
cursoitaliano/
├── app/ # PWA React + Vite + TypeScript
│ ├── public/ # manifest.webmanifest, ícones
│ └── src/
│ ├── db/db.ts # IndexedDB via Dexie
│ ├── engine/srs.ts # SM-2 simplificado
│ ├── types/content.ts # Tipos do conteúdo JSON
│ ├── App.tsx # Componente principal
│ ├── main.tsx # Entry point
│ └── styles.css # Design system
├── server/ # Node + Express + TypeScript
│ ├── cache/audio/ # Áudios TTS pré-gerados (offline)
│ └── src/
│ ├── lib/llm.ts # Cliente LiteLLM singleton
│ ├── routes/
│ │ ├── judge.ts # Motor de feedback 3 eixos
│ │ ├── transcribe.ts # ASR via omni model
│ │ └── tts.ts # Proxy de áudio TTS
│ └── index.ts # Express app
├── content/ # JSONs versionados (gerados por pipeline)
├── audio/ # Assets de áudio por missão
├── SPEC.md # Especificação completa do produto
├── .env.example # Template de variáveis de ambiente
└── package.json # Monorepo root (npm workspaces)
```
## Modelos Utilizados
| Modelo | Uso | Variável |
|--------|-----|----------|
| qwen3.8-max | Juiz de feedback, geração de missões | `CHAT_MODEL` |
| qwen3.8-flash | Drills rápidos, fallback de quota | `FAST_MODEL` |
| qwen3-omni-flash | Transcrição de áudio (ASR) | `OMNI_MODEL` |
Todos acessados via LiteLLM proxy (`LITELLM_BASE_URL`).
## Roadmap
- [x] Esqueleto do monorepo (app PWA + server LiteLLM)
- [ ] Geração de conteúdo: Trilha Bambino (12 unidades) + Viaggio M1–M6
- [ ] Pipeline de geração de áudio TTS (edge-tts)
- [ ] Service Worker para modo offline completo
- [ ] Minijogo "descreva sem nomear" (circunlóquio)
- [ ] Modo carro/shadowing com medição de lag
- [ ] Trilhas restantes (Viaggio M7–M15, Vivere completa)
- [ ] Preparatório CILS/CELI B1 cidadania
- [ ] Wrapper Android (Capacitor)
- [ ] Calibração do judge com votos "isso estava certo"
## Licença
Privado — uso pessoal.

@ -0,0 +1,90 @@
# Italiano Vivo — Especificação do Produto (v1)
App de italiano **situacional-first**, com voz livre, scoring de **inteligibilidade** ("errado mas entendido" conta como sucesso), treino de **estratégias de comunicação** (circunlóquio, pedir a palavra que falta) e **modo viagem offline**. Do extremo básico (A0/Pre-A1) ao avançado (C1), ancorado nos *can-do statements* do CEFR / ALTE Social & Tourist Can-Dos.
## 1. Por que este app existe (lacunas comprovadas na pesquisa)
| Dor | Estado do mercado | Nossa resposta |
|---|---|---|
| Avaliação binária ❌/✅ | Babbel/Duolingo/Speakly marcam fala correta como errada; nenhum separa precisão de sentido | Scoring em 3 eixos: **Sentido transmitido / Naturalidade / Precisão gramatical**. Saída padrão: *"Quase lá — o italiano diria: …"*, nunca "Errado" |
| Vocabulário temático solto | Babbel começa com queijo de cabra; Duolingo prende o aluno no path | Dia 1: aluno escolhe as próprias situações-âncora ("quero pedir um lanche", "preciso reclamar do aquecedor") |
| Falta de estratégia quando a palavra some | Zero apps de italiano treinam circunlóquio (evidência: Coluzzi 2022; Dörnyei 1995) | Módulo dedicado **"Salvavita"**: *Come si dice…?*, *Può ripetere più lentamente?*, descrever sem nomear. Penaliza **abandonar a mensagem**, não o erro |
| Syllabus gramatical (presente→congiuntivo) | Todos os grandes | Unidades = **missões comunicativas** (aeroporto, farmácia, hotel…). Gramática entra como insumo da missão, nunca como capítulo |
| Voz livre sem memória | Apps de IA conversacional: "no vocab list, no grammar log, no review schedule" | Conversa livre → extrai as frases que quebraram → rebuild → reaparecem espaçadas **dentro de uma situação nova** |
| Offline confiável + áudio-only | Só Pimsleur é hands-free, e 9% das reviews negativas citam offline quebrado | PWA com Service Worker + catálogo 100% local; TTS/áudio empacotados; IA só quando online, com degradação suave |
| ASR hostil ao aprendiz | Monossílabos ignorados, correções circulares | ASR via LLM multimodal (tolerante a sotaque) + botão **"isso estava certo"** que vira dado de re-treino; sensível a geminadas (*pala/balla*, *nono/nonna*) |
| Billing predatório | Queixa nº1 (renovação silenciosa, expiração antecipada) | **Compra única por trilha** ou lifetime. Sem streak-guilt, sem conteúdo removido |
## 2. Persona e escopo
- **Henrique viajante**: foi à Itália, travou no balcão. Quer o básico de criança ("io voglio / io ho bisogno / mi scusi, non so come si dice") encaixado em contexto real, progredindo até conversa adulta.
- Escopo **tudo**: viajante + expat/burocracia (permesso, ASL, banca, affitto), porque há exigência legal objetiva (B1 cidadania CILS/CELI) e disposição a pagar por resultado mensurável.
## 3. Metodologia implementada (mecânica de app)
| Princípio | Mecânica |
|---|---|
| Aquisição natural (como criança) | Fase 1 só áudio+imagem, sem escrita; texto desbloqueado após N exposições; produção cedo porém curta |
| Comprehensible input i+1 | Diálogos com % de cognatos controlado pelo nível; missão seguinte libera com comprehension ≥ ~80% |
| Chunks / lexical approach | Unidade mínima = bloco pronto (`Mi scusi, non ho capito — può ripetere più lentamente?`), nunca palavra isolada |
| CLT / Task-Based Learning | Cada missão tem **critério de sucesso comunicativo** ("marque uma visita médica sem inglês"). O app avalia a meta atingida, depois expõe os gaps |
| Output practice (Swain) | Após conversa livre: extrair 3 frases tentadas-e-quebradas → rebuild → re-gravar → reencontrar 3 dias depois |
| SRS | Revisão de **frases-chunks** (SM-2 simplificado), reintroduzidas dentro de diálogo novo, não como flashcard morto |
| Shadowing | Modo carro/corrida: áudio nativo em velocidade real, gravação sobreposta, medição de lag; protocolo Arguelles (blind → transcript → read+shadow) |
| Estratégias de comunicação (CS) | Minijogo "descreva sem nomear" cronometrado; cartões de resgate; auto-repair pontuado positivamente |
| CEFR can-do | Badge = **"Você agora consegue: reclamar de um item com defeito"**, com verificação por voz. Pre-A1 → B2/C1 |
## 4. Trilhas e missões (espinha dorsal)
**Trilha Bambino (A0–A1)** — núcleo da ideia: quero/não quero, tenho/preciso, gosto, dói, é grande/pequeno, mais/menos, isto/aquilo, números, sim/não/forse, e principalmente os **salvavidas**: *come si dice…? / non capisco / ripeta piano / come si scrive? / scusi, non conosco la parola*.
**Trilha Viaggio (A1–B2)** — aeroporto e alfândega · check-in de voo · táxi/ônibus/trem · hotel (checar, reclamar do quarto, pedir extra) · bar e restaurante (pedir, alergia, conta, gorjeta, reserva) · supermercado/padaria/lanche ("vorrei un panino con…" + descrever o que não sei o nome) · farmácia e médico (sintomas, dor, dosagem) · banco e câmbio · aluguel de carro · compras e devolução · reclamar/negociar/recusar · fazer amizade/small talk · emergência e polícia · telefone/online burocrático.
**Trilha Vivere (B1–C1)** — questura/permesso, ASL/sanità, SPID, comune/anagrafe, casa e landlord, scuola dei figli, lavoro, preparazione CILS/CELI B1 cittadinanza.
Cada missão = `scenario` com: objetivos can-do, chunks essenciais, diálogos-âncora (áudio), armadilhas reais (tu vs Lei, cortesia italiana, "magari/dai/vabbè"), role-play de voz livre, e checklist de sobrevivência offline.
## 5. Motor de feedback (o diferencial)
Pipeline por tentativa de fala:
1. **ASR** (qwen omni-flash, tolerante a sotaque) → transcrição + confusão provável.
2. **Judge** (qwen3.8-max) produz JSON estruturado:
- `meaning_conveyed`: full | partial | failed
- `naturalness`: score + versão que um italiano diria
- `grammar`: erros classificados (grave=gera mal-entendido / leve / cosmético)
- `next_move`: continuação do diálogo como italiano faria
- `rescue_card`: se o aluno travou por falta de palavra → ensina o salvavita aplicável
3. **Regra de ouro:** `meaning_conveyed=full` ⇒ missão avança mesmo com erros graves leves; feedback sempre termina com a frase reconstruída para repetir.
4. Botão "isto estava certo" → registra dissidência (dataset futuro de calibração).
## 6. Arquitetura técnica
```
cursoitaliano/
app/ # PWA React+Vite+TS (IndexedDB/Dexie, Workbox SW, MediaRecorder, Web Speech playback)
src/lib/ai.ts # cliente OpenAI-compatible -> LiteLLM (http://10.8.0.1:4000/v1)
src/engine/ # judge, scoring, srs, mission graph
src/content/ # JSONs gerados (missões, chunks, diálogos, salvavidas)
server/ # Node TS: /transcribe /judge /tts /generate-mission (cache + rate-limit + fallback chain)
content/ # pipeline de geração (Node) -> JSON versionado no repo
audio/ # assets TTS pré-gerados (por missão)
SPEC.md README.md .env.example
```
- **IA**: tudo via LiteLLM local (sem custo PAYG): chat/judge `qwen3.8-max`, drills rápidos `qwen3.8-flash`, voz/ASR `qwen3-omni-flash` (audio base64), TTS via Edge TTS vozes `it-IT-ElsaNeural`/`it-IT-DiegoNeural` (geração em lote, pacote offline; fallback Web Speech API).
- **Offline**: catálogo, chunks, diálogos e áudio empacotados no build + IndexedDB; role-play de voz exige rede; **phrasebook de resgate e SRS funcionam 100% offline**; fila de tentativas para julgamento quando voltar a conexão.
- **Segurança**: chave do proxy fora do bundle — `.env` no `server/`, app fala só com o próprio server; `.env` no .gitignore; `.env.example` sem segredo.
## 7. MVP (o que está sendo construído agora)
1. Repo inicializado, config, scripts.
2. Conteúdo gerado: trilha Bambino completa (12 unidades) + Viaggio M1–M6 (aeroporto, táxi/trem, hotel, restaurante, bar/lanche, farmácia/médico), cada uma com can-dos, 12–20 chunks, 2 diálogos, role-play prompts, salvavidas.
3. Server com `/judge` (3-eixos), `/transcribe` (omni), `/tts`.
4. App PWA: home por missões, player de diálogo com áudio, **cabine de voz** (gravar → judge → feedback "quase lá"), minijogo "descreva sem nomear", SRS de chunks, modo resgate offline.
5. README com rodar-local, arquitetura e roadmap (trilhas restantes, shadowing mode, CILS prep, Android nativo).
## 8. Roadmap pós-MVP
- Missões 7–15 da trilha Viaggio + trilha Vivere inteira (geração pelo mesmo pipeline).
- Modo carro/shadowing com medição de lag, análise fonêmica de geminadas.
- Export de erro como currículo (log de interlíngua + revisão adaptativa).
- Trilha CILS/CELI B1 (cidadania) com simulado.
- Wrapper Android (Capacitor) com áudio offline por pacote.
- Calibração do judge com os votos "isto estava certo".

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#009246" />
<meta name="description" content="Aprenda italiano com voz livre — situacional-first, tolerante a erros" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Italiano Vivo</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

@ -0,0 +1,26 @@
{
"name": "italiano-vivo-app",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"dexie": "^4.0.11",
"dexie-react-hooks": "^1.1.7",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"zustand": "^5.0.2"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.0",
"vite": "^6.0.0"
}
}

@ -0,0 +1,22 @@
{
"name": "Italiano Vivo",
"short_name": "ItalianoVivo",
"description": "Aprenda italiano com voz livre — situacional-first, tolerante a erros",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#009246",
"orientation": "portrait-primary",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

@ -0,0 +1,91 @@
/**
* Service Worker — Italiano Vivo
* Estratégia:
* - Shell (HTML/CSS/JS do app): cache-first
* - /content/* e /audio/*: cache-first com network fallback + runtime caching
* - /api/*: network-only (nunca cachear)
*
* Objetivo: modo resgate offline — frases + áudio já baixados funcionam no avião.
*/
const CACHE_NAME = "italiano-vivo-v1";
const SHELL_ASSETS = ["/", "/index.html"];
// ─── Install: pré-cache do shell ───
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_ASSETS)),
);
self.skipWaiting();
});
// ─── Activate: limpar caches antigos ───
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((k) => k !== CACHE_NAME)
.map((k) => caches.delete(k)),
),
),
);
self.clients.claim();
});
// ─── Fetch: roteamento por padrão de URL ───
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
const pathname = url.pathname;
// API: sempre network-only
if (pathname.startsWith("/api/")) {
return; // deixa o browser fazer o fetch normal
}
// Content e Audio: cache-first com network fallback + runtime caching
if (pathname.startsWith("/content/") || pathname.startsWith("/audio/")) {
event.respondWith(cacheFirstWithRuntimeCache(event.request));
return;
}
// Shell e demais assets: cache-first
event.respondWith(cacheFirst(event.request));
});
/**
* Cache-first: tenta cache, se não achar vai na rede (sem salvar no cache).
* Usado para shell/assets que são pré-cachados no install.
*/
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
return fetch(request);
}
/**
* Cache-first com runtime caching: tenta cache, se não achar vai na rede
* e salva a resposta no cache para uso offline futuro.
* Usado para /content/* e /audio/* que são baixados sob demanda.
*/
async function cacheFirstWithRuntimeCache(request) {
const cached = await caches.match(request);
if (cached) return cached;
try {
const response = await fetch(request);
// Só cachear respostas OK (não cachear 404/500)
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
} catch (_err) {
// Offline e sem cache: retorna erro genérico
return new Response("Offline — Italiano Vivo", {
status: 503,
statusText: "Service Unavailable",
headers: { "Content-Type": "text/plain" },
});
}
}

@ -0,0 +1,664 @@
/**
* App principal do Italiano Vivo.
* Roteamento simples por estado (sem react-router).
* Componentes funcionais com CSS próprio.
*/
import { useState, useEffect, useRef, useCallback } from "react";
import type { ContentIndex, Mission, MissionChunk, DialogueLine } from "./types/content.js";
import { db, type AttemptRecord } from "./db/db.js";
import { useLiveQuery } from "dexie-react-hooks";
import { review as srsReview, verdictToQuality, createCard, getDueCards } from "./engine/srs.js";
import type { SRSCard } from "./db/db.js";
// ─── Tipos de veredito do judge ───
interface JudgeVerdict {
meaning_conveyed: "full" | "partial" | "failed";
naturalness: number;
natural_version: string;
grammar: Array<{
it_said: string;
problem_pt: string;
fix_it: string;
severity: "blocks_understanding" | "minor" | "cosmetic";
}>;
rescue_card?: {
come_si_dice: string;
frase_it: string;
explicacao_pt: string;
};
next_move: {
italian: string;
portuguese: string;
};
praise_or_push: string;
}
type ViewState = "home" | "mission" | "review";
// ─── Flag italiana ───
function ItalianFlag() {
return (
<div className="flag">
<span className="green" />
<span className="white" />
<span className="red" />
</div>
);
}
// ─── Home: lista de missões por trilha ───
function HomeView({ onSelectMission }: { onSelectMission: (id: string) => void }) {
const [index, setIndex] = useState<ContentIndex | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const res = await fetch("/content/index.json");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()) as ContentIndex;
if (!cancelled) setIndex(data);
} catch (e) {
if (!cancelled) {
console.warn("Falha ao carregar conteúdo, usando array vazio:", e);
setIndex({ schemaVersion: 1, missions: [] });
setError("Conteúdo ainda não gerado. As missões aparecerão após o pipeline de geração.");
}
} finally {
if (!cancelled) setLoading(false);
}
}
load();
return () => { cancelled = true; };
}, []);
if (loading) return <div className="loading">🇮🇹 Carregando missões...</div>;
const trails = ["bambino", "viaggio", "vivere"] as const;
return (
<div>
{error && <div className="error-banner">{error}</div>}
{trails.map((trail) => {
const missions = index?.missions.filter((m) => m.trail === trail) ?? [];
if (missions.length === 0) return null;
return (
<div key={trail}>
<h2 style={{ marginTop: 20 }}>
Trilha {trail.charAt(0).toUpperCase() + trail.slice(1)}
</h2>
{missions.map((m) => (
<div key={m.id} className="card" onClick={() => onSelectMission(m.id)}>
<div className="card-header">
<h3>{m.titleIt}</h3>
<span className={`trail-badge trail-${trail}`}>{trail}</span>
</div>
<p className="text-muted">{m.titlePt}</p>
<p className="text-muted" style={{ fontSize: "0.8rem" }}>
CEFR: {m.cefr} · {m.canDos.length} objetivos
</p>
</div>
))}
</div>
);
})}
{(!index || index.missions.length === 0) && !error && (
<div className="card">
<p>Nenhuma missão disponível ainda.</p>
</div>
)}
</div>
);
}
// ─── Player de áudio ───
function AudioPlayer({ audioKey, label }: { audioKey: string; label?: string }) {
const [playing, setPlaying] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
const play = useCallback(() => {
if (audioRef.current) {
audioRef.current.currentTime = 0;
audioRef.current.play().catch(() => {});
}
}, []);
return (
<>
<button
className="btn-play"
onClick={play}
title={label ?? "Tocar áudio"}
aria-label={label ?? "Tocar áudio"}
>
{playing ? "⏸" : "▶"}
</button>
<audio
ref={audioRef}
src={`/audio/${audioKey}.mp3`}
onPlay={() => setPlaying(true)}
onEnded={() => setPlaying(false)}
onError={() => setPlaying(false)}
/>
</>
);
}
// ─── Cabine de Voz ───
function VoiceBooth({
missionId,
scenario,
goalPt,
expectedIt,
onComplete,
}: {
missionId: string;
scenario: string;
goalPt: string;
expectedIt?: string;
onComplete: (verdict: JudgeVerdict) => void;
}) {
const [recording, setRecording] = useState(false);
const [processing, setProcessing] = useState(false);
const [transcript, setTranscript] = useState("");
const [error, setError] = useState<string | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const startRecording = async () => {
try {
setError(null);
setTranscript("");
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream, { mimeType: "audio/webm" });
chunksRef.current = [];
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunksRef.current.push(e.data);
};
recorder.onstop = async () => {
stream.getTracks().forEach((t) => t.stop());
const blob = new Blob(chunksRef.current, { type: "audio/webm" });
await processAudio(blob);
};
mediaRecorderRef.current = recorder;
recorder.start();
setRecording(true);
} catch (e) {
setError("Não foi possível acessar o microfone. Verifique as permissões.");
}
};
const stopRecording = () => {
if (mediaRecorderRef.current && recording) {
mediaRecorderRef.current.stop();
setRecording(false);
}
};
const processAudio = async (blob: Blob) => {
setProcessing(true);
setError(null);
try {
// Converter para base64
const buffer = await blob.arrayBuffer();
const bytes = new Uint8Array(buffer);
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]!);
}
const base64 = btoa(binary);
// Transcrever
const transcribeRes = await fetch("/api/transcribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ audioBase64: base64, mime: "audio/webm" }),
});
if (!transcribeRes.ok) throw new Error("Erro na transcrição");
const transcribeData = (await transcribeRes.json()) as { text: string };
setTranscript(transcribeData.text);
// Julgar
const judgeRes = await fetch("/api/judge", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
missionId,
scenario,
learnerGoalPt: goalPt,
expectedIt,
transcriptRaw: transcribeData.text,
}),
});
if (!judgeRes.ok) throw new Error("Erro no julgamento");
const verdict = (await judgeRes.json()) as JudgeVerdict;
// Salvar tentativa no DB
await db.attempts.add({
missionId,
ts: Date.now(),
text: transcribeData.text,
verdict: {
meaning_conveyed: verdict.meaning_conveyed,
naturalness: verdict.naturalness,
natural_version: verdict.natural_version,
grammar: verdict.grammar,
praise_or_push: verdict.praise_or_push,
},
});
onComplete(verdict);
} catch (e) {
setError(e instanceof Error ? e.message : "Erro desconhecido");
} finally {
setProcessing(false);
}
};
return (
<div className="voice-booth">
<p>🎙️ Fale em italiano:</p>
<p className="text-it">"{goalPt}"</p>
{error && <div className="error-banner" style={{ margin: "12px 0" }}>{error}</div>}
{transcript && (
<div className="card" style={{ textAlign: "left", marginBottom: 12 }}>
<p className="text-muted">Transcrição:</p>
<p className="text-it">"{transcript}"</p>
</div>
)}
<div style={{ display: "flex", gap: 12, justifyContent: "center" }}>
{!recording ? (
<button
className="btn btn-record"
onClick={startRecording}
disabled={processing}
title="Gravar"
>
🎤
</button>
) : (
<button
className="btn btn-record recording"
onClick={stopRecording}
title="Parar gravação"
>
⏹
</button>
)}
</div>
{processing && <p className="text-muted" style={{ marginTop: 12 }}>Analisando sua fala...</p>}
</div>
);
}
// ─── Feedback do Judge ───
function FeedbackPanel({
verdict,
missionId,
}: {
verdict: JudgeVerdict;
missionId: string;
}) {
const [dissentRegistered, setDissentRegistered] = useState(false);
const handleDissent = async () => {
// Registrar dissidência ("isso estava certo")
const lastAttempt = await db.attempts
.where("missionId")
.equals(missionId)
.reverse()
.sortBy("ts");
const attempt = lastAttempt[0];
if (attempt) {
await db.attempts.update(attempt.id!, { dissent: true });
setDissentRegistered(true);
}
};
const meaningClass =
verdict.meaning_conveyed === "full"
? "success"
: verdict.meaning_conveyed === "partial"
? "warning"
: "error";
return (
<div>
<div className={`feedback-axis ${meaningClass}`}>
<strong>Sentido transmitido:</strong>{" "}
{verdict.meaning_conveyed === "full"
? "✅ Completo"
: verdict.meaning_conveyed === "partial"
? "⚠️ Parcial"
: "❌ Não transmitido"}
</div>
<div className="natural-version">
{verdict.natural_version}
</div>
<p style={{ fontWeight: 600, color: "var(--verde)", fontSize: "1.05rem" }}>
{verdict.praise_or_push}
</p>
{verdict.grammar.length > 0 && (
<div style={{ marginTop: 12 }}>
<h3>Gramática</h3>
{verdict.grammar.map((err, i) => (
<div key={i} className={`grammar-error ${err.severity}`}>
<span className="text-it">"{err.it_said}"</span> →{" "}
<span className="fix">{err.fix_it}</span>
<br />
<span className="text-muted">{err.problem_pt}</span>
</div>
))}
</div>
)}
{verdict.rescue_card && (
<div className="card" style={{ marginTop: 12, borderLeft: "4px solid var(--rosso)" }}>
<h3>💡 Salvavita</h3>
<p className="text-it">{verdict.rescue_card.come_si_dice}</p>
<p>{verdict.rescue_card.frase_it}</p>
<p className="text-muted">{verdict.rescue_card.explicacao_pt}</p>
</div>
)}
<div className="card" style={{ marginTop: 12 }}>
<h3>Próximo passo do diálogo</h3>
<p className="text-it">{verdict.next_move.italian}</p>
<p className="text-muted">{verdict.next_move.portuguese}</p>
</div>
{!dissentRegistered && (
<button className="dissent-btn" onClick={handleDissent}>
🤔 Isso estava certo (discordar da avaliação)
</button>
)}
{dissentRegistered && (
<p className="text-muted" style={{ marginTop: 8, fontSize: "0.85rem" }}>
✓ Dissidência registrada. Obrigado por ajudar a calibrar!
</p>
)}
</div>
);
}
// ─── Visão da Missão ───
function MissionView({
missionId,
onBack,
}: {
missionId: string;
onBack: () => void;
}) {
const [mission, setMission] = useState<Mission | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [verdict, setVerdict] = useState<JudgeVerdict | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const res = await fetch(`/content/missions/${missionId}.json`);
if (!res.ok) throw new Error(`Missão não encontrada (HTTP ${res.status})`);
const data = (await res.json()) as Mission;
if (!cancelled) setMission(data);
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : "Erro ao carregar missão");
} finally {
if (!cancelled) setLoading(false);
}
}
load();
return () => { cancelled = true; };
}, [missionId]);
if (loading) return <div className="loading">Carregando missão...</div>;
if (error) return <div className="error-banner">{error}</div>;
if (!mission) return <div className="error-banner">Missão não encontrada</div>;
return (
<div>
<button className="btn btn-secondary" onClick={onBack} style={{ marginBottom: 16 }}>
← Voltar
</button>
<div className="card">
<div className="card-header">
<h1>{mission.titleIt}</h1>
<span className={`trail-badge trail-${mission.trail}`}>{mission.trail}</span>
</div>
<p className="text-muted">{mission.titlePt}</p>
<p className="text-muted">CEFR: {mission.cefr}</p>
</div>
{/* Can-dos */}
<div className="card">
<h2>🎯 Objetivos</h2>
{mission.canDos.map((cd) => (
<p key={cd.id} style={{ paddingLeft: 8 }}>
☐ {cd.text}
</p>
))}
</div>
{/* Chunks */}
<div className="card">
<h2>📦 Frases-chave</h2>
{mission.chunks.map((chunk: MissionChunk, i: number) => (
<div key={i} className="chunk-item">
<AudioPlayer audioKey={chunk.audioKey} label={`Tocar: ${chunk.it}`} />
<div className="chunk-text">
<div className="it">{chunk.it}</div>
<div className="pt">{chunk.pt}</div>
{chunk.note && <div className="text-muted" style={{ fontSize: "0.8rem" }}>{chunk.note}</div>}
</div>
</div>
))}
</div>
{/* Diálogos */}
{mission.dialogues.map((dialogue: DialogueLine[], dIdx: number) => (
<div key={dIdx} className="card">
<h2>💬 Diálogo {dIdx + 1}</h2>
{dialogue.map((line: DialogueLine, lIdx: number) => (
<div key={lIdx} className={`dialogue-line ${line.speaker}`}>
<AudioPlayer audioKey={line.audioKey} label={`Tocar fala de ${line.speaker}`} />
<div className="dialogue-bubble">
<div className="text-it">{line.it}</div>
<div className="text-muted" style={{ fontSize: "0.85rem" }}>{line.pt}</div>
</div>
</div>
))}
</div>
))}
{/* Cabine de voz */}
<div className="card">
<h2>🎙️ Pratique falando</h2>
<VoiceBooth
missionId={mission.id}
scenario={mission.roleplay.settingIt}
goalPt={mission.roleplay.goalPt}
expectedIt={mission.roleplay.openerIt}
onComplete={(v) => setVerdict(v)}
/>
</div>
{/* Feedback */}
{verdict && (
<div className="card">
<h2>📊 Feedback</h2>
<FeedbackPanel verdict={verdict} missionId={mission.id} />
</div>
)}
{/* Survival checklist */}
{mission.survivalChecklist.length > 0 && (
<div className="card">
<h2>🆘 Checklist de Sobrevivência</h2>
{mission.survivalChecklist.map((item, i) => (
<p key={i} style={{ paddingLeft: 8 }}>• {item}</p>
))}
</div>
)}
</div>
);
}
// ─── Revisão SRS ───
function ReviewView() {
const cards = useLiveQuery(() => db.srsCards.toArray()) ?? [];
const dueCards = getDueCards(cards);
const [currentIdx, setCurrentIdx] = useState(0);
const [showAnswer, setShowAnswer] = useState(false);
const [selectedRating, setSelectedRating] = useState<number | null>(null);
const currentCard = dueCards[currentIdx];
const handleRate = async (quality: number) => {
if (!currentCard) return;
const updated = srsReview(currentCard, quality);
await db.srsCards.put(updated);
setSelectedRating(null);
setShowAnswer(false);
setCurrentIdx((prev) => prev + 1);
};
if (dueCards.length === 0) {
return (
<div className="card" style={{ textAlign: "center", padding: 40 }}>
<h2>✨ Tudo em dia!</h2>
<p className="text-muted">Nenhum cartão para revisar agora.</p>
<p className="text-muted">Volte mais tarde ou pratique novas missões.</p>
</div>
);
}
if (currentIdx >= dueCards.length) {
return (
<div className="card" style={{ textAlign: "center", padding: 40 }}>
<h2>🎉 Revisão completa!</h2>
<p className="text-muted">Você revisou {dueCards.length} cartões.</p>
</div>
);
}
// Narrowing: após os guards acima, currentCard é garantido definido
if (!currentCard) return null;
return (
<div>
<p className="text-muted" style={{ marginBottom: 12 }}>
Cartão {currentIdx + 1} de {dueCards.length}
</p>
<div className="flashcard" onClick={() => !showAnswer && setShowAnswer(true)}>
<div className="flashcard-inner">
<div className="it">{currentCard.chunkIt}</div>
{showAnswer && (
<>
<div className="pt" style={{ marginTop: 16 }}>{currentCard.chunkPt}</div>
{currentCard.audioKey && (
<div style={{ marginTop: 12 }} onClick={(e) => e.stopPropagation()}>
<AudioPlayer audioKey={currentCard.audioKey} />
</div>
)}
</>
)}
{!showAnswer && (
<p className="text-muted" style={{ marginTop: 16, fontSize: "0.85rem" }}>
Toque para revelar
</p>
)}
</div>
</div>
{showAnswer && (
<div>
<p className="text-muted" style={{ textAlign: "center", marginTop: 12 }}>
Como foi?
</p>
<div className="rating-buttons">
{[1, 2, 3, 4, 5].map((q) => (
<button
key={q}
className={`rating-btn ${selectedRating === q ? "selected" : ""}`}
onClick={() => handleRate(q)}
>
{q}
</button>
))}
</div>
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 4 }}>
<span className="text-muted" style={{ fontSize: "0.75rem" }}>Errei</span>
<span className="text-muted" style={{ fontSize: "0.75rem" }}>Perfeito</span>
</div>
</div>
)}
</div>
);
}
// ─── App Principal ───
export function App() {
const [view, setView] = useState<ViewState>("home");
const [selectedMissionId, setSelectedMissionId] = useState<string | null>(null);
const handleSelectMission = (id: string) => {
setSelectedMissionId(id);
setView("mission");
};
const handleBack = () => {
setSelectedMissionId(null);
setView("home");
};
return (
<div>
<header className="app-header">
<ItalianFlag />
<h1>Italiano Vivo</h1>
</header>
<nav className="nav-bar">
<button
className={`nav-tab ${view === "home" ? "active" : ""}`}
onClick={() => setView("home")}
>
🏠 Missões
</button>
<button
className={`nav-tab ${view === "review" ? "active" : ""}`}
onClick={() => setView("review")}
>
🔄 Revisão
</button>
</nav>
{view === "home" && <HomeView onSelectMission={handleSelectMission} />}
{view === "mission" && selectedMissionId && (
<MissionView missionId={selectedMissionId} onBack={handleBack} />
)}
{view === "review" && <ReviewView />}
</div>
);
}

@ -0,0 +1,83 @@
/**
* Banco de dados IndexedDB via Dexie.
* Armazena progresso, tentativas, cartões SRS, log de resgate e configurações.
*/
import Dexie, { type EntityTable } from "dexie";
export interface ProgressRecord {
missionId: string;
completedAt?: number;
bestMeaningScore?: "full" | "partial" | "failed";
attemptsCount: number;
}
export interface AttemptRecord {
id?: number;
missionId: string;
ts: number;
text: string;
verdict: {
meaning_conveyed: "full" | "partial" | "failed";
naturalness: number;
natural_version: string;
grammar: Array<{
it_said: string;
problem_pt: string;
fix_it: string;
severity: "blocks_understanding" | "minor" | "cosmetic";
}>;
praise_or_push: string;
};
/** Se o aluno marcou "isso estava certo" (dissidência) */
dissent?: boolean;
}
export interface SRSCard {
id: string;
chunkIt: string;
chunkPt: string;
audioKey?: string;
box: number;
interval: number;
due: number;
lastSeen: number;
easeFactor: number;
}
export interface RescueLogEntry {
id?: number;
missionId: string;
ts: number;
rescueCard: {
come_si_dice: string;
frase_it: string;
explicacao_pt: string;
};
used: boolean;
}
export interface AppSettings {
key: string;
value: string;
}
class ItalianoVivoDB extends Dexie {
progress!: EntityTable<ProgressRecord, "missionId">;
attempts!: EntityTable<AttemptRecord, "id">;
srsCards!: EntityTable<SRSCard, "id">;
rescueLog!: EntityTable<RescueLogEntry, "id">;
settings!: EntityTable<AppSettings, "key">;
constructor() {
super("ItalianoVivo");
this.version(1).stores({
progress: "missionId",
attempts: "++id, missionId, ts",
srsCards: "id, due, box",
rescueLog: "++id, missionId, ts",
settings: "key",
});
}
}
export const db = new ItalianoVivoDB();

@ -0,0 +1,112 @@
/**
* SM-2 simplificado para revisão espaçada de chunks.
* Qualidade 0-5 derivada do veredito:
* full = 4, partial = 2, failed = 1
* + bônus se o aluno regravou (tentou de novo)
*/
import type { SRSCard } from "../db/db.js";
/** Intervalos iniciais em dias por box (SM-2 simplificado) */
const BASE_INTERVALS = [0, 1, 3, 7, 14, 30, 60];
export interface ReviewInput {
meaningConveyed: "full" | "partial" | "failed";
reRecorded: boolean;
}
/** Converte veredito do judge em qualidade SM-2 (0-5) */
export function verdictToQuality(input: ReviewInput): number {
let quality: number;
switch (input.meaningConveyed) {
case "full":
quality = 4;
break;
case "partial":
quality = 2;
break;
case "failed":
quality = 1;
break;
}
// Bônus por re-gravação: mostra esforço e engajamento
if (input.reRecorded && quality < 5) {
quality = Math.min(quality + 1, 5);
}
return quality;
}
/**
* Calcula o próximo estado do cartão SRS após uma revisão.
* Função pura — sem efeitos colaterais.
*/
export function review(card: SRSCard, quality: number): SRSCard {
const now = Date.now();
const DAY_MS = 86_400_000;
let { box, interval, easeFactor } = card;
if (quality < 3) {
// Falhou: resetar para box 0
box = 0;
interval = 0;
} else {
// Sucesso: avançar box
box = Math.min(box + 1, BASE_INTERVALS.length - 1);
// Calcular novo intervalo
if (box === 1) {
interval = 1;
} else if (box === 2) {
interval = 3;
} else {
// SM-2: I(n) = I(n-1) * EF
interval = Math.round(interval * easeFactor);
}
// Ajustar ease factor baseado na qualidade
// EF' = EF + (0.1 - (5 - q) * (0.08 + (5 - q) * 0.02))
easeFactor = easeFactor + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02));
easeFactor = Math.max(easeFactor, 1.3); // Mínimo 1.3
}
const due = now + interval * DAY_MS;
return {
...card,
box,
interval,
easeFactor,
due,
lastSeen: now,
};
}
/** Cria um novo cartão SRS para um chunk */
export function createCard(params: {
id: string;
chunkIt: string;
chunkPt: string;
audioKey?: string;
}): SRSCard {
return {
id: params.id,
chunkIt: params.chunkIt,
chunkPt: params.chunkPt,
audioKey: params.audioKey,
box: 0,
interval: 0,
due: Date.now(), // Disponível imediatamente
lastSeen: 0,
easeFactor: 2.5, // Ease factor inicial padrão SM-2
};
}
/** Retorna cartões vencidos (due <= agora) */
export function getDueCards(cards: SRSCard[]): SRSCard[] {
const now = Date.now();
return cards.filter((c) => c.due <= now).sort((a, b) => a.due - b.due);
}

@ -0,0 +1,27 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App.js";
import "./styles.css";
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("Elemento #root não encontrado");
createRoot(rootEl).render(
<StrictMode>
<App />
</StrictMode>,
);
// Service Worker: só em produção para modo resgate offline
if ("serviceWorker" in navigator && import.meta.env.PROD) {
window.addEventListener("load", () => {
navigator.serviceWorker
.register("/sw.js")
.then((reg) => {
console.log("[SW] Registrado com sucesso, scope:", reg.scope);
})
.catch((err) => {
console.warn("[SW] Falha ao registrar:", err);
});
});
}

@ -0,0 +1,434 @@
/* Italiano Vivo — Design System */
:root {
/* Cores da bandeira italiana + neutros */
--verde: #009246;
--bianco: #ffffff;
--rosso: #ce2b37;
--bg: #f8f9fa;
--surface: #ffffff;
--text: #1a1a2e;
--text-muted: #6c757d;
--border: #e2e8f0;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
--radius: 12px;
--font: "Segoe UI", system-ui, -apple-system, sans-serif;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font);
background: var(--bg);
color: var(--text);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
#root {
max-width: 480px;
margin: 0 auto;
min-height: 100vh;
padding: 16px;
}
/* Tipografia */
h1 {
font-size: 1.75rem;
font-weight: 700;
margin-bottom: 8px;
}
h2 {
font-size: 1.35rem;
font-weight: 600;
margin-bottom: 12px;
}
h3 {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 8px;
}
p {
margin-bottom: 8px;
}
.text-muted {
color: var(--text-muted);
font-size: 0.9rem;
}
.text-it {
font-style: italic;
color: var(--verde);
}
/* Cards */
.card {
background: var(--surface);
border-radius: var(--radius);
padding: 16px;
margin-bottom: 12px;
box-shadow: var(--shadow);
border: 1px solid var(--border);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
/* Botões */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 20px;
border: none;
border-radius: var(--radius);
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
}
.btn:active {
transform: scale(0.97);
}
.btn-primary {
background: var(--verde);
color: var(--bianco);
}
.btn-primary:hover {
background: #007a3a;
}
.btn-secondary {
background: var(--border);
color: var(--text);
}
.btn-secondary:hover {
background: #d1d5db;
}
.btn-danger {
background: var(--rosso);
color: var(--bianco);
}
.btn-record {
width: 64px;
height: 64px;
border-radius: 50%;
background: var(--rosso);
color: white;
font-size: 1.5rem;
border: 3px solid #ff6b7a;
}
.btn-record.recording {
animation: pulse 1.2s infinite;
background: #ff4757;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.08); }
}
.btn-play {
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--verde);
color: white;
font-size: 1.2rem;
flex-shrink: 0;
}
/* Trail badges */
.trail-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.trail-bambino { background: #d4edda; color: #155724; }
.trail-viaggio { background: #cce5ff; color: #004085; }
.trail-vivere { background: #fff3cd; color: #856404; }
/* Feedback cards */
.feedback-axis {
padding: 12px;
border-left: 4px solid var(--border);
margin-bottom: 8px;
background: var(--bg);
border-radius: 0 var(--radius) var(--radius) 0;
}
.feedback-axis.success { border-left-color: var(--verde); }
.feedback-axis.warning { border-left-color: #ffc107; }
.feedback-axis.error { border-left-color: var(--rosso); }
.natural-version {
background: #e8f5e9;
padding: 10px 14px;
border-radius: var(--radius);
margin: 8px 0;
font-size: 1.05rem;
}
.natural-version::before {
content: "🇮🇹 Um italiano diria: ";
font-weight: 700;
font-size: 0.85rem;
display: block;
margin-bottom: 4px;
color: var(--verde);
}
/* Voice booth */
.voice-booth {
text-align: center;
padding: 24px 16px;
background: linear-gradient(135deg, #f8f9fa, #e8f5e9);
border-radius: var(--radius);
margin: 16px 0;
}
.voice-booth p {
margin-bottom: 16px;
font-size: 1.1rem;
}
/* Grammar errors list */
.grammar-error {
padding: 8px 12px;
background: #fff3cd;
border-radius: 8px;
margin-bottom: 6px;
font-size: 0.9rem;
}
.grammar-error .fix {
color: var(--verde);
font-weight: 600;
}
.grammar-error.blocks_understanding {
background: #f8d7da;
}
/* SRS flashcard */
.flashcard {
perspective: 1000px;
min-height: 200px;
cursor: pointer;
}
.flashcard-inner {
background: var(--surface);
border-radius: var(--radius);
padding: 32px 24px;
text-align: center;
box-shadow: var(--shadow);
border: 2px solid var(--border);
min-height: 200px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.flashcard-inner .it {
font-size: 1.4rem;
font-weight: 600;
color: var(--verde);
margin-bottom: 8px;
}
.flashcard-inner .pt {
font-size: 1rem;
color: var(--text-muted);
}
.rating-buttons {
display: flex;
gap: 8px;
justify-content: center;
margin-top: 16px;
}
.rating-btn {
width: 44px;
height: 44px;
border-radius: 50%;
border: 2px solid var(--border);
background: var(--surface);
font-weight: 700;
cursor: pointer;
transition: all 0.15s;
}
.rating-btn:hover { border-color: var(--verde); color: var(--verde); }
.rating-btn.selected { background: var(--verde); color: white; border-color: var(--verde); }
/* Loading & Error states */
.loading {
text-align: center;
padding: 40px;
color: var(--text-muted);
}
.error-banner {
background: #f8d7da;
color: #721c24;
padding: 12px 16px;
border-radius: var(--radius);
margin-bottom: 16px;
}
/* Navigation */
.nav-bar {
display: flex;
gap: 8px;
margin-bottom: 20px;
overflow-x: auto;
padding-bottom: 4px;
}
.nav-tab {
padding: 8px 16px;
border-radius: 20px;
border: 1px solid var(--border);
background: var(--surface);
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: all 0.2s;
}
.nav-tab.active {
background: var(--verde);
color: white;
border-color: var(--verde);
}
/* Chunk list */
.chunk-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-bottom: 1px solid var(--border);
}
.chunk-item:last-child { border-bottom: none; }
.chunk-text {
flex: 1;
}
.chunk-text .it {
font-weight: 600;
color: var(--verde);
}
.chunk-text .pt {
font-size: 0.85rem;
color: var(--text-muted);
}
/* Dialogue */
.dialogue-line {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 10px 0;
}
.dialogue-line.learner {
flex-direction: row-reverse;
}
.dialogue-bubble {
max-width: 80%;
padding: 10px 14px;
border-radius: 16px;
font-size: 0.95rem;
}
.dialogue-line.italian .dialogue-bubble {
background: #e8f5e9;
border-bottom-left-radius: 4px;
}
.dialogue-line.learner .dialogue-bubble {
background: #e3f2fd;
border-bottom-right-radius: 4px;
}
/* Dissent button */
.dissent-btn {
margin-top: 12px;
padding: 8px 16px;
background: transparent;
border: 1px dashed var(--text-muted);
border-radius: 8px;
color: var(--text-muted);
font-size: 0.85rem;
cursor: pointer;
}
.dissent-btn:hover {
border-color: var(--verde);
color: var(--verde);
}
/* Header */
.app-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 2px solid var(--verde);
}
.app-header h1 {
margin: 0;
font-size: 1.5rem;
}
.flag {
display: inline-flex;
width: 32px;
height: 22px;
border-radius: 3px;
overflow: hidden;
}
.flag span {
flex: 1;
}
.flag .green { background: var(--verde); }
.flag .white { background: var(--bianco); border: 1px solid #ddd; }
.flag .red { background: var(--rosso); }

@ -0,0 +1,80 @@
/**
* Tipos TypeScript para o conteúdo JSON versionado.
* Gerado pelo pipeline em content/, consumido pelo app.
*/
/** Um chunk lexical — unidade mínima de aprendizado */
export interface MissionChunk {
/** Frase em italiano */
it: string;
/** Tradução em português */
pt: string;
/** Nota gramatical ou cultural opcional */
note?: string;
/** Chave do arquivo de áudio (sem extensão): audio/{audioKey}.mp3 */
audioKey: string;
}
/** Declaração can-do do CEFR */
export interface CanDo {
id: string;
text: string;
}
/** Cartão de resgate (salvavita) */
export interface RescueCard {
come_si_dice: string;
frase_it: string;
explicacao_pt: string;
}
/** Linha de diálogo */
export interface DialogueLine {
speaker: "italian" | "learner";
it: string;
pt: string;
audioKey: string;
}
/** Trilhas do app */
export type Trail = "bambino" | "viaggio" | "vivere";
/** Uma missão completa */
export interface Mission {
id: string;
trail: Trail;
cefr: string;
titleIt: string;
titlePt: string;
canDos: CanDo[];
chunks: MissionChunk[];
dialogues: DialogueLine[][];
roleplay: {
settingIt: string;
italianRole: string;
goalPt: string;
openerIt: string;
};
survivalChecklist: string[];
}
/** Bundle de conteúdo versionado */
export interface ContentBundle {
schemaVersion: 1;
missions: Mission[];
}
/** Índice leve carregado na home (sem chunks/diálogos pesados) */
export interface MissionSummary {
id: string;
trail: Trail;
cefr: string;
titleIt: string;
titlePt: string;
canDos: CanDo[];
}
export interface ContentIndex {
schemaVersion: 1;
missions: MissionSummary[];
}

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"noUncheckedIndexedAccess": true,
"types": ["vite/client"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "dist"]
}

@ -0,0 +1 @@
{"root":["./src/db/db.ts","./src/engine/srs.ts","./src/types/content.ts","./src/app.tsx","./src/main.tsx"],"version":"5.9.3"}

@ -0,0 +1,27 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": {
target: "http://localhost:8787",
changeOrigin: true,
},
"/audio": {
target: "http://localhost:8787",
changeOrigin: true,
},
"/content": {
target: "http://localhost:8787",
changeOrigin: true,
},
},
},
build: {
outDir: "dist",
sourcemap: true,
},
});

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save