Laboratório ao vivo "orquestrador-fluxo" 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 ===='. ==== OrquestradorFluxoLab.tsx ==== "use client"; import "@xyflow/react/dist/style.css"; import { useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { BaseEdge, Handle, MarkerType, Position, ReactFlow, ReactFlowProvider, getSmoothStepPath, useReactFlow, type Edge, type EdgeProps, type EdgeTypes, type Node, type NodeProps, type NodeTypes, } from "@xyflow/react"; import type { LiveLabMeta, LiveLabProps } from "./registry"; import styles from "./OrquestradorFluxo.module.css"; /** * Laboratório "orquestrador-fluxo": um orquestrador de LLMs com harness, * desenhado com o React Flow real (@xyflow/react 12). * * O grafo tem nove nós: pedido, orquestrador (roteador), cinco modelos em * paralelo (pesquisa, redação, análise, classificação e revisão), verificação * e resposta. A verificação pode reprovar um resultado e devolvê-lo ao * orquestrador por uma aresta de volta, que reexecuta só aquele modelo: é o * ciclo do harness (pedido, ferramenta, verificação, retorno). * * A simulação é um roteiro determinístico de eventos em "segundos de modelo", * tocado por requestAnimationFrame na escala de 1 s real = 40 ms de animação. * Com "reduzir movimento" ativo no aparelho nada anda sozinho: a pessoa avança * etapa por etapa por um botão, e as arestas não têm traço em movimento. * * As durações do modo "medição real" são as do board do geo-orchestrator da * Brasil GEO rodado em 25/09/2026 (cinco modelos respondendo em paralelo à * mesma demanda). As etapas de roteamento, verificação e montagem da resposta * são ilustrativas nos dois modos, e o tempo de parede exibido conta só o * trecho dos modelos, para bater com a medição. * * Regras da casa: cor só por token do tema, alvo de toque de 44 px, foco * visível, nenhuma chamada de rede, dado inteiro no módulo. */ type Estado = "aguardando" | "executando" | "concluido" | "reprovado" | "desligado"; const ROTULO_ESTADO: Record = { aguardando: "aguardando", executando: "executando", concluido: "concluído", reprovado: "reprovado", desligado: "desligada", }; interface Modelo { id: string; titulo: string; provedor: string; /** Duração medida em 25/09/2026, em segundos. */ duracaoReal: number; /** Duração do cenário ilustrativo, em segundos de modelo. */ duracaoFicticia: number; /** Custo medido em 25/09/2026, em dólares. */ custoReal: number; notaReal?: string; } /* Medição de 25/09/2026: board do geo-orchestrator (Brasil GEO), 5 de 5 agentes responderam, tempo de parede 221 s, soma 360,4 s, custo US$ 0,48. */ const MODELOS: Modelo[] = [ { id: "m1", titulo: "Pesquisa com fontes vivas", provedor: "Perplexity", duracaoReal: 39.3, duracaoFicticia: 40, custoReal: 0.0108 }, { id: "m2", titulo: "Redação", provedor: "GPT-5.5", duracaoReal: 47.7, duracaoFicticia: 50, custoReal: 0.1295 }, { id: "m3", titulo: "Análise", provedor: "Gemini Pro", duracaoReal: 34.2, duracaoFicticia: 30, custoReal: 0.0519 }, { id: "m4", titulo: "Classificação", provedor: "Gemini Flash", duracaoReal: 18.2, duracaoFicticia: 20, custoReal: 0.0329 }, { id: "m5", titulo: "Revisão", provedor: "Claude Opus", duracaoReal: 221, duracaoFicticia: 60, custoReal: 0.2548, notaReal: "com nova tentativa", }, ]; /** O resultado que a verificação reprova uma vez quando está ligada. */ const REPROVADO = "m3"; /** Segundos de modelo percorridos por segundo real (1 s real = 40 ms de animação). */ const VELOCIDADE = 25; interface EventoEstado { id: string; estado: Estado; } interface Grupo { t: number; eventos: EventoEstado[]; narracao?: string; } interface Corrida { id: string; inicio: number; fim: number; custo: number; reexecucao: boolean; } interface Roteiro { grupos: Grupo[]; corridas: Corrida[]; } interface Opcoes { paralelo: boolean; verificacao: boolean; real: boolean; } const fmt = (n: number) => n.toLocaleString("pt-BR", { minimumFractionDigits: 1, maximumFractionDigits: 1 }); const fmtMoeda = (n: number) => `US$ ${n.toLocaleString("pt-BR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; function montarRoteiro(o: Opcoes): Roteiro { const grupos = new Map(); const add = (t: number, eventos: EventoEstado[], narracao?: string) => { const chave = Math.round(t * 100) / 100; const grupo = grupos.get(chave) ?? { t: chave, eventos: [] }; grupo.eventos.push(...eventos); if (narracao) grupo.narracao = grupo.narracao ? `${grupo.narracao} ${narracao}` : narracao; grupos.set(chave, grupo); }; const dur = (m: Modelo) => (o.real ? m.duracaoReal : m.duracaoFicticia); const custo = (m: Modelo) => (o.real ? m.custoReal : 0); const corridas: Corrida[] = []; add(0, [{ id: "pedido", estado: "executando" }], "O pedido entrou na fila do orquestrador."); add( 1, [ { id: "pedido", estado: "concluido" }, { id: "orq", estado: "executando" }, ], "O orquestrador partiu o pedido em cinco tarefas e escolheu um modelo para cada uma.", ); add( 2, [{ id: "orq", estado: "concluido" }], o.paralelo ? "Cinco modelos trabalham ao mesmo tempo; o relógio corre pelo mais lento." : "Em sequência: cada modelo só começa quando o anterior termina.", ); let cursor = 2; let fim = 2; for (const m of MODELOS) { const inicio = o.paralelo ? 2 : cursor; const f = inicio + dur(m); const notaInicio = o.real && m.notaReal ? `${m.titulo} (${m.provedor}): a primeira chamada esgotou o tempo e o harness tentou de novo.` : undefined; add(inicio, [{ id: m.id, estado: "executando" }], o.paralelo ? notaInicio : [`${m.titulo} começou.`, notaInicio].filter(Boolean).join(" ")); add(f, [{ id: m.id, estado: "concluido" }], `${m.titulo} terminou em ${fmt(dur(m))} s.`); corridas.push({ id: m.id, inicio, fim: f, custo: custo(m), reexecucao: false }); cursor = f; fim = Math.max(fim, f); } let t = fim; if (o.verificacao) { const rep = MODELOS.find((m) => m.id === REPROVADO)!; add(t, [{ id: "verif", estado: "executando" }], "A verificação confere as cinco respostas contra o pedido."); t += 1; add( t, [ { id: "verif", estado: "aguardando" }, { id: rep.id, estado: "reprovado" }, { id: "orq", estado: "executando" }, ], `${rep.titulo} reprovada pela verificação: voltou ao orquestrador, que reexecuta só essa tarefa.`, ); t += 1; add(t, [ { id: "orq", estado: "concluido" }, { id: rep.id, estado: "executando" }, ]); corridas.push({ id: rep.id, inicio: t, fim: t + dur(rep), custo: custo(rep), reexecucao: true }); t += dur(rep); add( t, [ { id: rep.id, estado: "concluido" }, { id: "verif", estado: "executando" }, ], "A verificação roda de novo sobre a resposta corrigida.", ); t += 1; add( t, [ { id: "verif", estado: "concluido" }, { id: "resp", estado: "executando" }, ], "Aprovado: a resposta sai montada com as cinco partes.", ); } else { add( t, [{ id: "resp", estado: "executando" }], "Sem verificação, a resposta sai direto: um erro de qualquer modelo chegaria a quem pediu sem ninguém conferir.", ); } t += 0.5; const parede = Math.max(...corridas.map((c) => c.fim)) - Math.min(...corridas.map((c) => c.inicio)); const soma = corridas.reduce((n, c) => n + (c.fim - c.inicio), 0); const reexec = corridas.filter((c) => c.reexecucao).length; add( t, [{ id: "resp", estado: "concluido" }], `Fim. Os modelos levaram ${fmt(parede)} s de parede; em fila, seriam ${fmt(soma)} s${reexec ? `, já contando ${reexec} reexecução pedida pela verificação` : ""}.`, ); return { grupos: [...grupos.values()].sort((a, b) => a.t - b.t), corridas }; } function estadosAte(grupos: Grupo[], indice: number, verificacao: boolean): Record { const estados: Record = { pedido: "aguardando", orq: "aguardando", verif: verificacao ? "aguardando" : "desligado", resp: "aguardando", }; for (const m of MODELOS) estados[m.id] = "aguardando"; for (const grupo of grupos.slice(0, indice)) { for (const e of grupo.eventos) estados[e.id] = e.estado; } if (!verificacao) estados.verif = "desligado"; return estados; } function medir(corridas: Corrida[], t: number) { const iniciadas = corridas.filter((c) => c.inicio <= t); if (!iniciadas.length) return { parede: 0, soma: 0, reexec: 0, custo: 0 }; const inicio = Math.min(...iniciadas.map((c) => c.inicio)); const fim = Math.max(...iniciadas.map((c) => Math.min(c.fim, t))); return { parede: fim - inicio, soma: iniciadas.reduce((n, c) => n + (Math.min(c.fim, t) - c.inicio), 0), reexec: iniciadas.filter((c) => c.reexecucao).length, custo: iniciadas.reduce((n, c) => n + c.custo, 0), }; } /* ---------- movimento reduzido ---------- */ const consultaMovimento = () => window.matchMedia("(prefers-reduced-motion: reduce)"); function useMovimentoReduzido(): boolean { return useSyncExternalStore( (cb) => { const m = consultaMovimento(); m.addEventListener("change", cb); return () => m.removeEventListener("change", cb); }, () => consultaMovimento().matches, () => false, ); } /* ---------- nós e arestas ---------- */ type DadosNo = { titulo: string; sub: string; estado: Estado; vertical: boolean; entrada: boolean; saida: boolean; volta?: "entrada" | "saida"; duracao?: string; nota?: string; }; type NoEtapaType = Node; function NoEtapa({ data }: NodeProps) { const alvo = data.vertical ? Position.Top : Position.Left; const fonte = data.vertical ? Position.Bottom : Position.Right; const voltaPos = data.vertical ? Position.Right : Position.Top; return (
{data.entrada ? : null} {data.saida ? : null} {data.volta === "entrada" ? : null} {data.volta === "saida" ? : null} {data.titulo} {data.sub} {data.duracao ? `${ROTULO_ESTADO[data.estado]} (${data.duracao})` : ROTULO_ESTADO[data.estado]} {data.nota ? {data.nota} : null}
); } type DadosAresta = { offset?: number; rotulo?: string; }; type ArestaType = Edge; /** Aresta de volta (verificação para o orquestrador), desviando do bloco dos modelos. */ function ArestaVolta({ sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition, data, markerEnd }: EdgeProps) { const [caminho, rotuloX, rotuloY] = getSmoothStepPath({ sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition, offset: data?.offset ?? 60, borderRadius: 10, }); return ; } const TIPOS_DE_NO: NodeTypes = { etapa: NoEtapa }; const TIPOS_DE_ARESTA: EdgeTypes = { volta: ArestaVolta }; const LARGURA_NO = 160; /** Coluna única (celular), grade 3 mais 2 (coluna de leitura) ou horizontal (tela larga). */ type Layout = "coluna" | "grade" | "horizontal"; function escolherLayout(largura: number): Layout { if (largura >= 1150) return "horizontal"; if (largura >= 560) return "grade"; return "coluna"; } /* Linhas a 110 px (100 px na coluna única): o nó mais alto tem quatro linhas (título em duas, provedor e estado) e mede cerca de 92 px, então nenhum nó encosta no de baixo. As alturas do palco cabem no zoom 1 com padding 0,08. */ function posicoes(layout: Layout): Record { if (layout === "coluna") { const x = 0; return { pedido: { x, y: 0 }, orq: { x, y: 100 }, m1: { x, y: 200 }, m2: { x, y: 300 }, m3: { x, y: 400 }, m4: { x, y: 500 }, m5: { x, y: 600 }, verif: { x, y: 700 }, resp: { x, y: 800 }, }; } if (layout === "grade") { const passo = LARGURA_NO + 12; const centro = passo; return { pedido: { x: centro, y: 0 }, orq: { x: centro, y: 110 }, m1: { x: 0, y: 220 }, m2: { x: passo, y: 220 }, m3: { x: passo * 2, y: 220 }, m4: { x: passo / 2, y: 330 }, m5: { x: passo * 1.5, y: 330 }, verif: { x: centro, y: 440 }, resp: { x: centro, y: 550 }, }; } return { pedido: { x: 0, y: 219 }, orq: { x: 200, y: 219 }, m1: { x: 400, y: 0 }, m2: { x: 400, y: 110 }, m3: { x: 400, y: 220 }, m4: { x: 400, y: 330 }, m5: { x: 400, y: 440 }, verif: { x: 600, y: 219 }, resp: { x: 800, y: 219 }, }; } /** Desvio da aresta de volta: passa à direita da linha mais larga (fluxo vertical) ou acima da coluna dos modelos. */ const DESVIO_VOLTA: Record = { coluna: 40, grade: 200, horizontal: 240 }; function montarNos(estados: Record, layout: Layout, real: boolean): NoEtapaType[] { const p = posicoes(layout); const vertical = layout !== "horizontal"; const no = (id: string, titulo: string, sub: string, extra: Partial = {}): NoEtapaType => ({ id, type: "etapa", position: p[id], width: LARGURA_NO, draggable: false, selectable: false, focusable: false, data: { titulo, sub, estado: estados[id], vertical, entrada: true, saida: true, ...extra }, }); return [ no("pedido", "Pedido", "quem pede", { entrada: false }), no("orq", "Orquestrador", "parte e roteia", { volta: "entrada" }), ...MODELOS.map((m) => no(m.id, m.titulo, m.provedor, { duracao: `${fmt(real ? m.duracaoReal : m.duracaoFicticia)} s`, nota: real ? m.notaReal : undefined, }), ), no("verif", "Verificação", "juiz das respostas", { volta: "saida" }), no("resp", "Resposta", "montada e conferida", { saida: false }), ]; } function montarArestas(estados: Record, layout: Layout, verificacao: boolean, reduzido: boolean): ArestaType[] { const algumReprovado = MODELOS.some((m) => estados[m.id] === "reprovado"); const marcador = { type: MarkerType.ArrowClosed, width: 16, height: 16 }; const aresta = (id: string, source: string, target: string, ativa: boolean, extra: Partial = {}): ArestaType => { const { className: classeExtra, ...resto } = extra; return { id, source, target, type: "smoothstep", animated: ativa && !reduzido, markerEnd: marcador, focusable: false, selectable: false, ...resto, className: [ativa ? styles.arestaAtiva : "", classeExtra ?? ""].filter(Boolean).join(" "), }; }; const respAtiva = estados.resp === "executando"; return [ aresta("e-pedido-orq", "pedido", "orq", estados.orq === "executando" && !algumReprovado), ...MODELOS.map((m) => aresta(`e-orq-${m.id}`, "orq", m.id, estados[m.id] === "executando")), ...MODELOS.map((m) => aresta(`e-${m.id}-verif`, m.id, "verif", estados.verif === "executando" || (!verificacao && respAtiva))), aresta("e-verif-resp", "verif", "resp", respAtiva), aresta("e-volta", "verif", "orq", algumReprovado, { type: "volta", sourceHandle: "volta", targetHandle: "volta", className: styles.arestaVolta, data: { offset: DESVIO_VOLTA[layout], rotulo: verificacao ? "reprovado volta" : "sem verificação" }, }), ]; } /** Refaz o enquadramento quando a orientação ou a largura do palco mudam. */ const OPCOES_VISTA = { padding: 0.08, maxZoom: 1, minZoom: 0.6 } as const; function AjusteDeVista({ layout, largura }: { layout: Layout; largura: number }) { const { fitView } = useReactFlow(); useEffect(() => { const id = requestAnimationFrame(() => { void fitView({ ...OPCOES_VISTA, duration: 0 }); }); return () => cancelAnimationFrame(id); }, [layout, largura, fitView]); return null; } /* ---------- o laboratório ---------- */ export default function OrquestradorFluxoLab(_props: LiveLabProps) { const uid = useId(); const reduzido = useMovimentoReduzido(); const [paralelo, setParalelo] = useState(true); const [verificacao, setVerificacao] = useState(true); const [real, setReal] = useState(true); const [indice, setIndice] = useState(0); const [relogio, setRelogio] = useState(0); const [rodando, setRodando] = useState(false); const [largura, setLargura] = useState(0); const palco = useRef(null); const roteiro = useMemo(() => montarRoteiro({ paralelo, verificacao, real }), [paralelo, verificacao, real]); const total = roteiro.grupos.length; const ultimoT = roteiro.grupos[total - 1]?.t ?? 0; const layout = escolherLayout(largura || 760); useEffect(() => { const el = palco.current; if (!el || typeof ResizeObserver === "undefined") return; const ro = new ResizeObserver((entradas) => { for (const e of entradas) setLargura(Math.round(e.contentRect.width)); }); ro.observe(el); return () => ro.disconnect(); }, []); /* Relógio da cena: só no modo com movimento. Cada quadro aplica os grupos de eventos cujo instante já passou. */ useEffect(() => { if (!rodando || reduzido) return; const inicioReal = performance.now(); let id = 0; const passo = (agora: number) => { const t = ((agora - inicioReal) / 1000) * VELOCIDADE; let i = 0; while (i < total && roteiro.grupos[i].t <= t) i++; setIndice(i); setRelogio(Math.min(t, ultimoT)); if (i >= total) { setRodando(false); return; } id = requestAnimationFrame(passo); }; id = requestAnimationFrame(passo); return () => cancelAnimationFrame(id); }, [rodando, reduzido, roteiro, total, ultimoT]); function reiniciar() { setRodando(false); setIndice(0); setRelogio(0); } function disparar() { reiniciar(); if (reduzido) { setIndice(1); setRelogio(roteiro.grupos[0]?.t ?? 0); return; } setRodando(true); } function avancar() { if (indice >= total) return; setRelogio(roteiro.grupos[indice].t); setIndice(indice + 1); } function trocar(setter: (v: T) => void, valor: T) { reiniciar(); setter(valor); } const estados = useMemo(() => estadosAte(roteiro.grupos, indice, verificacao), [roteiro, indice, verificacao]); const nos = useMemo(() => montarNos(estados, layout, real), [estados, layout, real]); const arestas = useMemo(() => montarArestas(estados, layout, verificacao, reduzido), [estados, layout, verificacao, reduzido]); const medida = medir(roteiro.corridas, relogio); const narracao = useMemo(() => { for (let i = indice - 1; i >= 0; i--) { const n = roteiro.grupos[i].narracao; if (n) return n; } return reduzido ? "Aperte Disparar pedido e depois Avançar etapa para percorrer o ciclo no seu ritmo." : "Aperte Disparar pedido para ver o orquestrador distribuir as tarefas."; }, [roteiro, indice, reduzido]); const terminou = indice >= total && total > 0; return (
{"Orquestrador com harness"}

{ "Um pedido vira cinco tarefas, cada uma vai para um modelo, e a verificação devolve ao orquestrador o que não passou. O tempo de parede no paralelo é o do modelo mais lento, e a soma das durações é o que a mesma conta custaria em fila." }

{reduzido ? "Movimento reduzido ativo no aparelho: nada anda sozinho. Avance etapa por etapa pelo botão; as arestas ficam paradas e só trocam de cor." : "Animação em escala: 1 segundo real dos modelos vira 40 milissegundos na tela. Com \"reduzir movimento\" ligado no sistema, o laboratório passa a avançar por botão."}

{"Controles"}
{reduzido ? ( ) : null}

{"Modo"}

{narracao}

{"Relógio da cena"}
{`${fmt(relogio)} s`}
{"Modelos: tempo de parede"}
{`${fmt(medida.parede)} s`}
{"Modelos: soma das durações"}
{`${fmt(medida.soma)} s`}
{"Reexecuções pela verificação"}
{medida.reexec}
{real ? (
{medida.reexec ? "Custo estimado com a reexecução" : "Custo medido"}
{fmtMoeda(medida.custo)}
) : null}

{real ? "Durações e custos dos cinco modelos medidos em 25/09/2026 no board do geo-orchestrator da Brasil GEO: Perplexity sonar-pro, GPT-5.5, Gemini 3.1 Pro, Gemini 3.8 Flash e Claude Opus 5 (5 de 5 responderam; 221 s de parede em paralelo, 360,4 s em fila, US$ 0,48; a chamada do Claude esgotou o tempo uma vez e o harness tentou de novo). Roteamento, verificação e montagem da resposta são etapas ilustrativas, com 1 s cada." : "Cenário ilustrativo: durações inventadas para a leitura da cena, sem custo. Ligue a medição real para ver os números de 25/09/2026."}

); } export const meta: LiveLabMeta = { biblioteca: "React Flow (xyflow)", pacote: "@xyflow/react", versao: "^12.11.3", docsUrl: "https://reactflow.dev/learn", npmUrl: "https://www.npmjs.com/package/@xyflow/react", trechoLinguagem: "tsx", trecho: `import "@xyflow/react/dist/style.css"; import { Handle, Position, ReactFlow, type Node, type NodeProps, type NodeTypes } from "@xyflow/react"; type Etapa = Node<{ titulo: string; estado: "aguardando" | "executando" | "concluido" | "reprovado" }, "etapa">; function NoEtapa({ data }: NodeProps) { return (
{data.titulo}
); } const tipos: NodeTypes = { etapa: NoEtapa }; export function Fluxo({ nos, estados, reduzido }) { const arestas = nos.slice(1).map((n) => ({ id: \`orq-\${n.id}\`, source: "orq", target: n.id, type: "smoothstep", animated: estados[n.id] === "executando" && !reduzido, })); return ; }`, observar: [ "No modo paralelo, o tempo de parede dos modelos é o do mais lento (221 s na medição real), enquanto a soma das durações mostra o que a fila custaria (360,4 s).", "Com a verificação ligada, um resultado reprovado volta ao orquestrador e só aquela tarefa roda de novo: a volta custa tempo e evita a resposta errada.", "Com a verificação desligada, a resposta sai direto e a aresta pontilhada avisa que ninguém conferiu.", "Com \"reduzir movimento\" ativo no aparelho, nada anda sozinho: as arestas só trocam de cor e a cena avança por botão.", ], }; ==== OrquestradorFluxo.module.css ==== /* Laboratório orquestrador-fluxo (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, foco visível pelo acento do curso e nenhum texto abaixo de 13 px, inclusive dentro do grafo e na atribuição. */ .raiz { display: grid; gap: 1rem; color: var(--text); } .topo { display: grid; gap: 0.5rem; } .degrau { display: inline-flex; align-items: center; width: fit-content; min-height: 28px; padding: 0 0.7rem; border: 1px solid var(--course-accent, var(--accent)); border-radius: 999px; background: var(--bg-white); color: var(--course-accent, var(--accent)); font-size: 13px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; } .regra { margin: 0; font-size: 14px; line-height: 1.55; color: var(--text); } .modo { margin: 0; padding: 0.6rem 0.8rem; border-left: 3px solid var(--warning); border-radius: var(--radius, 8px); background: var(--bg-muted); font-size: 13px; line-height: 1.5; color: var(--text); } .modo[data-reduzido="true"] { border-left-color: var(--success); } /* Controles */ .controles { display: grid; gap: 0.75rem; margin: 0; padding: 0.9rem 1rem; border: 1px solid var(--border); border-radius: var(--radius, 8px); background: var(--bg-muted); } .controles legend { padding: 0 0.3rem; font-size: 13px; font-weight: 700; color: var(--text); } .linha { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: center; } .rotuloGrupo { margin: 0; font-size: 13px; font-weight: 600; color: var(--text); } .botao, .segmento, .interruptor { min-height: 44px; min-width: 44px; padding: 0 1rem; 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; touch-action: manipulation; } .botao:hover, .segmento:hover, .interruptor:hover { border-color: var(--course-accent, var(--accent)); } .botao:disabled, .segmento:disabled, .interruptor:disabled { cursor: not-allowed; opacity: 0.55; } .botao:focus-visible, .segmento:focus-visible, .interruptor:focus-visible { outline: 2px solid var(--course-accent, var(--accent)); outline-offset: 2px; } .botaoPrimario { border-color: var(--accent-solid, var(--accent)); background: var(--accent-solid, var(--accent)); color: #ffffff; } .botaoPrimario:hover { border-color: var(--accent-solid, var(--accent)); filter: brightness(1.08); } .segmentos { display: inline-flex; flex-wrap: wrap; gap: 0.35rem; } .segmento[aria-pressed="true"] { border-color: var(--course-accent, var(--accent)); box-shadow: inset 0 0 0 1px var(--course-accent, var(--accent)); color: var(--course-accent, var(--accent)); } .interruptor { display: inline-flex; align-items: center; gap: 0.6rem; } .interruptor::before { content: ""; display: inline-block; width: 34px; height: 18px; border-radius: 999px; border: 1px solid var(--border); background: var(--bg-muted); position: relative; transition: background-color 160ms ease; } .interruptor[aria-checked="true"]::before { background: var(--course-accent, var(--accent)); border-color: var(--course-accent, var(--accent)); } .interruptor[aria-checked="true"] { border-color: var(--course-accent, var(--accent)); } /* O palco do grafo tem altura fixa: o React Flow precisa de tamanho conhecido para o fitView. */ .palco { position: relative; width: 100%; height: 640px; border: 1px solid var(--border); border-radius: var(--radius, 8px); background: var(--bg-muted); overflow: hidden; } /* Alturas calculadas para o grafo caber em zoom 1 com padding de 8%: grade 3 mais 2 (coluna de leitura) e coluna única (celular). */ .palco[data-layout="grade"] { height: 780px; } .palco[data-layout="coluna"] { height: 1070px; } .palco :global(.react-flow) { background: transparent; } .palco :global(.react-flow__edge-path) { stroke: var(--border); stroke-width: 2; } .palco :global(.react-flow__edge).arestaAtiva :global(.react-flow__edge-path) { stroke: var(--course-accent, var(--accent)); stroke-width: 2.5; } .palco :global(.react-flow__edge).arestaVolta :global(.react-flow__edge-path) { stroke-dasharray: 6 5; stroke: var(--text-muted); } .palco :global(.react-flow__edge).arestaVolta.arestaAtiva :global(.react-flow__edge-path) { stroke: var(--danger); } .palco :global(.react-flow__edge-textbg) { fill: var(--bg-white); } .palco :global(.react-flow__edge-text) { fill: var(--text); font-size: 13px; font-weight: 600; } .palco :global(.react-flow__arrowhead polyline) { stroke: var(--text-muted); fill: var(--text-muted); } .palco :global(.react-flow__handle) { width: 9px; height: 9px; border: 1px solid var(--bg-white); background: var(--text-muted); } .palco :global(.react-flow__attribution) { padding: 2px 6px; border-radius: 4px 0 0 0; background: var(--bg-white); color: var(--text-muted); font-size: 13px; } .palco :global(.react-flow__attribution a) { color: var(--text-muted); } /* Nós */ .no { position: relative; box-sizing: border-box; width: 160px; display: grid; gap: 0.15rem; padding: 0.55rem 0.7rem; border: 2px solid var(--border); border-radius: var(--radius, 8px); background: var(--bg-white); color: var(--text); font-size: 14px; line-height: 1.25; } .no[data-estado="executando"] { border-color: var(--course-accent, var(--accent)); } .no[data-estado="concluido"] { border-color: var(--success); } .no[data-estado="reprovado"] { border-color: var(--danger); } .no[data-estado="desligado"] { border-style: dashed; opacity: 0.75; } @media (prefers-reduced-motion: no-preference) { .no[data-estado="executando"] { animation: pulso 1.2s ease-in-out infinite; } } @keyframes pulso { 0%, 100% { box-shadow: 0 0 0 0 var(--course-accent, var(--accent)); } 50% { box-shadow: 0 0 0 5px transparent; } } .tituloNo { font-size: 15px; font-weight: 700; color: var(--text); } .subNo { font-size: 14px; color: var(--text-muted); } .estadoNo { font-size: 14px; font-weight: 600; color: var(--text-muted); } .no[data-estado="executando"] .estadoNo { color: var(--course-accent, var(--accent)); } .no[data-estado="concluido"] .estadoNo { color: var(--success); } .no[data-estado="reprovado"] .estadoNo { color: var(--danger); } .notaNo { font-size: 14px; white-space: nowrap; color: var(--warning-text, var(--text)); border-top: 1px dashed var(--border); padding-top: 0.2rem; } /* Leituras ao vivo: uma única região de status. */ .status { display: grid; gap: 0.6rem; padding: 0.9rem 1rem; border: 1px solid var(--border); border-radius: var(--radius, 8px); background: var(--bg-white); } .narracao { margin: 0; min-height: 2.6em; font-size: 14px; line-height: 1.5; color: var(--text); } .leituras { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.5rem 0.9rem; margin: 0; } .leitura { display: grid; gap: 0.1rem; margin: 0; padding: 0.5rem 0.6rem; border-radius: var(--radius, 8px); background: var(--bg-muted); } .leitura dt { font-size: 13px; color: var(--text-muted); } .leitura dd { margin: 0; font-size: 15px; font-weight: 700; font-variant-numeric: tabular-nums; color: var(--text); } .nota { margin: 0; font-size: 13px; line-height: 1.5; color: var(--text-muted); }