Laboratório ao vivo "paralelismo-corrida" do curso Frontends com Vibecoding (capítulo bônus). Código-fonte completo, copiado de src/components/course/blocks/live-labs/ por scripts/export-fvc-labs-txt.mjs. Licença de uso: a mesma do repositório do site. Cada arquivo abaixo começa com uma linha '==== nome ===='. ==== ParalelismoCorridaLab.tsx ==== "use client"; import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; import NumberFlow, { NumberFlowGroup, usePrefersReducedMotion } from "@number-flow/react"; import { gsap } from "gsap"; import type { LiveLabMeta, LiveLabProps } from "./registry"; import styles from "./ParalelismoCorrida.module.css"; /** * Laboratório "paralelismo-corrida": dois painéis para o capítulo bônus. * * Painel 1, "Mil tarefas, N trabalhadores": 1.024 tarefas de CPU idênticas * (contar primos até 48.000 por divisão de tentativa, cerca de 2 ms cada num * notebook comum) distribuídas entre Web Workers reais, criados a partir de um * Blob URL para não depender de configuração do bundler. Cada ladrilho do * canvas ganha a cor do trabalhador que o resolveu; o tempo de parede, as * tarefas por segundo e a aceleração em relação à execução com um trabalhador * aparecem em number-flow. A aceleração para de crescer quando o número de * trabalhadores passa dos núcleos reais do aparelho, e a soma no fim continua * sequencial: é o gargalo que o paralelismo não elimina. * * Painel 2, "Corrida de tokens por segundo": três pistas com uma resposta de * 300 tokens, animadas por uma gsap.timeline pausada que a pessoa reproduz, * pausa ou percorre com um controle deslizante. Cada pista separa a espera * pela primeira palavra (cenário ilustrativo) do ritmo de geração (número * publicado com fonte). Duas escalas de tempo: 60 s reais em 12 s de tela, ou * 1 s real em 12 s de tela, porque na escala humana as máquinas terminam antes * de o olho perceber. * * Regras da casa: cor só por token do tema (resolvida para o canvas em tempo * de execução), alvo de toque de 44 px, foco visível, movimento só quando a * pessoa pede e nunca sozinho sob "reduzir movimento", nenhuma chamada de rede. */ const LADO = 32; const TOTAL_TAREFAS = LADO * LADO; const LIMITE_PRIMOS = 48000; const TAMANHO_CANVAS = 320; const TETO_TRABALHADORES = 16; /* Corpo do worker como texto: vira um Blob URL no navegador. A função é a mesma que roda na thread principal quando não há Worker disponível. */ const CODIGO_WORKER = ` function contarPrimos(limite, sal) { let total = 0; for (let n = 2 + sal; n <= limite + sal; n++) { let primo = true; for (let d = 2; d * d <= n; d++) { if (n % d === 0) { primo = false; break; } } if (primo) total++; } return total; } self.onmessage = (evento) => { const { indice, limite } = evento.data; const resultado = contarPrimos(limite, indice); self.postMessage({ indice, resultado }); }; `; function contarPrimos(limite: number, sal: number): number { let total = 0; for (let n = 2 + sal; n <= limite + sal; n++) { let primo = true; for (let d = 2; d * d <= n; d++) { if (n % d === 0) { primo = false; break; } } if (primo) total++; } return total; } interface Medicao { trabalhadores: number; tempoMs: number; soma: number; } type Rgb = [number, number, number]; /* Tokens do tema que pintam os trabalhadores, na ordem, com o hex de reserva (valores "era #..." do globals.css) para navegador que recuse oklch no canvas. */ const TOKENS_TRABALHADOR: { token: string; reserva: string }[] = [ { token: "--course-accent", reserva: "#0070ca" }, { token: "--success", reserva: "#2e844a" }, { token: "--warning", reserva: "#e58a00" }, { token: "--danger", reserva: "#ba0517" }, ]; /** Lê um token do tema no próprio elemento e o resolve para RGB pintando um pixel. */ function resolverToken(elemento: HTMLElement, ctxSonda: CanvasRenderingContext2D, token: string, reserva: string): Rgb { const estilo = getComputedStyle(elemento); let valor = estilo.getPropertyValue(token).trim(); if (!valor && token === "--course-accent") valor = estilo.getPropertyValue("--accent").trim(); ctxSonda.fillStyle = "#010203"; if (valor) ctxSonda.fillStyle = valor; /* Cor inválida deixa o fillStyle como estava: cai no hex de reserva. */ if (ctxSonda.fillStyle === "#010203") ctxSonda.fillStyle = reserva; ctxSonda.clearRect(0, 0, 1, 1); ctxSonda.fillRect(0, 0, 1, 1); const d = ctxSonda.getImageData(0, 0, 1, 1).data; return [d[0], d[1], d[2]]; } function misturar(a: Rgb, b: Rgb, t: number): Rgb { return [Math.round(a[0] + (b[0] - a[0]) * t), Math.round(a[1] + (b[1] - a[1]) * t), Math.round(a[2] + (b[2] - a[2]) * t)]; } const rgb = (c: Rgb) => `rgb(${c[0]} ${c[1]} ${c[2]})`; interface Paleta { fundo: string; pendente: string; trabalhadores: string[]; } /** Uma cor por trabalhador: quatro tokens puros, depois os mesmos tons clareados por degrau. */ function montarPaleta(elemento: HTMLElement, n: number): Paleta { const sonda = document.createElement("canvas"); sonda.width = 1; sonda.height = 1; const ctx = sonda.getContext("2d", { willReadFrequently: true }); if (!ctx) { return { fundo: "#ffffff", pendente: "#f3f3f3", trabalhadores: Array.from({ length: n }, (_, k) => TOKENS_TRABALHADOR[k % 4].reserva), }; } const fundo = resolverToken(elemento, ctx, "--bg-white", "#ffffff"); const pendente = resolverToken(elemento, ctx, "--bg-muted", "#f3f3f3"); const bases = TOKENS_TRABALHADOR.map((t) => resolverToken(elemento, ctx, t.token, t.reserva)); const trabalhadores = Array.from({ length: n }, (_, k) => rgb(misturar(bases[k % 4], fundo, Math.floor(k / 4) * 0.22))); return { fundo: rgb(fundo), pendente: rgb(pendente), trabalhadores }; } /* ---------- Painel 2: as três pistas da corrida ---------- */ const TOKENS_RESPOSTA = 300; const SEGUNDOS_DE_TELA = 12; interface Pista { id: "humano" | "gpu" | "wafer"; rotulo: string; /** Tokens por segundo publicados. */ taxa: number; /** Espera pela primeira palavra, em segundos (cenário ilustrativo). */ espera: number; fonte: string; } const PISTAS: Pista[] = [ { id: "humano", rotulo: "Leitura humana em silêncio", taxa: 5, espera: 0.6, fonte: "238 palavras por minuto, cerca de 4 palavras por segundo (Brysbaert, 2019, Journal of Memory and Language); 1 palavra vale cerca de 1,3 token, então perto de 5 tokens por segundo.", }, { id: "gpu", rotulo: "GPU especializada (Groq LPU, modelo gpt-oss-120B)", taxa: 480, espera: 0.3, fonte: "Cerca de 480 tokens por segundo, medição independente da Artificial Analysis consultada em 25 de setembro de 2026.", }, { id: "wafer", rotulo: "Chip do tamanho de um wafer (Cerebras, gpt-oss-120B)", taxa: 1700, espera: 0.2, fonte: "1.700 tokens por segundo medidos pela Artificial Analysis; o fabricante anuncia 3.000. A barra usa a medição independente.", }, ]; type Escala = "humana" | "maquinas"; const duracaoReal = (p: Pista) => p.espera + TOKENS_RESPOSTA / p.taxa; const JANELA_HUMANA = Math.max(...PISTAS.map(duracaoReal)); const JANELA_MAQUINAS = 1; const fmtSegundos = new Intl.NumberFormat("pt-BR", { minimumFractionDigits: 1, maximumFractionDigits: 2 }); const fmtInteiro = new Intl.NumberFormat("pt-BR", { maximumFractionDigits: 0 }); export default function ParalelismoCorridaLab(_props: LiveLabProps) { const uid = useId(); const movimentoReduzido = usePrefersReducedMotion() === true; /* ---------- Painel 1 ---------- */ const canvasRef = useRef(null); const donos = useRef(new Int8Array(TOTAL_TAREFAS).fill(-1)); const paleta = useRef(null); const workersRef = useRef([]); const urlWorkerRef = useRef(null); const rafRef = useRef(null); const feitasRef = useRef(0); const flushRef = useRef(null); const [nucleos, setNucleos] = useState(null); const [temWorker, setTemWorker] = useState(true); const [trabalhadores, setTrabalhadores] = useState(1); const [rodando, setRodando] = useState(false); const [feitas, setFeitas] = useState(0); const [ultimaMedicao, setUltima] = useState(null); const [baseUm, setBaseUm] = useState(null); const [contagem, setContagem] = useState([]); const [modoThread, setModoThread] = useState(false); useEffect(() => { if (typeof navigator !== "undefined") setNucleos(navigator.hardwareConcurrency || null); setTemWorker(typeof Worker !== "undefined" && typeof Blob !== "undefined" && typeof URL !== "undefined"); }, []); const opcoes = useMemo(() => { const teto = Math.min(TETO_TRABALHADORES, Math.max(8, nucleos ?? 4)); const base = [1, 2, 4, 8, 16].filter((n) => n <= teto); if (nucleos && nucleos <= TETO_TRABALHADORES && !base.includes(nucleos)) base.push(nucleos); return base.sort((a, b) => a - b); }, [nucleos]); const pintarLadrilho = useCallback((indice: number) => { const canvas = canvasRef.current; const p = paleta.current; if (!canvas || !p) return; const ctx = canvas.getContext("2d"); if (!ctx) return; const escala = canvas.width / TAMANHO_CANVAS; const ts = (TAMANHO_CANVAS / LADO) * escala; const x = (indice % LADO) * ts; const y = Math.floor(indice / LADO) * ts; const dono = donos.current[indice]; ctx.fillStyle = dono < 0 ? p.pendente : p.trabalhadores[dono % p.trabalhadores.length]; ctx.fillRect(x + escala, y + escala, ts - 2 * escala, ts - 2 * escala); }, []); const pintarTudo = useCallback(() => { const canvas = canvasRef.current; if (!canvas) return; const dpr = Math.min(2, window.devicePixelRatio || 1); const lado = Math.round(TAMANHO_CANVAS * dpr); if (canvas.width !== lado) { canvas.width = lado; canvas.height = lado; } paleta.current = montarPaleta(canvas, TETO_TRABALHADORES); const ctx = canvas.getContext("2d"); if (!ctx) return; ctx.fillStyle = paleta.current.fundo; ctx.fillRect(0, 0, canvas.width, canvas.height); for (let i = 0; i < TOTAL_TAREFAS; i++) pintarLadrilho(i); }, [pintarLadrilho]); /* Pinta no mount e repinta quando o tema muda (data-theme no html ou a preferência do sistema): a grade é derivada de `donos`, então nada se perde. */ useEffect(() => { pintarTudo(); const html = document.documentElement; const observador = new MutationObserver(() => pintarTudo()); observador.observe(html, { attributes: true, attributeFilter: ["data-theme", "class", "style"] }); const consulta = window.matchMedia("(prefers-color-scheme: dark)"); const aoMudar = () => pintarTudo(); consulta.addEventListener("change", aoMudar); return () => { observador.disconnect(); consulta.removeEventListener("change", aoMudar); }; }, [pintarTudo]); const agendarFlush = useCallback(() => { if (flushRef.current !== null) return; flushRef.current = window.requestAnimationFrame(() => { flushRef.current = null; setFeitas(feitasRef.current); }); }, []); const encerrarWorkers = useCallback(() => { for (const w of workersRef.current) w.terminate(); workersRef.current = []; if (urlWorkerRef.current) { URL.revokeObjectURL(urlWorkerRef.current); urlWorkerRef.current = null; } if (rafRef.current !== null) { window.cancelAnimationFrame(rafRef.current); rafRef.current = null; } }, []); const parar = useCallback(() => { encerrarWorkers(); setRodando(false); }, [encerrarWorkers]); useEffect(() => () => encerrarWorkers(), [encerrarWorkers]); const executar = useCallback(() => { encerrarWorkers(); donos.current.fill(-1); feitasRef.current = 0; setFeitas(0); setUltima(null); setContagem(Array.from({ length: trabalhadores }, () => 0)); pintarTudo(); setRodando(true); const inicio = performance.now(); const porTrabalhador = Array.from({ length: trabalhadores }, () => 0); let proximoIndice = 0; let soma = 0; const concluir = () => { /* A soma dos 1.024 resultados é a etapa que continua sequencial. */ const tempoMs = performance.now() - inicio; encerrarWorkers(); setContagem([...porTrabalhador]); setUltima({ trabalhadores, tempoMs, soma }); if (trabalhadores === 1) setBaseUm(tempoMs); setRodando(false); }; const registrar = (indice: number, k: number, resultado: number) => { donos.current[indice] = k; porTrabalhador[k] += 1; soma += resultado; feitasRef.current += 1; pintarLadrilho(indice); agendarFlush(); }; if (!temWorker) { /* Sem Worker: fatias de quatro tarefas por quadro na thread principal. */ setModoThread(true); const passo = () => { const fim = Math.min(proximoIndice + 4, TOTAL_TAREFAS); for (; proximoIndice < fim; proximoIndice++) registrar(proximoIndice, 0, contarPrimos(LIMITE_PRIMOS, proximoIndice)); if (proximoIndice < TOTAL_TAREFAS) rafRef.current = window.requestAnimationFrame(passo); else { rafRef.current = null; concluir(); } }; rafRef.current = window.requestAnimationFrame(passo); return; } setModoThread(false); const url = URL.createObjectURL(new Blob([CODIGO_WORKER], { type: "text/javascript" })); urlWorkerRef.current = url; const lista: Worker[] = []; for (let k = 0; k < trabalhadores; k++) { const w = new Worker(url); w.onmessage = (evento: MessageEvent<{ indice: number; resultado: number }>) => { registrar(evento.data.indice, k, evento.data.resultado); if (proximoIndice < TOTAL_TAREFAS) w.postMessage({ indice: proximoIndice++, limite: LIMITE_PRIMOS }); if (feitasRef.current === TOTAL_TAREFAS) concluir(); }; w.onerror = () => { /* Falha num worker: encerra e cai para a thread principal na próxima execução. */ encerrarWorkers(); setTemWorker(false); setRodando(false); }; lista.push(w); } workersRef.current = lista; for (const w of lista) { if (proximoIndice < TOTAL_TAREFAS) w.postMessage({ indice: proximoIndice++, limite: LIMITE_PRIMOS }); } }, [agendarFlush, encerrarWorkers, pintarLadrilho, pintarTudo, temWorker, trabalhadores]); const aceleracao = ultimaMedicao && baseUm ? baseUm / ultimaMedicao.tempoMs : null; const tarefasPorSegundo = ultimaMedicao ? Math.round((TOTAL_TAREFAS / ultimaMedicao.tempoMs) * 1000) : 0; const rotuloCanvas = rodando ? `${fmtInteiro.format(feitas)} de ${fmtInteiro.format(TOTAL_TAREFAS)} tarefas concluídas por ${trabalhadores} ${trabalhadores === 1 ? "trabalhador" : "trabalhadores"}.` : ultimaMedicao ? `Grade de ${fmtInteiro.format(TOTAL_TAREFAS)} tarefas concluídas por ${ultimaMedicao.trabalhadores} ${ultimaMedicao.trabalhadores === 1 ? "trabalhador" : "trabalhadores"} em ${fmtInteiro.format(Math.round(ultimaMedicao.tempoMs))} ms.` : `Grade de ${fmtInteiro.format(TOTAL_TAREFAS)} tarefas ainda não executadas.`; /* ---------- Painel 2 ---------- */ const tlRef = useRef(null); const barrasRef = useRef>({ humano: null, gpu: null, wafer: null }); const contadoresRef = useRef>({ humano: 0, gpu: 0, wafer: 0 }); const ultimoFlushRef = useRef(0); const [tokens, setTokens] = useState>({ humano: 0, gpu: 0, wafer: 0 }); const [tempoReal, setTempoReal] = useState(0); const [reproduzindo, setReproduzindo] = useState(false); const [escala, setEscala] = useState("humana"); const janela = escala === "humana" ? JANELA_HUMANA : JANELA_MAQUINAS; const publicar = useCallback( (forcar: boolean) => { const tl = tlRef.current; if (!tl) return; const agora = performance.now(); if (!forcar && agora - ultimoFlushRef.current < 120) return; ultimoFlushRef.current = agora; setTokens({ ...contadoresRef.current }); setTempoReal(tl.time()); }, [], ); useEffect(() => { const tl = gsap.timeline({ paused: true }); for (const p of PISTAS) { const alvo = { valor: 0 }; tl.to( alvo, { valor: TOKENS_RESPOSTA, duration: TOKENS_RESPOSTA / p.taxa, ease: "none", onUpdate: () => { contadoresRef.current[p.id] = Math.round(alvo.valor); }, }, p.espera, ); const barra = barrasRef.current[p.id]; if (barra) { gsap.set(barra, { scaleX: 0, transformOrigin: "left center" }); tl.to(barra, { scaleX: 1, duration: TOKENS_RESPOSTA / p.taxa, ease: "none" }, p.espera); } } tl.eventCallback("onUpdate", () => publicar(false)); tl.eventCallback("onComplete", () => { publicar(true); setReproduzindo(false); }); tlRef.current = tl; return () => { tl.kill(); tlRef.current = null; }; }, [publicar]); /* Escala de tempo: a janela inteira cabe em 12 s de tela. */ useEffect(() => { const tl = tlRef.current; if (!tl) return; tl.timeScale(janela / SEGUNDOS_DE_TELA); if (tl.time() > janela) { tl.pause(janela); setReproduzindo(false); publicar(true); } }, [janela, publicar]); /* Na escala das máquinas a reprodução para no fim do primeiro segundo. */ useEffect(() => { if (!reproduzindo || escala !== "maquinas") return; const id = window.setInterval(() => { const tl = tlRef.current; if (tl && tl.time() >= janela) { tl.pause(janela); setReproduzindo(false); publicar(true); } }, 50); return () => window.clearInterval(id); }, [escala, janela, publicar, reproduzindo]); const reproduzir = () => { const tl = tlRef.current; if (!tl || movimentoReduzido) return; if (tl.time() >= janela) tl.time(0); tl.play(); setReproduzindo(true); }; const pausar = () => { tlRef.current?.pause(); setReproduzindo(false); publicar(true); }; const reiniciar = () => { const tl = tlRef.current; if (!tl) return; tl.pause(0); setReproduzindo(false); publicar(true); }; const percorrer = (valor: number) => { const tl = tlRef.current; if (!tl) return; tl.pause((valor / 1000) * janela); setReproduzindo(false); publicar(true); }; const posicao = Math.round(Math.min(1, tempoReal / janela) * 1000); return (
{/* ---------- Painel 1 ---------- */}

{"Mil tarefas, N trabalhadores"}

{ "Cada ladrilho é uma tarefa de CPU igual às outras: contar os primos até 48.000. Os trabalhadores são Web Workers, linhas de execução separadas da que desenha a página. Este aparelho informa " } {nucleos ? `${nucleos} ${nucleos === 1 ? "núcleo" : "núcleos"}` : "um número desconhecido de núcleos"} {"."}

{rodando ? `${fmtInteiro.format(feitas)} de ${fmtInteiro.format(TOTAL_TAREFAS)} tarefas concluídas.` : ultimaMedicao ? `${fmtInteiro.format(TOTAL_TAREFAS)} tarefas em ${fmtInteiro.format(Math.round(ultimaMedicao.tempoMs))} ms com ${ultimaMedicao.trabalhadores} ${ultimaMedicao.trabalhadores === 1 ? "trabalhador" : "trabalhadores"}. Soma dos resultados: ${fmtInteiro.format(ultimaMedicao.soma)}, feita em sequência no fim.` : "Escolha o número de trabalhadores e aperte Executar. Comece com um, para ter a medida de referência."}

{!temWorker || modoThread ? (

{ "Este navegador não oferece Web Workers, então a demonstração roda na linha principal, em fatias por quadro. A página fica menos responsiva enquanto calcula, e o número de trabalhadores não muda o tempo." }

) : null}
{"Tempo de parede"} {" ms"}
{"Tarefas por segundo"}
{"Aceleração sobre um trabalhador"} {aceleracao ? ( <> {" vezes"} ) : ( {baseUm ? "execute de novo" : "meça com 1 antes"} )}
{contagem.length > 1 ? (
    {contagem.map((n, k) => (
  • ))}
) : null}

{ "O tempo cai quase pela metade a cada dobra de trabalhadores até chegar ao número de núcleos reais; depois disso, dobrar não ajuda, porque as linhas de execução passam a disputar o mesmo núcleo. A soma final dos 1.024 resultados continua sequencial: dividir o trabalho acelera as partes, nunca a junção." }

{/* ---------- Painel 2 ---------- */}

{"Corrida de tokens por segundo"}

{ "Uma resposta de 300 tokens, que são os pedaços de palavra que o modelo produz, em três ritmos publicados. A espera pela primeira palavra é cenário ilustrativo; o ritmo de geração tem fonte e data no rótulo de cada pista." }

{"Escala de tempo"}
    {PISTAS.map((p) => { const t = tokens[p.id]; const concluida = t >= TOKENS_RESPOSTA; const esperando = tempoReal < p.espera; return (
  1. {p.rotulo} {` de ${TOKENS_RESPOSTA} tokens`}
    { barrasRef.current[p.id] = el; }} className={styles.barra} data-concluida={concluida} />

    {esperando ? `Aguardando a primeira palavra: ${fmtSegundos.format(p.espera)} s (cenário ilustrativo).` : concluida ? `Concluída em ${fmtSegundos.format(duracaoReal(p))} s reais: ${fmtSegundos.format(p.espera)} s de espera e ${fmtInteiro.format(p.taxa)} tokens por segundo.` : `Gerando a ${fmtInteiro.format(p.taxa)} tokens por segundo; termina em ${fmtSegundos.format(duracaoReal(p))} s reais.`}

    {p.fonte}

  2. ); })}

{`Relógio: ${fmtSegundos.format(Math.min(tempoReal, janela))} s de tempo real. ${PISTAS.map((p) => `${p.rotulo.split(" (")[0]}: ${fmtInteiro.format(tokens[p.id])}${tokens[p.id] >= TOKENS_RESPOSTA ? " (concluída)" : ""}`).join("; ")}.`}

{movimentoReduzido ? "Seu sistema pede menos movimento: nada corre sozinho aqui. Percorra a corrida com o controle deslizante, e os números trocam de uma vez." : "Na escala humana as duas máquinas terminam antes de o olho perceber; troque para a escala das máquinas para ver a diferença entre a GPU e o wafer dentro do primeiro segundo."}

); } export const meta: LiveLabMeta = { biblioteca: "GSAP, Web Workers e number-flow", pacote: "gsap, @number-flow/react", versao: "^3.15.0 e ^0.6.2", docsUrl: "https://gsap.com/docs/v3/", npmUrl: "https://www.npmjs.com/package/gsap", trechoLinguagem: "tsx", trecho: `// Trabalhadores reais: um Worker por linha de execução, criado de um Blob. const url = URL.createObjectURL(new Blob([CODIGO_WORKER], { type: "text/javascript" })); const workers = Array.from({ length: n }, () => new Worker(url)); let proximoIndice = 0; for (const [k, w] of workers.entries()) { w.onmessage = (e) => { pintarLadrilho(e.data.indice, k); // a cor é a do trabalhador if (proximoIndice < 1024) w.postMessage({ indice: proximoIndice++, limite: 48000 }); }; w.postMessage({ indice: proximoIndice++, limite: 48000 }); // fila dinâmica: quem acaba pega a próxima } // Corrida: linha do tempo pausada; a pessoa reproduz ou percorre. const tl = gsap.timeline({ paused: true }); for (const p of PISTAS) { tl.to(barra[p.id], { scaleX: 1, duration: 300 / p.taxa, ease: "none" }, p.espera); } tl.timeScale(janelaReal / 12); // 60 s reais em 12 s de tela tl.pause((e.target.value / 1000) * janelaReal)} />`, observar: [ "Rode com 1, depois 2, 4 e 8 trabalhadores: o tempo de parede cai a cada dobra até o número de núcleos do aparelho e depois estabiliza, porque as linhas de execução passam a disputar o mesmo núcleo.", "A soma dos 1.024 resultados aparece só no fim e é feita em sequência: dividir o trabalho acelera as partes, nunca a junção.", "Na corrida, a espera pela primeira palavra e o ritmo de geração são medidas diferentes; troque a escala para ver a GPU e o wafer se separarem dentro do primeiro segundo.", "Com reduzir movimento ativo no sistema, nada corre sozinho: o controle deslizante percorre a corrida e os números trocam de uma vez.", ], }; ==== ParalelismoCorrida.module.css ==== /* Laboratório paralelismo-corrida do curso Frontends com Vibecoding. Cor só por token do tema: contraste AA nos temas claro e escuro sem cor fixa. Alvo de toque de 44 px em todo controle e foco visível pelo acento do curso. */ .lab { display: grid; gap: 1rem; color: var(--text); } .paineis { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr)); gap: 1rem; } .painel { display: grid; gap: 0.75rem; min-width: 0; padding: 0.9rem 1rem; border: 1px solid var(--border); border-radius: var(--radius, 8px); background: var(--bg-muted); } .tituloPainel { margin: 0; font-size: 15px; line-height: 1.3; font-weight: 700; color: var(--text); } .explica, .estado, .nota, .aviso { margin: 0; font-size: 14px; line-height: 1.55; color: var(--text); } .nota { font-size: 13px; color: var(--text-muted); } .aviso { padding: 0.6rem 0.8rem; border-left: 3px solid var(--warning); border-radius: var(--radius, 8px); background: var(--bg-white); font-size: 13px; } .controles { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: flex-end; } .campo { display: grid; gap: 0.25rem; font-size: 13px; font-weight: 600; color: var(--text); } .seletor, .botao { min-height: 44px; min-width: 44px; padding: 0 0.9rem; border: 1px solid var(--border); border-radius: var(--radius, 8px); background: var(--bg-white); color: var(--text); font: inherit; font-size: 14px; font-weight: 600; cursor: pointer; } .botao:hover:not(:disabled), .seletor:hover:not(:disabled) { border-color: var(--course-accent, var(--accent)); } .botao:disabled, .seletor:disabled { cursor: not-allowed; opacity: 0.55; } .botao:focus-visible, .seletor:focus-visible, .faixa input:focus-visible, .opcaoEscala input:focus-visible { outline: 2px solid var(--course-accent, var(--accent)); outline-offset: 2px; } .palcoCanvas { display: flex; justify-content: center; } .canvas { display: block; width: 100%; max-width: 320px; aspect-ratio: 1 / 1; height: auto; border: 1px solid var(--border); border-radius: var(--radius, 8px); background: var(--bg-white); } .cartoes { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 0.5rem; } .cartao { display: grid; gap: 0.2rem; padding: 0.6rem 0.75rem; border: 1px solid var(--border); border-radius: var(--radius, 8px); background: var(--bg-white); } .rotulo { font-size: 13px; font-weight: 600; color: var(--text-muted); } .valor { font-size: 1.35rem; line-height: 1.2; font-weight: 700; color: var(--text); font-variant-numeric: tabular-nums; } .unidade { font-size: 0.85rem; font-weight: 600; color: var(--text-muted); } .semDado { font-size: 13px; font-weight: 600; color: var(--text-muted); } .legenda { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.3rem 0.75rem; font-size: 13px; color: var(--text); } .itemLegenda { display: flex; align-items: center; gap: 0.45rem; min-height: 24px; } .amostra { flex: 0 0 auto; width: 14px; height: 14px; border: 1px solid var(--border); border-radius: 3px; } .escalas { display: grid; gap: 0.4rem; margin: 0; padding: 0.6rem 0.8rem; border: 1px solid var(--border); border-radius: var(--radius, 8px); background: var(--bg-white); } .legendaEscala { padding: 0 0.3rem; font-size: 13px; font-weight: 700; color: var(--text); } .opcaoEscala { display: flex; align-items: center; gap: 0.6rem; min-height: 44px; font-size: 14px; line-height: 1.4; color: var(--text); cursor: pointer; } .opcaoEscala input { width: 20px; height: 20px; margin: 0; flex: 0 0 auto; accent-color: var(--course-accent, var(--accent)); cursor: pointer; } .faixa { display: grid; gap: 0.3rem; font-size: 13px; font-weight: 600; color: var(--text); } .faixa input { width: 100%; min-height: 44px; margin: 0; accent-color: var(--course-accent, var(--accent)); cursor: pointer; } .pistas { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.75rem; } .pista { display: grid; gap: 0.35rem; padding: 0.7rem 0.8rem; border: 1px solid var(--border); border-radius: var(--radius, 8px); background: var(--bg-white); } .pistaTopo { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 0.25rem 0.75rem; } .pistaRotulo { font-size: 14px; font-weight: 700; color: var(--text); } .pistaTokens { font-size: 14px; font-weight: 600; color: var(--text); font-variant-numeric: tabular-nums; } .trilho { position: relative; height: 14px; overflow: hidden; border: 1px solid var(--border); border-radius: 999px; background: var(--bg-muted); } /* A barra é preenchida pelo GSAP via transform: scaleX, com origem à esquerda. */ .barra { position: absolute; inset: 0; display: block; transform: scaleX(0); transform-origin: left center; border-radius: 999px; background: var(--course-accent, var(--accent)); will-change: transform; } .barra[data-concluida="true"] { background: var(--success); } .pistaLeitura { margin: 0; font-size: 13px; line-height: 1.5; color: var(--text); } .pistaFonte { margin: 0; font-size: 13px; line-height: 1.5; color: var(--text-muted); } .nota[data-reduzido="true"] { padding-left: 0.7rem; border-left: 3px solid var(--success); color: var(--text); } @media (prefers-reduced-motion: no-preference) { .botao, .seletor { transition: background-color 0.15s ease, border-color 0.15s ease; } }