| name | mira-quiz |
| description | Slide de QUIZ AO VIVO no Mira: a plateia escaneia um QR-code e responde uma múltipla escolha num Google Forms; o slide lê as respostas via Google Sheets gviz/JSONP. Diferente do mira-survey, tem resposta correta, revelação controlada pelo apresentador e percentuais que só aparecem após revelar. Use SEMPRE que o usuário disser /mira-quiz, quiz ao vivo, pergunta com resposta correta, revelar resposta, ranking do quiz, quiz com QR, quiz tipo Mentimeter, quiz tipo Slido, ou pedir um slide onde a plateia responde pelo celular e a resposta correta é revelada no palco. Para enquete sem resposta correta use /mira-survey; para QR simples use /mira-qrcode. |
Skill: Quiz ao vivo no slide
Fonte da verdade: decisões congeladas em BRAINSTORM_MIRA_QUIZ.md (2026-06-30). O /mira-quiz reaproveita a arquitetura validada do mira-survey: Google Forms como interface, Google Sheets como fonte viva e leitura pelo endpoint gviz com JSONP para funcionar por file://.
Modelo mental
O Mira não hospeda o jogo. Ele monta um slide que lê respostas já coletadas pelo Google Forms:
- A plateia abre o link de votação pelo QR-code.
- Cada envio cai na planilha de respostas vinculada ao Forms.
- O slide lê a planilha a cada poucos segundos pelo
gviz.
- Antes da revelação, mostra pergunta, QR, alternativas e total de respostas.
- Depois da revelação, marca a correta em verde e mostra contagem + percentual por alternativa.
Dados obrigatórios
Confirme estes dados antes de gerar o slide. Se faltar qualquer item, pergunte e pare.
| Dado | Exemplo | Uso |
|---|
| Link de votação | forms.gle/... ou docs.google.com/forms/... | vira QR-code inline |
| Link da planilha | docs.google.com/spreadsheets/d/<ID>/... | fonte viva de respostas |
| Pergunta | Qual destes é um modelo multimodal? | título do quiz |
| Alternativas | GPT-4o, Excel, Photoshop, PowerPoint | cards de resposta |
| Correta | GPT-4o | destaque na revelação |
| Campo de nome | primeira coluna textual depois do timestamp, ou índice informado | ranking básico |
Texto sugerido se faltar algo:
Para montar o quiz ao vivo eu preciso do link de votação do Google Forms, do link da planilha de respostas, da pergunta, das alternativas e da resposta correta. A planilha precisa estar pública como "qualquer pessoa com o link -> Leitor". Pode colar esses dados aqui?
Formulário esperado
O Google Forms deve ter, no mínimo:
- Um campo de identificação (nome ou apelido).
- Uma pergunta de múltipla escolha com as mesmas alternativas do slide.
Na planilha, a skill assume (salvo se o usuário indicar outro cabeçalho):
- A última coluna é a resposta do quiz.
- A primeira coluna textual depois do timestamp é o nome/apelido.
Se a planilha tiver várias perguntas, avise que o slide usará a última coluna e peça confirmação.
Verificação da planilha
Extraia o SHEET_ID do link da planilha com /spreadsheets/d/<ID>/ e teste o mesmo endpoint que o slide usará:
curl -sL "https://docs.google.com/spreadsheets/d/<SHEET_ID>/gviz/tq?tqx=out:json" | head -c 800
Se vier google.visualization.Query.setResponse({...}) com "status":"ok", a planilha está legível. Se vier HTML de login ou erro, peça para o usuário ajustar o compartilhamento para qualquer pessoa com o link -> Leitor.
Nunca use "Publicar na web -> CSV". Esse endpoint pode ficar cacheado por minutos. O quiz ao vivo usa só gviz + JSONP.
QR-code local
O QR do link de votação é gerado localmente e embutido como SVG inline, igual ao /mira-qrcode e ao /mira-survey. Não use npx qrcode, API externa nem CDN.
- Instale o pacote uma vez numa pasta temp reaproveitável, se ainda não existir:
npm install qrcode --no-save --prefix "<pasta-temp>"
- Gere o SVG:
node -e "require('qrcode').toString('LINK_VOTACAO',{type:'svg',errorCorrectionLevel:'M',margin:0,color:{dark:'#0a0a0a',light:'#ffffff'}},(e,s)=>{if(e)throw e;process.stdout.write(s)})"
- Cole o
<svg> inteiro dentro de .qr-card, com o comentário:
O link de votação não aparece por extenso no slide.
Estados
O slide tem três estados:
votando: mostra pergunta, alternativas neutras, QR-code, total de respostas e status ao vivo. Não mostra percentuais.
revelando: pausa curta, cards respiram e incorretas reduzem brilho.
revelado: correta em verde #35D07F, contagens e percentuais visíveis, barras proporcionais preenchidas.
Comandos:
- Botão discreto "Revelar".
- Tecla
R para revelar.
- Tecla
K para alternar ranking depois da revelação.
- Tecla
V para voltar ao estado de votação durante testes.
Direção visual
Padrão da v1: cards de alternativas com barras internas. Melhor formato para esconder porcentagens antes da revelação e destacar a correta depois.
- Fundo escuro Mira, laranja
#FF904D como identidade.
- Correta em verde
#35D07F, fundo translúcido e halo leve.
- Incorretas continuam legíveis, com menos destaque após revelar.
- Check discreto na correta.
- Barras aparecem só depois da revelação.
- Animação elegante, sem partículas e sem atrapalhar a leitura em projeção.
- A Regra Zero do Mira continua valendo (loop interno contínuo): a bolinha "ao vivo", o brilho do QR e a respiração leve dos cards mantêm o slide vivo.
Template canônico
Gere o slide a partir deste HTML e preencha apenas:
SHEET_ID
QUESTION
OPTIONS
CORRECT
ANSWER_HEADER, se necessário
NAME_HEADER, se necessário
- SVG inline do QR dentro de
.qr-card
Salve em decks/<nome-do-quiz>/index.html.
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mira - Quiz ao vivo</title>
<style>
:root{
--bg:#15151a; --panel:#1d1d24; --panel2:#24242d; --ink:#f4f4f6;
--muted:#9a9aa6; --line:#30303a; --orange:#FF904D; --green:#35D07F;
--green-soft:rgba(53,208,127,.16); --green-line:rgba(53,208,127,.68);
}
*{box-sizing:border-box; margin:0; padding:0;}
html,body{height:100%;}
body{background:radial-gradient(circle at 18% 12%, rgba(255,144,77,.12), transparent 32%), var(--bg); color:var(--ink); font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif; overflow:hidden;}
.stage{height:100vh; display:grid; grid-template-columns:1fr 450px; gap:42px; padding:46px 56px; align-items:stretch;}
.main{min-width:0; display:flex; flex-direction:column; gap:26px;}
.top{display:flex; align-items:flex-start; justify-content:space-between; gap:28px;}
.eyebrow{color:var(--orange); font-size:18px; font-weight:800; letter-spacing:.12em; text-transform:uppercase; margin-bottom:12px;}
.question{font-size:43px; line-height:1.12; font-weight:800; max-width:980px;}
.status{display:flex; align-items:center; justify-content:flex-end; gap:10px; color:var(--muted); font-size:18px; white-space:nowrap; padding-top:10px;}
.dot{width:11px; height:11px; border-radius:50%; background:#39d353; box-shadow:0 0 0 0 rgba(57,211,83,.6); animation:pulse 1.6s infinite;}
.dot.off{background:#888; animation:none; box-shadow:none;}
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(57,211,83,.55)}70%{box-shadow:0 0 0 12px rgba(57,211,83,0)}100%{box-shadow:0 0 0 0 rgba(57,211,83,0)}}
.options{flex:1; display:grid; grid-template-columns:1fr 1fr; gap:18px; align-content:center;}
.option{position:relative; min-height:142px; overflow:hidden; border:1px solid var(--line); border-radius:22px; background:rgba(29,29,36,.88); padding:24px; display:flex; flex-direction:column; justify-content:space-between; transition:transform .45s ease, opacity .45s ease, border-color .45s ease, background .45s ease, box-shadow .45s ease; animation:card-breathe 5s ease-in-out infinite;}
.option:nth-child(2){animation-delay:.35s}.option:nth-child(3){animation-delay:.7s}.option:nth-child(4){animation-delay:1.05s}
@keyframes card-breathe{0%,100%{transform:translateY(0)}50%{transform:translateY(-3px)}}
.state-revealing .option{animation:suspense .42s ease-in-out 2;}
@keyframes suspense{0%,100%{transform:translateX(0) scale(1)}25%{transform:translateX(-4px) scale(1.01)}75%{transform:translateX(4px) scale(1.01)}}
.state-revealed .option{animation:none;}
.state-revealed .option.wrong{opacity:.52; transform:scale(.985);}
.state-revealed .option.correct{background:linear-gradient(135deg, var(--green-soft), rgba(29,29,36,.92)); border-color:var(--green-line); box-shadow:0 0 0 1px rgba(53,208,127,.25), 0 18px 70px rgba(53,208,127,.16); transform:scale(1.025);}
.fill{position:absolute; inset:auto 0 0 0; height:0; background:linear-gradient(90deg, rgba(53,208,127,.36), rgba(53,208,127,.10)); transition:height 900ms cubic-bezier(.2,.8,.2,1); pointer-events:none;}
.wrong .fill{background:linear-gradient(90deg, rgba(255,255,255,.13), rgba(255,255,255,.04));}
.state-revealed .fill{height:var(--pct);}
.option-main{position:relative; z-index:1; display:flex; align-items:flex-start; gap:16px;}
.letter{width:38px; height:38px; border-radius:12px; display:grid; place-items:center; background:rgba(255,144,77,.15); color:var(--orange); font-weight:800; flex:0 0 auto;}
.text{font-size:31px; line-height:1.14; font-weight:750;}
.check{margin-left:auto; width:34px; height:34px; border-radius:50%; display:grid; place-items:center; color:#08150d; background:var(--green); opacity:0; transform:scale(.65); transition:opacity .3s ease, transform .45s cubic-bezier(.2,1.3,.4,1);}
.state-revealed .correct .check{opacity:1; transform:scale(1);}
.meta{position:relative; z-index:1; display:flex; align-items:center; justify-content:space-between; color:var(--muted); font-size:22px; font-variant-numeric:tabular-nums; opacity:0; transform:translateY(8px); transition:opacity .35s ease, transform .35s ease;}
.state-revealed .meta{opacity:1; transform:translateY(0);}
.meta b{color:var(--ink);}
.side{background:rgba(29,29,36,.92); border:1px solid var(--line); border-radius:28px; padding:34px 30px; display:flex; flex-direction:column; align-items:center; justify-content:space-between; min-width:0;}
.qr-block{text-align:center; display:flex; flex-direction:column; align-items:center; gap:18px; padding-top:18px;}
.qr-block h2{font-size:35px; line-height:1.05;}
.accent{color:var(--orange);}
.qr-block p{color:var(--muted); font-size:20px; line-height:1.35;}
.qr-card{background:#fff; padding:17px; border-radius:20px; line-height:0; box-shadow:0 0 34px rgba(255,144,77,.16); animation:qr-glow 3.2s ease-in-out infinite;}
.qr-card svg{width:338px; height:338px; display:block;}
@keyframes qr-glow{0%,100%{box-shadow:0 0 26px rgba(255,144,77,.18)}50%{box-shadow:0 0 52px rgba(255,144,77,.34)}}
.controls{width:100%; display:flex; flex-direction:column; gap:14px;}
.total{display:flex; justify-content:space-between; align-items:baseline; padding:17px 18px; background:rgba(255,255,255,.04); border:1px solid var(--line); border-radius:18px;}
.total span{color:var(--muted); font-size:17px;}
.total b{font-size:42px; line-height:1;}
.actions{display:flex; gap:10px;}
button{appearance:none; border:1px solid var(--line); border-radius:14px; background:var(--panel2); color:var(--ink); padding:13px 14px; font-size:16px; font-weight:800; cursor:pointer; flex:1;}
button.primary{background:var(--orange); color:#23140d; border-color:transparent;}
button:focus-visible{outline:3px solid rgba(255,144,77,.4); outline-offset:2px;}
.hint{color:var(--muted); text-align:center; font-size:14px;}
.ranking{position:absolute; inset:46px 56px; display:none; grid-template-columns:1fr 450px; gap:42px; background:rgba(21,21,26,.96);}
.ranking.show{display:grid; animation:rank-in .45s ease both;}
@keyframes rank-in{from{opacity:0; transform:translateY(18px)}to{opacity:1; transform:translateY(0)}}
.rank-panel{grid-column:1 / 2; align-self:center;}
.rank-title{font-size:50px; font-weight:850; margin-bottom:24px;}
.rank-list{display:flex; flex-direction:column; gap:14px; max-width:860px;}
.rank-item{display:grid; grid-template-columns:72px 1fr auto; gap:18px; align-items:center; background:rgba(29,29,36,.92); border:1px solid var(--line); border-radius:18px; padding:18px 22px; animation:rank-card .5s ease both;}
.rank-item:nth-child(2){animation-delay:.06s}.rank-item:nth-child(3){animation-delay:.12s}.rank-item:nth-child(4){animation-delay:.18s}.rank-item:nth-child(5){animation-delay:.24s}
@keyframes rank-card{from{opacity:0; transform:translateY(20px)}to{opacity:1; transform:translateY(0)}}
.rank-pos{font-size:28px; color:var(--orange); font-weight:850;}
.rank-name{font-size:30px; font-weight:750;}
.rank-score{font-size:19px; color:var(--muted);}
.rank-empty{color:var(--muted); font-size:28px;}
.rank-side{grid-column:2 / 3; align-self:stretch; display:flex; align-items:center; justify-content:center; color:var(--muted); text-align:center; font-size:22px; line-height:1.4;}
</style>
</head>
<body class="state-voting">
<main class="stage">
<section class="main">
<div class="top">
<div>
<div class="eyebrow">Quiz ao vivo</div>
<h1 class="question" id="question">Qual destes é um modelo multimodal?</h1>
</div>
<div class="status"><span class="dot" id="dot"></span><span id="status">conectando...</span></div>
</div>
<div class="options" id="options"></div>
</section>
<aside class="side">
<div class="qr-block">
<h2>Responda <span class="accent">agora</span></h2>
<p>Aponte a câmera do celular<br>para o QR-code</p>
<div class="qr-card">
</div>
</div>
<div class="controls">
<div class="total"><span>respostas recebidas</span><b id="total">0</b></div>
<div class="actions">
<button class="primary" id="revealBtn">Revelar</button>
<button id="rankBtn">Ranking</button>
</div>
<div class="hint">R revela · K ranking · V votar</div>
</div>
</aside>
</main>
<section class="ranking" id="ranking">
<div class="rank-panel">
<div class="eyebrow">Participantes que acertaram</div>
<h2 class="rank-title">Ranking</h2>
<div class="rank-list" id="rankList"></div>
</div>
<div class="rank-side">Pressione K para voltar ao resultado.</div>
</section>
<script>
var SHEET_ID = "COLE_O_SHEET_ID_AQUI";
var QUESTION = "Qual destes é um modelo multimodal?";
var OPTIONS = ["GPT-4o", "Excel", "Photoshop", "PowerPoint"];
var CORRECT = "GPT-4o";
var ANSWER_HEADER = "";
var NAME_HEADER = "";
var INTERVALO = 3000;
var counts = {}, displayed = {}, total = 0, rowsCache = [], revealed = false, rankingOpen = false;
OPTIONS.forEach(function(o){ counts[o]=0; displayed[o]=0; });
document.getElementById("question").textContent = QUESTION;
function poll(){
var s = document.createElement("script");
s.src = "https://docs.google.com/spreadsheets/d/" + SHEET_ID +
"/gviz/tq?tqx=out:json;responseHandler:miraQuizPoll&headers=1&_=" + Date.now();
s.onerror = function(){ setStatus(false); s.remove(); };
s.onload = function(){ s.remove(); };
document.body.appendChild(s);
}
window.miraQuizPoll = function(resp){
if(!resp || !resp.table){ setStatus(false); return; }
var table = resp.table, cols = table.cols || [], rows = table.rows || [];
var answerIdx = findColumn(cols, ANSWER_HEADER);
if(answerIdx < 0) answerIdx = cols.length - 1;
var nameIdx = findColumn(cols, NAME_HEADER);
if(nameIdx < 0) nameIdx = detectNameColumn(cols, answerIdx);
var next = {};
OPTIONS.forEach(function(o){ next[o]=0; });
rowsCache = [];
rows.forEach(function(row){
var answer = cellValue(row, answerIdx);
var name = cellValue(row, nameIdx);
if(answer !== ""){
if(!(answer in next)) next[answer] = 0;
next[answer] += 1;
rowsCache.push({name:name || "Participante", answer:answer});
}
});
counts = next;
total = Object.keys(counts).reduce(function(sum,o){ return sum + counts[o]; }, 0);
Object.keys(counts).forEach(function(o){ if(!(o in displayed)) displayed[o]=0; });
setStatus(true);
renderRanking();
};
function findColumn(cols, header){
if(!header) return -1;
var wanted = normalize(header);
for(var i=0;i<cols.length;i++){
if(normalize(cols[i].label || "") === wanted) return i;
}
return -1;
}
function detectNameColumn(cols, answerIdx){
for(var i=1;i<cols.length;i++){
if(i !== answerIdx) return i;
}
return 1;
}
function cellValue(row, idx){
if(idx < 0 || !row || !row.c || !row.c[idx]) return "";
var v = row.c[idx].v;
return v === null || v === undefined ? "" : String(v).trim();
}
function setStatus(ok){
var dot = document.getElementById("dot"), st = document.getElementById("status");
if(ok){ dot.className = "dot"; st.textContent = "ao vivo"; }
else{ dot.className = "dot off"; st.textContent = "sem conexão com a planilha"; }
}
function tick(){
Object.keys(counts).forEach(function(o){
var t = counts[o] || 0, d = displayed[o] || 0;
d = d + (t-d)*0.14;
if(Math.abs(t-d) < 0.01) d = t;
displayed[o] = d;
});
drawOptions();
requestAnimationFrame(tick);
}
function drawOptions(){
document.getElementById("total").textContent = total;
var realTotal = total || 1;
var html = "";
OPTIONS.forEach(function(opt, idx){
var n = counts[opt] || 0;
var pct = Math.round(100 * n / realTotal);
var classes = "option";
if(revealed) classes += opt === CORRECT ? " correct" : " wrong";
html += '<article class="'+classes+'" style="--pct:'+pct+'%">'+
'<div class="fill"></div>'+
'<div class="option-main">'+
'<div class="letter">'+String.fromCharCode(65+idx)+'</div>'+
'<div class="text">'+escapeHtml(opt)+'</div>'+
'<div class="check">✓</div>'+
'</div>'+
'<div class="meta"><span><b>'+n+'</b> votos</span><span>'+pct+'%</span></div>'+
'</article>';
});
document.getElementById("options").innerHTML = html;
}
function reveal(){
if(revealed) return;
document.body.className = "state-revealing";
setTimeout(function(){
revealed = true;
document.body.className = "state-revealed";
renderRanking();
}, 900);
}
function voteMode(){
revealed = false;
rankingOpen = false;
document.body.className = "state-voting";
document.getElementById("ranking").className = "ranking";
}
function toggleRanking(){
if(!revealed) reveal();
rankingOpen = !rankingOpen;
document.getElementById("ranking").className = rankingOpen ? "ranking show" : "ranking";
renderRanking();
}
function renderRanking(){
var hits = rowsCache.filter(function(r){ return normalize(r.answer) === normalize(CORRECT); });
var seen = {}, unique = [];
hits.forEach(function(r){
var key = normalize(r.name);
if(!seen[key]){ seen[key]=1; unique.push(r); }
});
var list = document.getElementById("rankList");
if(!unique.length){ list.innerHTML = '<div class="rank-empty">Aguardando acertos...</div>'; return; }
list.innerHTML = unique.slice(0,5).map(function(r,i){
return '<div class="rank-item"><div class="rank-pos">#'+(i+1)+'</div>'+
'<div class="rank-name">'+escapeHtml(r.name)+'</div>'+
'<div class="rank-score">acertou</div></div>';
}).join("");
}
function normalize(s){
return String(s || "").trim().toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g,"");
}
function escapeHtml(s){
return String(s).replace(/[&<>"']/g, function(c){ return {"&":"&","<":"<",">":">",'"':""","'":"'"}[c]; });
}
document.getElementById("revealBtn").addEventListener("click", reveal);
document.getElementById("rankBtn").addEventListener("click", toggleRanking);
window.addEventListener("keydown", function(ev){
if(ev.key === "r" || ev.key === "R") reveal();
if(ev.key === "k" || ev.key === "K") toggleRanking();
if(ev.key === "v" || ev.key === "V") voteMode();
});
drawOptions();
poll();
setInterval(poll, INTERVALO);
requestAnimationFrame(tick);
</script>
</body>
</html>
Passos
- Confirmar dados obrigatórios. Link de votação, link da planilha, pergunta, alternativas e resposta correta. Se faltar algo, peça e pare.
- Validar a correta. A resposta correta precisa ser exatamente uma das alternativas, ignorando só caixa e acentos para conferência. No HTML, grave o texto canônico da alternativa.
- Extrair
SHEET_ID do link da planilha e verificar o gviz. Se a planilha não estiver pública, peça ajuste.
- Gerar QR localmente com
qrcode e embutir SVG inline. Nunca mostre o link por extenso no slide.
- Montar o slide com o template canônico. Preencha
QUESTION, OPTIONS, CORRECT, SHEET_ID e, se necessário, ANSWER_HEADER e NAME_HEADER.
- Salvar em
decks/<nome-do-quiz>/index.html.
- Reportar o caminho do arquivo, os atalhos (
R, K, V) e lembrar que o slide precisa de internet para ler a planilha, mas abre por duplo clique via file://.
Checklist