Laboratório ao vivo "painel-denso" 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 ===='. ==== PainelDensoLab.tsx ==== "use client"; import { useCallback, useEffect, useId, useMemo, useRef, useState, type CSSProperties } from "react"; import type { EChartsOption } from "echarts"; import type { EChartsType } from "echarts/core"; import type { LiveLabMeta } from "./registry"; import styles from "./PainelDenso.module.css"; /** * Laboratório "painel-denso": um painel grande, com até 12 visões coordenadas * e dados fictícios da agência Mirante, montado como o guia do capítulo ensina. * * - O ECharts só desce por `import()` quando o painel entra na tela * (IntersectionObserver), e as instâncias saem com `dispose` quando ele sai. * - Cor só por token do CSS: a paleta de 8 dos dois temas vive no módulo CSS e o * JavaScript lê os valores com getComputedStyle. Nenhum hexadecimal aqui. * - Movimento com função: entrada em cadeia, transição ao filtrar, morph entre * barra e mapa de área, destaque, contagem do número e reprodução do tempo com * pausa. `prefers-reduced-motion`, o botão do curso (`html[data-motion]`) e o * controle do painel entregam direto o estado final. * - Uma tabela equivalente fica logo abaixo das visões e leva ao mesmo filtro * pelo teclado. */ type Canal = (typeof CANAIS)[number]; type Tema = "claro" | "escuro"; type Movimento = "funcao" | "reduzido"; type Densidade = 6 | 9 | 12; type ModoCanais = "barras" | "mapa"; const CANAIS = ["Busca", "Social", "E-mail", "Indicação", "Afiliados", "Eventos"] as const; const SEMANAS = 26; const META_SEMANAL = 340; const PASSO_DA_REPRODUCAO_MS = 500; const INTERVALO_DA_ENTRADA_MS = 80; /** Gerador com semente fixa (mulberry32): os mesmos números a cada visita. */ function criarSorteio(semente: number): () => number { let s = semente >>> 0; return () => { s = (s + 0x6d2b79f5) >>> 0; let t = s; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } const PERFIS: Record = { Busca: { base: 112, tendencia: 0.5, custo: 18 }, Social: { base: 86, tendencia: 1.1, custo: 26 }, "E-mail": { base: 58, tendencia: 0.2, custo: 6 }, Indicação: { base: 34, tendencia: 0.7, custo: 4 }, Afiliados: { base: 29, tendencia: -0.2, custo: 21 }, Eventos: { base: 24, tendencia: -0.45, custo: 44 }, }; function gerarSerie(canal: Canal, semente: number, recuo: number): number[] { const sorteio = criarSorteio(semente); const { base, tendencia } = PERFIS[canal]; return Array.from({ length: SEMANAS }, (_, i) => { const onda = Math.sin((i + semente) / 3.4) * base * 0.09; const ruido = (sorteio() - 0.5) * base * 0.16; return Math.max(2, Math.round(base * recuo + tendencia * i + onda + ruido)); }); } const LEADS = Object.fromEntries(CANAIS.map((c, i) => [c, gerarSerie(c, 11 + i * 7, 1)])) as Record; const ANTERIOR = Object.fromEntries(CANAIS.map((c, i) => [c, gerarSerie(c, 53 + i * 5, 0.88)])) as Record; const ROTULOS_SEMANA = Array.from({ length: SEMANAS }, (_, i) => `S${i + 1}`); const somar = (lista: number[]) => lista.reduce((acc, v) => acc + v, 0); const totalDoCanal = (c: Canal) => somar(LEADS[c]); const numero = (v: number) => Math.round(v).toLocaleString("pt-BR"); const porcento = (v: number, casas = 0) => `${v.toFixed(casas).replace(".", ",")}%`; const sinal = (v: number, texto: string) => `${v > 0 ? "+" : v < 0 ? "−" : ""}${texto}`; const ORDEM_POR_TOTAL: Canal[] = [...CANAIS].sort((a, b) => totalDoCanal(b) - totalDoCanal(a)); const TOTAL_GERAL = somar(CANAIS.map(totalDoCanal)); const META_DO_SEMESTRE = META_SEMANAL * SEMANAS; const canaisDoFiltro = (filtro: Canal | null): readonly Canal[] => (filtro ? [filtro] : CANAIS); const leadsPorSemana = (filtro: Canal | null) => Array.from({ length: SEMANAS }, (_, i) => somar(canaisDoFiltro(filtro).map((c) => LEADS[c][i]))); const anteriorPorSemana = (filtro: Canal | null) => Array.from({ length: SEMANAS }, (_, i) => somar(canaisDoFiltro(filtro).map((c) => ANTERIOR[c][i]))); const trimestreAtual = (c: Canal) => somar(LEADS[c].slice(13)); const trimestreAnterior = (c: Canal) => somar(LEADS[c].slice(0, 13)); const variacaoDoTrimestre = (c: Canal) => (trimestreAtual(c) / trimestreAnterior(c) - 1) * 100; const custoPorSemana = (filtro: Canal | null) => Array.from({ length: SEMANAS }, (_, i) => { const canais = canaisDoFiltro(filtro); const leads = somar(canais.map((c) => LEADS[c][i])); const gasto = somar(canais.map((c) => LEADS[c][i] * PERFIS[c].custo * (1 + i * 0.011))); return Math.round((gasto / leads) * 10) / 10; }); const mediaMovel = (lista: number[], janela: number) => lista.map((_, i) => { const trecho = lista.slice(Math.max(0, i - janela + 1), i + 1); return Math.round((somar(trecho) / trecho.length) * 10) / 10; }); /** Seis períodos de cerca de um mês, para a participação a 100%. */ const MESES = Array.from({ length: 6 }, (_, m) => ({ rotulo: `Mês ${m + 1}`, inicio: Math.round((m * SEMANAS) / 6), fim: Math.round(((m + 1) * SEMANAS) / 6), })); const participacaoNoMes = (c: Canal, m: (typeof MESES)[number]) => { const doCanal = somar(LEADS[c].slice(m.inicio, m.fim)); const doMes = somar(CANAIS.map((k) => somar(LEADS[k].slice(m.inicio, m.fim)))); return Math.round((doCanal / doMes) * 1000) / 10; }; interface Cores { series: Record; superficie: string; texto: string; suave: string; grade: string; neutro: string; seqInicio: string; seqFim: string; fonte: string; } function lerCores(el: HTMLElement): Cores { const estilo = getComputedStyle(el); const token = (nome: string) => estilo.getPropertyValue(nome).trim(); return { series: Object.fromEntries(CANAIS.map((c, i) => [c, token(`--pd-serie-${i + 1}`)])) as Record, superficie: token("--pd-superficie"), texto: token("--pd-texto"), suave: token("--pd-suave"), grade: token("--pd-grade"), neutro: token("--pd-neutro"), seqInicio: token("--pd-seq-inicio"), seqFim: token("--pd-seq-fim"), fonte: estilo.fontFamily, }; } /** As 12 visões, na ordem da decisão: a densidade mostra as N primeiras. */ const VISOES = [ "area", "custo", "canais", "calor", "cascata", "participacao", ...ORDEM_POR_TOTAL.map((c) => `mini-${c}`), ]; interface Contexto { cores: Cores; filtro: Canal | null; animar: boolean; atraso: number; letra: number; estreito: boolean; modoCanais: ModoCanais; descricao: string; } function opcaoBase(ctx: Contexto): EChartsOption { const { cores, letra } = ctx; return { backgroundColor: "transparent", textStyle: { fontFamily: cores.fonte, fontSize: letra, color: cores.texto }, animation: ctx.animar, animationDuration: 400, animationEasing: "cubicOut", animationDelay: ctx.atraso, animationDurationUpdate: 400, animationEasingUpdate: "cubicInOut", animationDelayUpdate: 0, stateAnimation: { duration: 200, easing: "cubicOut" }, aria: { enabled: true, label: { description: ctx.descricao } }, }; } function eixoDeTempo(ctx: Contexto, mostrar = true): NonNullable { const { cores, letra } = ctx; return { type: "category", data: ROTULOS_SEMANA, boundaryGap: false, axisTick: { show: false }, axisLine: { lineStyle: { color: cores.grade } }, axisLabel: { show: mostrar, color: cores.suave, fontSize: letra, hideOverlap: true }, }; } function eixoDeValor(ctx: Contexto, extra: Record = {}): NonNullable { const { cores, letra } = ctx; return { type: "value", min: 0, splitLine: { lineStyle: { color: cores.grade } }, axisLabel: { color: cores.suave, fontSize: letra }, ...extra, } as NonNullable; } function dicaBase(ctx: Contexto) { const { cores, letra } = ctx; return { confine: true, backgroundColor: cores.superficie, borderColor: cores.grade, textStyle: { color: cores.texto, fontSize: letra }, transitionDuration: 0.15, }; } /** Dica rica: a linha inteira do corte, com total, participação e variação. */ function dicaDaSemana(params: unknown, filtro: Canal | null): string { const lista = (Array.isArray(params) ? params : [params]) as { dataIndex: number }[]; const i = lista[0]?.dataIndex ?? 0; const canais = canaisDoFiltro(filtro); const total = somar(canais.map((c) => LEADS[c][i])); const anterior = somar(canais.map((c) => ANTERIOR[c][i])); const linhas = canais .map((c) => { const v = LEADS[c][i]; return `${c}${numero(v)}${porcento((v / total) * 100)}`; }) .join(""); const variacao = (total / anterior - 1) * 100; return `Semana ${i + 1}${linhas}
Total ${numero(total)}, ${sinal(variacao, porcento(Math.abs(variacao)))} sobre o período anterior
`; } const opcoes: Record EChartsOption> = { area(ctx) { const { cores, filtro, letra, estreito } = ctx; const direita = estreito ? 74 : 84; return { ...opcaoBase(ctx), grid: { left: 44, right: direita, top: 18, bottom: 28 }, tooltip: { ...dicaBase(ctx), trigger: "axis", axisPointer: { type: "line", lineStyle: { color: cores.suave } }, formatter: (p: unknown) => dicaDaSemana(p, filtro), }, dataZoom: [{ type: "inside", xAxisIndex: 0, filterMode: "none", disabled: estreito, zoomOnMouseWheel: "ctrl", moveOnMouseWheel: false }], xAxis: eixoDeTempo(ctx), yAxis: eixoDeValor(ctx), series: [ ...CANAIS.map((c) => { const ligado = !filtro || filtro === c; return { id: c, name: c, type: "line" as const, stack: "leads", symbol: "none", triggerEvent: true, lineStyle: { width: 2, color: cores.superficie }, areaStyle: { color: cores.series[c], opacity: 1 }, itemStyle: { color: cores.series[c] }, emphasis: { focus: "series" as const }, data: LEADS[c].map((v) => (ligado ? v : 0)), endLabel: { show: ligado, formatter: c, color: cores.texto, fontSize: letra, distance: 6 }, labelLayout: { moveOverlap: "shiftY" as const, hideOverlap: false }, }; }), { id: "anterior", name: "Período anterior", type: "line", symbol: "none", silent: true, lineStyle: { color: cores.texto, width: 2, type: "dashed", opacity: 0.75 }, data: anteriorPorSemana(filtro), markLine: filtro ? { data: [] } : { symbol: "none", silent: true, lineStyle: { color: cores.texto, width: 1.5, type: "solid" }, label: { formatter: `Meta ${META_SEMANAL}`, position: "insideEndTop", color: cores.texto, fontSize: letra }, data: [{ yAxis: META_SEMANAL }], }, }, ], }; }, custo(ctx) { const { cores, filtro, letra, estreito } = ctx; const serie = custoPorSemana(filtro); const corDaLinha = filtro ? cores.series[filtro] : cores.texto; return { ...opcaoBase(ctx), grid: { left: 44, right: estreito ? 74 : 84, top: 16, bottom: 62 }, tooltip: { ...dicaBase(ctx), trigger: "axis", axisPointer: { type: "line", lineStyle: { color: cores.suave } }, valueFormatter: (v: unknown) => `R$ ${String(v).replace(".", ",")}` }, dataZoom: [ { type: "slider", xAxisIndex: 0, filterMode: "none", height: 30, bottom: 6, textStyle: { color: cores.suave, fontSize: letra }, borderColor: cores.grade, fillerColor: `${cores.neutro}55`, dataBackground: { lineStyle: { color: cores.suave }, areaStyle: { color: cores.neutro } }, handleStyle: { color: cores.suave, borderColor: cores.suave } }, ], xAxis: eixoDeTempo(ctx), /* Linha não precisa partir do zero: a escala acompanha os dados, e o título diz a variação. */ yAxis: eixoDeValor(ctx, { min: undefined, scale: true, axisLabel: { color: cores.suave, fontSize: letra, formatter: "R$ {value}" } }), series: [ { id: "custo", name: "Custo por lead", type: "line", symbol: "none", sampling: "lttb", lineStyle: { width: 1.5, color: cores.suave }, itemStyle: { color: cores.suave }, data: serie, }, { id: "media", name: "Média de 4 semanas", type: "line", symbol: "none", lineStyle: { width: 3, color: corDaLinha }, itemStyle: { color: corDaLinha }, data: mediaMovel(serie, 4), endLabel: { show: true, color: cores.texto, fontSize: letra, formatter: (p: { value?: unknown }) => `R$ ${String(p.value).replace(".", ",")}` }, }, ], }; }, canais(ctx) { const { cores, filtro, letra, modoCanais } = ctx; const corDe = (c: Canal) => (!filtro || filtro === c ? cores.series[c] : cores.neutro); if (modoCanais === "mapa") { return { ...opcaoBase(ctx), animationDurationUpdate: 550, tooltip: { ...dicaBase(ctx), trigger: "item", formatter: (p: unknown) => dicaDoCanal(p) }, series: [ { id: "canais", type: "treemap", universalTransition: true, roam: false, nodeClick: false, breadcrumb: { show: false }, left: 0, right: 0, top: 0, bottom: 0, itemStyle: { borderColor: cores.superficie, borderWidth: 2, gapWidth: 2 }, label: { show: true, /* Rótulo em caixinha da superfície: contraste de texto garantido sobre qualquer cor da série. */ color: cores.texto, backgroundColor: cores.superficie, padding: [3, 5], borderRadius: 4, fontSize: letra, fontWeight: 600, formatter: (p: { name?: string; value?: unknown }) => `${p.name}\n${numero(Number(p.value))}`, }, data: ORDEM_POR_TOTAL.map((c) => ({ name: c, value: totalDoCanal(c), groupId: c, itemStyle: { color: corDe(c) } })), }, ], }; } return { ...opcaoBase(ctx), animationDurationUpdate: 550, grid: { left: 76, right: 56, top: 8, bottom: 8 }, tooltip: { ...dicaBase(ctx), trigger: "item", formatter: (p: unknown) => dicaDoCanal(p) }, xAxis: { type: "value", min: 0, show: false }, yAxis: { type: "category", inverse: true, data: ORDEM_POR_TOTAL as unknown as string[], axisTick: { show: false }, axisLine: { lineStyle: { color: cores.grade } }, axisLabel: { color: cores.texto, fontSize: letra }, }, series: [ { id: "canais", type: "bar", universalTransition: true, barMaxWidth: 38, label: { show: true, position: "right", color: cores.texto, fontSize: letra, formatter: (p: { value?: unknown }) => numero(Number((p.value as { value?: number })?.value ?? p.value)) }, data: ORDEM_POR_TOTAL.map((c) => ({ value: totalDoCanal(c), name: c, groupId: c, itemStyle: { color: corDe(c) } })), }, ], }; }, calor(ctx) { const { cores, filtro, letra } = ctx; const valores = CANAIS.flatMap((c) => LEADS[c]); return { ...opcaoBase(ctx), grid: { left: 76, right: 12, top: 8, bottom: 28 }, tooltip: { ...dicaBase(ctx), trigger: "item", formatter: (p: unknown) => { const d = (p as { data: [number, number, number | string] }).data; return `${ORDEM_POR_TOTAL[d[1]]}, semana ${d[0] + 1}
${d[2] === "-" ? "Fora do filtro" : `${numero(Number(d[2]))} leads`}`; }, }, xAxis: { type: "category", data: ROTULOS_SEMANA, axisTick: { show: false }, axisLine: { show: false }, splitArea: { show: false }, axisLabel: { color: cores.suave, fontSize: letra, hideOverlap: true } }, yAxis: { type: "category", inverse: true, data: ORDEM_POR_TOTAL as unknown as string[], axisTick: { show: false }, axisLine: { show: false }, axisLabel: { color: cores.texto, fontSize: letra } }, visualMap: { show: false, min: Math.min(...valores), max: Math.max(...valores), dimension: 2, inRange: { color: [cores.seqInicio, cores.seqFim] } }, series: [ { id: "calor", type: "heatmap", itemStyle: { borderColor: cores.superficie, borderWidth: 1 }, emphasis: { itemStyle: { borderColor: cores.texto, borderWidth: 2 } }, data: ORDEM_POR_TOTAL.flatMap((c, y) => LEADS[c].map((v, x) => [x, y, !filtro || filtro === c ? v : "-"])), }, ], }; }, cascata(ctx) { const { cores, filtro, letra } = ctx; const canais = filtro ? [filtro] : ORDEM_POR_TOTAL; const inicio = somar(canais.map(trimestreAnterior)); let corrente = inicio; const degraus = canais.map((c) => { const delta = trimestreAtual(c) - trimestreAnterior(c); const base = delta >= 0 ? corrente : corrente + delta; corrente += delta; return { c, delta, base }; }); const nomes = ["Anterior", ...canais, "Atual"]; return { ...opcaoBase(ctx), grid: { left: 70, right: 60, top: 8, bottom: 8 }, tooltip: { ...dicaBase(ctx), trigger: "item", formatter: (p: unknown) => { const i = (p as { dataIndex: number }).dataIndex; if (i === 0) return `Trimestre anterior
${numero(inicio)} leads`; if (i === nomes.length - 1) return `Trimestre atual
${numero(corrente)} leads`; const d = degraus[i - 1]; return `${d.c}
${sinal(d.delta, numero(Math.abs(d.delta)))} leads no trimestre`; }, }, xAxis: { type: "value", min: 0, show: false }, yAxis: { type: "category", inverse: true, data: nomes, axisTick: { show: false }, axisLine: { lineStyle: { color: cores.grade } }, axisLabel: { color: cores.texto, fontSize: letra } }, series: [ { id: "apoio", type: "bar", stack: "cascata", silent: true, itemStyle: { color: "transparent" }, emphasis: { disabled: true }, data: [0, ...degraus.map((d) => d.base), 0], }, { id: "degrau", type: "bar", stack: "cascata", barCategoryGap: "22%", label: { show: true, position: "right", color: cores.texto, fontSize: letra, formatter: (p: { dataIndex: number }) => { if (p.dataIndex === 0) return numero(inicio); if (p.dataIndex === nomes.length - 1) return numero(corrente); const d = degraus[p.dataIndex - 1].delta; return sinal(d, numero(Math.abs(d))); }, }, data: [ { value: inicio, itemStyle: { color: cores.suave } }, ...degraus.map((d) => ({ value: Math.abs(d.delta), itemStyle: { color: cores.series[d.c] } })), { value: corrente, itemStyle: { color: cores.suave } }, ], }, ], }; }, participacao(ctx) { const { cores, filtro, letra } = ctx; return { ...opcaoBase(ctx), grid: { left: 40, right: 76, top: 10, bottom: 26 }, tooltip: { ...dicaBase(ctx), trigger: "axis", axisPointer: { type: "shadow" }, valueFormatter: (v: unknown) => porcento(Number(v), 1) }, xAxis: { type: "category", data: MESES.map((m) => m.rotulo.replace("Mês ", "M")), axisTick: { show: false }, axisLine: { lineStyle: { color: cores.grade } }, axisLabel: { color: cores.suave, fontSize: letra } }, yAxis: eixoDeValor(ctx, { max: 100, axisLabel: { color: cores.suave, fontSize: letra, formatter: "{value}%" } }), series: CANAIS.map((c) => { const ligado = !filtro || filtro === c; return { id: c, name: c, type: "bar" as const, stack: "cem", barCategoryGap: "14%", itemStyle: { color: ligado ? cores.series[c] : cores.neutro, borderColor: cores.superficie, borderWidth: 2 }, emphasis: { focus: "series" as const }, labelLayout: { moveOverlap: "shiftY" as const }, data: MESES.map((m, i) => ({ value: participacaoNoMes(c, m), label: i === MESES.length - 1 && ligado ? { show: true, position: "right" as const, formatter: c, color: cores.texto, fontSize: letra } : { show: false }, })), }; }), }; }, }; function dicaDoCanal(p: unknown): string { const nome = (p as { name: Canal }).name; const total = totalDoCanal(nome); const variacao = variacaoDoTrimestre(nome); return `${nome}
${numero(total)} leads, ${porcento((total / TOTAL_GERAL) * 100)} do total
${sinal(variacao, porcento(Math.abs(variacao)))} no último trimestre`; } function opcaoDoMini(canal: Canal, ctx: Contexto): EChartsOption { const { cores, filtro, letra } = ctx; const ligado = !filtro || filtro === canal; const cor = ligado ? cores.series[canal] : cores.neutro; const teto = Math.max(...CANAIS.flatMap((c) => LEADS[c])); return { ...opcaoBase(ctx), grid: { left: 30, right: 8, top: 8, bottom: 22 }, tooltip: { ...dicaBase(ctx), trigger: "axis", axisPointer: { type: "line", lineStyle: { color: cores.suave } } }, dataZoom: [{ type: "inside", xAxisIndex: 0, filterMode: "none", disabled: true }], xAxis: { ...(eixoDeTempo(ctx) as object), axisLabel: { color: cores.suave, fontSize: letra, interval: SEMANAS - 2, hideOverlap: true } } as NonNullable, yAxis: eixoDeValor(ctx, { max: teto, interval: teto, axisLabel: { color: cores.suave, fontSize: letra } }), series: [ { id: canal, name: canal, type: "line", symbol: "none", lineStyle: { width: 2, color: cor }, itemStyle: { color: cor }, areaStyle: { color: cor, opacity: 0.2 }, data: LEADS[canal], }, ], }; } function maiorDeltaDeParticipacao(): { canal: Canal; pontos: number } { const [primeiro, ultimo] = [MESES[0], MESES[MESES.length - 1]]; const deltas = CANAIS.map((canal) => ({ canal, pontos: participacaoNoMes(canal, ultimo) - participacaoNoMes(canal, primeiro) })); return deltas.reduce((a, b) => (b.pontos > a.pontos ? b : a)); } /** Título de cada visão: a conclusão calculada do dado, com o filtro aplicado. */ function tituloDaVisao(id: string, filtro: Canal | null): string { const semanas = leadsPorSemana(filtro); switch (id) { case "area": { if (filtro) return `${filtro} rendeu ${numero(somar(semanas))} leads em ${SEMANAS} semanas`; const batidas = semanas.filter((v) => v >= META_SEMANAL).length; return `Os leads bateram a meta em ${batidas} de ${SEMANAS} semanas`; } case "custo": { const custo = custoPorSemana(filtro); const media = (l: number[]) => somar(l) / l.length; const variacao = (media(custo.slice(13)) / media(custo.slice(0, 13)) - 1) * 100; return `O custo por lead ${variacao >= 0 ? "subiu" : "caiu"} ${porcento(Math.abs(variacao), 1)} no trimestre`; } case "canais": return filtro ? `${filtro} responde por ${porcento((totalDoCanal(filtro) / TOTAL_GERAL) * 100)} dos leads` : `${ORDEM_POR_TOTAL[0]} lidera com ${porcento((totalDoCanal(ORDEM_POR_TOTAL[0]) / TOTAL_GERAL) * 100)} dos leads`; case "calor": { const canais = canaisDoFiltro(filtro); const pico = canais .flatMap((c) => LEADS[c].map((v, i) => ({ c, v, i }))) .reduce((a, b) => (b.v > a.v ? b : a)); return `O pico veio de ${pico.c}, na semana ${pico.i + 1}`; } case "cascata": { const canais = canaisDoFiltro(filtro); const delta = somar(canais.map((c) => trimestreAtual(c) - trimestreAnterior(c))); return `De um trimestre para o outro: ${sinal(delta, numero(Math.abs(delta)))} leads`; } case "participacao": { const maior = maiorDeltaDeParticipacao(); return `${maior.canal} ganhou ${porcento(maior.pontos, 1).replace("%", "")} pontos de participação em seis meses`; } default: { const canal = id.replace("mini-", "") as Canal; return `${canal}: ${numero(totalDoCanal(canal))} leads`; } } } function classeDaVisao(id: string): string { if (id.startsWith("mini-")) return styles.visaoMini; const mapa: Record = { area: styles.visaoArea, custo: styles.visaoCusto, canais: styles.visaoCanais, calor: styles.visaoCalor, cascata: styles.visaoCascata, participacao: styles.visaoParticipacao, }; return mapa[id] ?? ""; } function Sparkline({ valores, canal, ligado }: { valores: number[]; canal: Canal; ligado: boolean }) { const maior = Math.max(...valores); const menor = Math.min(...valores); const pontos = valores .map((v, i) => `${((i / (valores.length - 1)) * 96 + 2).toFixed(1)},${(22 - ((v - menor) / (maior - menor || 1)) * 20).toFixed(1)}`) .join(" "); const estilo = { "--pd-traco": ligado ? `var(--pd-serie-${CANAIS.indexOf(canal) + 1})` : "var(--pd-neutro)" } as CSSProperties; return ( ); } const prefereReduzido = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches || document.documentElement.dataset.motion === "reduce" || document.documentElement.dataset.llxMotion === "reduce"; const prefereEscuro = () => { const tema = document.documentElement.dataset.theme; if (tema === "dark") return true; if (tema === "light") return false; return window.matchMedia("(prefers-color-scheme: dark)").matches; }; type Biblioteca = typeof import("echarts/core"); /** A biblioteca desce só aqui, quando o painel entra na tela. */ async function carregarEcharts(): Promise { const [core, graficos, componentes, recursos, desenho] = await Promise.all([ import("echarts/core"), import("echarts/charts"), import("echarts/components"), import("echarts/features"), import("echarts/renderers"), ]); core.use([ graficos.LineChart, graficos.BarChart, graficos.HeatmapChart, graficos.TreemapChart, componentes.GridComponent, componentes.TooltipComponent, componentes.DataZoomComponent, componentes.VisualMapComponent, componentes.MarkLineComponent, componentes.AriaComponent, recursos.UniversalTransition, recursos.LabelLayout, desenho.CanvasRenderer, ]); return core; } export default function Lab() { const uid = useId(); const raiz = useRef(null); const caixas = useRef(new Map()); const instancias = useRef(new Map()); const bibliotecaRef = useRef(null); const observadorDeTamanho = useRef(null); const filtroRef = useRef(null); const grupo = useMemo(() => `painel-denso-${uid}`, [uid]); const [biblioteca, setBiblioteca] = useState(null); const [naTela, setNaTela] = useState(false); const [falhou, setFalhou] = useState(false); const [densidade, setDensidade] = useState(6); const [tema, setTema] = useState(null); const [temaDoSite, setTemaDoSite] = useState("claro"); const [movimento, setMovimento] = useState(null); const [reduzidoNoSistema, setReduzidoNoSistema] = useState(false); const [filtro, setFiltro] = useState(null); const [modoCanais, setModoCanais] = useState("barras"); const [semana, setSemana] = useState(null); const [tocando, setTocando] = useState(false); const [parede, setParede] = useState(false); const [telaFalsa, setTelaFalsa] = useState(false); const [largura, setLargura] = useState(1000); const [aviso, setAviso] = useState("Filtro desligado: todas as visões mostram os seis canais."); const temaAtivo: Tema = tema ?? temaDoSite; const movimentoAtivo: Movimento = movimento ?? (reduzidoNoSistema ? "reduzido" : "funcao"); const animar = movimentoAtivo === "funcao"; const visoes = useMemo(() => VISOES.slice(0, densidade), [densidade]); useEffect(() => { filtroRef.current = filtro; }, [filtro]); /* Preferências do site e do sistema, relidas quando mudam. */ useEffect(() => { const ler = () => { setTemaDoSite(prefereEscuro() ? "escuro" : "claro"); setReduzidoNoSistema(prefereReduzido()); }; ler(); const obs = new MutationObserver(ler); obs.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme", "data-motion", "data-llx-motion", "class"] }); const esquema = window.matchMedia("(prefers-color-scheme: dark)"); const reduzir = window.matchMedia("(prefers-reduced-motion: reduce)"); esquema.addEventListener("change", ler); reduzir.addEventListener("change", ler); return () => { obs.disconnect(); esquema.removeEventListener("change", ler); reduzir.removeEventListener("change", ler); }; }, []); /* Entrou na tela: carrega a biblioteca. Saiu: as instâncias são descartadas. */ useEffect(() => { const el = raiz.current; if (!el) return; if (typeof IntersectionObserver === "undefined") { setNaTela(true); return; } const obs = new IntersectionObserver(([entrada]) => setNaTela(entrada.isIntersecting), { rootMargin: "240px 0px" }); obs.observe(el); return () => obs.disconnect(); }, []); useEffect(() => { if (!naTela || bibliotecaRef.current) return; let cancelado = false; carregarEcharts() .then((lib) => { if (cancelado) return; bibliotecaRef.current = lib; setBiblioteca(() => lib); }) .catch(() => { if (!cancelado) setFalhou(true); }); return () => { cancelado = true; }; }, [naTela]); const descartar = useCallback((id?: string) => { for (const [chave, chart] of instancias.current) { if (id && chave !== id) continue; const caixa = caixas.current.get(chave); if (caixa) observadorDeTamanho.current?.unobserve(caixa); chart.dispose(); instancias.current.delete(chave); } }, []); useEffect(() => { observadorDeTamanho.current = new ResizeObserver((entradas) => { for (const entrada of entradas) { const id = (entrada.target as HTMLElement).dataset.visao; const chart = id ? instancias.current.get(id) : undefined; chart?.resize({ animation: { duration: 0 } }); } }); const el = raiz.current; const medirLargura = el ? new ResizeObserver(([e]) => setLargura(Math.round(e.contentRect.width))) : null; if (el) medirLargura?.observe(el); return () => { observadorDeTamanho.current?.disconnect(); medirLargura?.disconnect(); descartar(); }; }, [descartar]); useEffect(() => { if (!naTela) descartar(); }, [naTela, descartar]); const alternarFiltro = useCallback((canal: Canal) => { const proximo = filtroRef.current === canal ? null : canal; filtroRef.current = proximo; setFiltro(proximo); setAviso(proximo ? `Filtro ligado: todas as visões mostram só ${proximo}.` : "Filtro desligado: todas as visões mostram os seis canais."); }, []); const limparFiltro = useCallback(() => { filtroRef.current = null; setFiltro(null); setAviso("Filtro desligado: todas as visões mostram os seis canais."); }, []); /* Sincroniza instâncias e opções com o estado do painel. */ useEffect(() => { if (!biblioteca || !naTela || !raiz.current) return; const cores = lerCores(raiz.current); for (const id of [...instancias.current.keys()]) if (!visoes.includes(id)) descartar(id); visoes.forEach((id, ordem) => { const caixa = caixas.current.get(id); if (!caixa) return; let chart = instancias.current.get(id); const nova = !chart; if (!chart) { chart = biblioteca.init(caixa, undefined, { renderer: "canvas" }); if (id === "area" || id === "custo" || id.startsWith("mini-")) chart.group = grupo; const canalDoMini = id.startsWith("mini-") ? (id.replace("mini-", "") as Canal) : null; chart.on("click", (p: { name?: string; seriesName?: string; data?: unknown }) => { let canal: string | undefined = canalDoMini ?? undefined; if (id === "canais") canal = p.name; if (id === "area" || id === "participacao") canal = p.seriesName; if (id === "calor") canal = ORDEM_POR_TOTAL[(p.data as number[])[1]]; if (id === "cascata") canal = p.name; if (canal && (CANAIS as readonly string[]).includes(canal)) alternarFiltro(canal as Canal); }); instancias.current.set(id, chart); observadorDeTamanho.current?.observe(caixa); } const ctx: Contexto = { cores, filtro, animar, /* Cadeia de entrada: 80 ms entre visões e 400 ms cada, até 1.280 ms em 12 visões. */ atraso: nova && animar ? ordem * INTERVALO_DA_ENTRADA_MS : 0, letra: parede ? 15 : 12, estreito: largura < 560, modoCanais, descricao: `${tituloDaVisao(id, filtro)}. Dados fictícios da agência Mirante. A tabela logo abaixo do painel traz os mesmos números.`, }; const opcao = id.startsWith("mini-") ? opcaoDoMini(id.replace("mini-", "") as Canal, ctx) : opcoes[id](ctx); /* A troca de barras para mapa precisa substituir a série inteira para o morph acontecer. */ chart.setOption(opcao, id === "canais" ? { notMerge: true } : undefined); }); biblioteca.connect(grupo); }, [biblioteca, naTela, visoes, filtro, temaAtivo, animar, modoCanais, parede, largura, grupo, descartar, alternarFiltro]); /* Reprodução do tempo: um passo a cada 500 ms, com pausa e posição à vista. */ useEffect(() => { if (!tocando) return; const relogio = window.setInterval(() => { setSemana((atual) => { const proxima = (atual ?? 0) + 1; if (proxima >= SEMANAS) { setTocando(false); return SEMANAS; } return proxima; }); }, PASSO_DA_REPRODUCAO_MS); return () => window.clearInterval(relogio); }, [tocando]); useEffect(() => { if (semana === null) return; instancias.current.get("area")?.dispatchAction({ type: "showTip", seriesIndex: 0, dataIndex: semana - 1 }); }, [semana]); useEffect(() => { if (!animar) setTocando(false); }, [animar]); const reproduzir = useCallback(() => { if (!animar) { setSemana(null); setAviso("Movimento reduzido: o painel mostra direto o semestre inteiro."); return; } setSemana((s) => (s === null || s >= SEMANAS ? 1 : s)); setTocando(true); }, [animar]); /* Tela cheia pela Fullscreen API; sem ela (iPhone), o painel cobre a janela. */ useEffect(() => { const aoMudar = () => setParede(document.fullscreenElement === raiz.current); document.addEventListener("fullscreenchange", aoMudar); return () => document.removeEventListener("fullscreenchange", aoMudar); }, []); useEffect(() => { if (!telaFalsa) return; const aoTeclar = (e: KeyboardEvent) => { if (e.key === "Escape") { setTelaFalsa(false); setParede(false); } }; document.addEventListener("keydown", aoTeclar); return () => document.removeEventListener("keydown", aoTeclar); }, [telaFalsa]); const alternarTelaCheia = useCallback(async () => { if (telaFalsa) { setTelaFalsa(false); setParede(false); return; } if (document.fullscreenElement) { await document.exitFullscreen(); return; } const el = raiz.current; if (el?.requestFullscreen && document.fullscreenEnabled) { try { await el.requestFullscreen(); return; } catch { /* Cai no modo que cobre a janela. */ } } setTelaFalsa(true); setParede(true); }, [telaFalsa]); /* Faixa 1: manchete e número principal, calculados do dado. */ const ate = semana ?? SEMANAS; const leads = somar(leadsPorSemana(filtro).slice(0, ate)); const metaProporcional = filtro ? Math.round((META_DO_SEMESTRE * totalDoCanal(filtro) * ate) / (TOTAL_GERAL * SEMANAS)) : META_SEMANAL * ate; const anteriorAcumulado = somar(anteriorPorSemana(filtro).slice(0, ate)); const sobreAnterior = (leads / anteriorAcumulado - 1) * 100; const sobreMeta = (leads / metaProporcional - 1) * 100; const piores = canaisDoFiltro(filtro).filter((c) => variacaoDoTrimestre(c) < 0); const pior = piores.length ? piores.reduce((a, b) => (variacaoDoTrimestre(b) < variacaoDoTrimestre(a) ? b : a)) : null; const lider = filtro ?? ORDEM_POR_TOTAL[0]; const liderAcumulado = somar(LEADS[lider].slice(0, ate)); const totalAcumulado = somar(leadsPorSemana(null).slice(0, ate)); const manchete = filtro ? `${filtro} trouxe ${porcento((liderAcumulado / totalAcumulado) * 100)} dos ${numero(totalAcumulado)} leads até a semana ${ate}.` : `${lider} puxa o volume, com ${porcento((liderAcumulado / totalAcumulado) * 100)} dos ${numero(totalAcumulado)} leads até a semana ${ate}.`; /* Contagem do número: só quando o valor muda, de 400 a 600 ms. */ const [numeroNaTela, setNumeroNaTela] = useState(leads); const numeroAnterior = useRef(leads); useEffect(() => { const de = numeroAnterior.current; numeroAnterior.current = leads; if (!animar || de === leads) { setNumeroNaTela(leads); return; } const duracao = tocando ? 400 : 600; const inicio = performance.now(); let quadro = 0; const passo = (agora: number) => { const t = Math.min(1, (agora - inicio) / duracao); const suave = 1 - (1 - t) ** 3; setNumeroNaTela(Math.round(de + (leads - de) * suave)); if (t < 1) quadro = requestAnimationFrame(passo); }; quadro = requestAnimationFrame(passo); return () => cancelAnimationFrame(quadro); }, [leads, animar, tocando]); const classes = [styles.painel, parede ? styles.parede : "", telaFalsa ? styles.telaFalsa : ""].filter(Boolean).join(" "); const visaoDoGrafico = (id: string) => (
{id === "canais" ? ( {tituloDaVisao(id, filtro)} ) : ( tituloDaVisao(id, filtro) )}
{ if (el) caixas.current.set(id, el); else caixas.current.delete(id); }} />
); return (

Agência fictícia Mirante. Todos os números são inventados para este exercício.

{parede ? (
) : (
Densidade
{([6, 9, 12] as const).map((n) => ( ))}
Tema
Movimento
Tempo
{semana !== null && !tocando ? ( ) : null}
)}

Regra do filtro: clique num canal em qualquer visão ou na tabela. Vale para todas as visões, um canal por vez, e a participação continua calculada sobre o total.

{aviso}

{falhou ?

O painel não carregou neste aparelho. A tabela abaixo traz os mesmos números.

: null}

Faixa 1: a decisão

{manchete}

{numero(numeroNaTela)} leads, contra uma meta de {numero(metaProporcional)}{semana !== null ? ` até a semana ${semana} de ${SEMANAS}` : ` em ${SEMANAS} semanas`}
  • {sinal(sobreAnterior, porcento(Math.abs(sobreAnterior)))} sobre o período anterior
  • {sinal(sobreMeta, porcento(Math.abs(sobreMeta)))} sobre a meta
  • {pior ? (
  • Piorou: {pior}, {sinal(variacaoDoTrimestre(pior), porcento(Math.abs(variacaoDoTrimestre(pior))))} no último trimestre
  • ) : null}

Faixa 2: o tempo, uma data por coluna

{visoes.slice(0, 3).map(visaoDoGrafico)}

Faixa 3: a comparação entre canais

{visoes.slice(3).map(visaoDoGrafico)}

Os mesmos números em tabela

{ORDEM_POR_TOTAL.map((c) => { const v = variacaoDoTrimestre(c); return ( ); })}
Leads por canal em 26 semanas fictícias, com participação, variação no último trimestre e evolução.
Canal Leads Participação Trimestre Evolução
{numero(totalDoCanal(c))} {porcento((totalDoCanal(c) / TOTAL_GERAL) * 100)} {sinal(v, porcento(Math.abs(v)))}
Total {numero(TOTAL_GERAL)} {porcento(100)}
Ver os leads de cada semana
{CANAIS.map((c) => )} {ROTULOS_SEMANA.map((rotulo, i) => ( {CANAIS.map((c) => )} ))}
Leads por semana e por canal, sem filtro.
Semana{c}
{i + 1}{LEADS[c][i]}

Fonte: dados fictícios gerados neste navegador com semente fixa, semanas 1 a {SEMANAS}.

); } export const meta: LiveLabMeta = { biblioteca: "Apache ECharts", pacote: "echarts", versao: "^6.1.0", docsUrl: "https://echarts.apache.org/handbook/en/get-started/", npmUrl: "https://www.npmjs.com/package/echarts", trechoLinguagem: "ts", trecho: `// A biblioteca desce só quando o painel entra na tela const core = await import("echarts/core"); const { LineChart, BarChart, TreemapChart } = await import("echarts/charts"); const { UniversalTransition } = await import("echarts/features"); // ...componentes e renderizador entram no mesmo core.use([...]) const linha = core.init(caixaDeCima); const custo = core.init(caixaDeBaixo); // Mesma data na mesma coluna: a linha de leitura e o zoom andam juntos linha.group = custo.group = "mirante"; core.connect("mirante"); // Filtro: mesma série, mesmo id, e o desenho caminha em 400 ms linha.setOption({ animation: !movimentoReduzido, animationDurationUpdate: 400, series: canais.map((c) => ({ id: c, stack: "leads", type: "line", data: filtro && filtro !== c ? zeros : leads[c], })), });`, observar: [ "Clique num canal: todas as visões obedecem ao mesmo filtro, e cada área caminha para o novo valor em vez de sumir.", "Passe o dedo ou o mouse na área empilhada: a linha de leitura aparece na mesma semana do custo por lead e dos múltiplos pequenos.", "Troque para mapa de área: os mesmos canais mudam de forma no mesmo espaço, e o movimento mostra que nada foi trocado.", "Ligue Reduzido e o painel entrega o estado final, com os mesmos números.", ], }; ==== PainelDenso.module.css ==== /* Laboratório painel-denso do curso Frontends com Vibecoding. A paleta de 8 vive aqui, por tema, e o JavaScript só lê os tokens. Medida com validate_palette.js em 05/11/2026 sobre as superfícies abaixo. */ .painel { --pd-superficie: #ffffff; --pd-superficie-2: #f3f5f8; --pd-texto: #18212c; --pd-suave: #4d5967; --pd-grade: #d7dde4; --pd-neutro: #c4ccd6; --pd-seq-inicio: #dbe9f6; --pd-seq-fim: #0b4f91; --pd-alerta: #9a3412; --pd-serie-1: #0b63b5; --pd-serie-2: #d55e00; --pd-serie-3: #0a8a62; --pd-serie-4: #a64d9a; --pd-serie-5: #b07a00; --pd-serie-6: #2b8ab3; --pd-serie-7: #c2356e; --pd-serie-8: #5d6bc4; container-type: inline-size; display: grid; gap: 1rem; padding: 0.75rem; border-radius: 10px; background: var(--pd-superficie); color: var(--pd-texto); text-align: start; } .painel[data-tema="escuro"] { --pd-superficie: #182230; --pd-superficie-2: #212d3d; --pd-texto: #eef2f7; --pd-suave: #aab6c5; --pd-grade: #35445a; --pd-neutro: #4a5a70; --pd-seq-inicio: #24384f; --pd-seq-fim: #9fd0f7; --pd-alerta: #f7a472; --pd-serie-1: #4a90d9; --pd-serie-2: #d66b2a; --pd-serie-3: #2c9f70; --pd-serie-4: #c46fb9; --pd-serie-5: #b8861f; --pd-serie-6: #3a9fc7; --pd-serie-7: #d9457a; --pd-serie-8: #7f78e8; } .painel button:focus-visible, .painel summary:focus-visible, .rolagem:focus-visible { outline: 2px solid var(--pd-texto); outline-offset: 2px; } .selo { margin: 0; font-size: 13px; font-weight: 600; color: var(--pd-suave); } .controles { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: stretch; } .grupo { margin: 0; padding: 0.25rem 0.5rem 0.45rem; min-width: 0; border: 1px solid var(--pd-grade); border-radius: 8px; } .grupo legend { padding: 0 0.3rem; font-size: 13px; font-weight: 600; color: var(--pd-suave); } .opcoes { display: flex; flex-wrap: wrap; gap: 0.25rem; } .botao, .botaoPequeno { min-height: 44px; padding: 0 0.75rem; border: 1px solid var(--pd-grade); border-radius: 8px; background: var(--pd-superficie); color: var(--pd-texto); font: inherit; font-size: 14px; line-height: 1.2; cursor: pointer; } .botao[aria-pressed="true"] { border-color: var(--pd-texto); background: var(--pd-texto); color: var(--pd-superficie); font-weight: 600; } .botao[aria-disabled="true"] { color: var(--pd-suave); cursor: default; } .botaoTelaCheia { align-self: flex-end; } .botaoPequeno { flex: none; font-size: 13px; } .faixaDoFiltro { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: center; justify-content: space-between; padding: 0.5rem 0.65rem; border-radius: 8px; background: var(--pd-superficie-2); } .regra { flex: 1 1 18rem; margin: 0; font-size: 14px; line-height: 1.4; text-align: left; } .avisoOculto { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } .faixa { display: grid; gap: 0.5rem; min-width: 0; } .tituloDaFaixa { margin: 0; padding: 0 0 0.25rem; border-bottom: 2px solid var(--pd-grade); font-size: 13px; font-weight: 700; color: var(--pd-suave); } .topo { display: grid; gap: 0.5rem; } .manchete { margin: 0; font-size: 20px; font-weight: 700; line-height: 1.25; text-align: left; } .numeroPrincipal { display: flex; flex-wrap: wrap; gap: 0.2rem 0.6rem; align-items: baseline; } .valor { font-size: 56px; font-weight: 800; line-height: 1.05; font-variant-numeric: tabular-nums; } .denominador { font-size: 15px; color: var(--pd-suave); } .comparacoes { display: flex; flex-wrap: wrap; gap: 0.25rem 1.1rem; margin: 0; padding: 0; list-style: none; font-size: 14px; } .alerta { display: inline-flex; gap: 0.35rem; align-items: center; color: var(--pd-alerta); } .iconeDeAlerta { display: inline-grid; place-items: center; width: 18px; height: 18px; border: 2px solid currentColor; border-radius: 50%; font-size: 12px; font-weight: 800; line-height: 1; } /* Malha: uma coluna no celular, duas no meio e 12 colunas na tela grande. */ .malha { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.75rem; } .visao { grid-column: 1 / -1; display: flex; flex-direction: column; min-width: 0; margin: 0; padding: 0.6rem; border: 1px solid var(--pd-grade); border-radius: 8px; } .visaoMini { grid-column: span 1; } .tituloDaVisao { margin: 0 0 0.35rem; font-size: 14px; font-weight: 650; line-height: 1.3; text-align: left; color: var(--pd-texto); } .tituloComAcao { display: flex; flex-wrap: wrap; gap: 0.4rem 0.6rem; align-items: center; justify-content: space-between; } .grafico { flex: 1; width: 100%; height: 320px; min-height: 300px; } .visaoArea .grafico { height: 480px; } .visaoCalor .grafico { height: 300px; } .visaoMini .grafico { height: 160px; min-height: 140px; } @container (min-width: 640px) { .malha { grid-template-columns: repeat(6, minmax(0, 1fr)); } .visaoCascata, .visaoParticipacao { grid-column: span 3; } .visaoMini { grid-column: span 2; } } @container (min-width: 1100px) { .malha { grid-template-columns: repeat(12, minmax(0, 1fr)); } .topo { grid-template-columns: minmax(0, 5fr) minmax(0, 4fr) minmax(0, 3fr); align-items: start; } .visaoArea, .visaoCusto { grid-column: span 8; } .visaoArea .grafico { height: 560px; } .visaoCanais { grid-column: 9 / span 4; grid-row: 1 / span 2; } .visaoCanais .grafico { height: 100%; min-height: 400px; } .visaoCalor { grid-column: span 6; } .visaoCascata, .visaoParticipacao { grid-column: span 3; } .visaoMini { grid-column: span 2; } } /* Tabela densa com sparkline. */ .rolagem { overflow-x: auto; } .tabela { width: 100%; min-width: 460px; border-collapse: collapse; font-size: 14px; font-variant-numeric: tabular-nums; } .tabela th, .tabela td { padding: 0.3rem 0.5rem; border-bottom: 1px solid var(--pd-grade); font-weight: 400; text-align: left; } .tabela thead th { font-weight: 700; color: var(--pd-suave); } .tabela tfoot th, .tabela tfoot td { font-weight: 700; } .tabela .direita { text-align: right; } .tabela tr[data-ativa] { background: var(--pd-superficie-2); } .botaoDaLinha { display: inline-flex; gap: 0.45rem; align-items: center; min-height: 44px; padding: 0 0.35rem; border: 0; background: none; color: inherit; font: inherit; text-decoration: underline; text-underline-offset: 3px; cursor: pointer; } .botaoDaLinha[aria-pressed="true"] { font-weight: 700; } .amostra { width: 12px; height: 12px; border-radius: 3px; flex: none; } .sparkline { display: block; width: 110px; height: 26px; } .detalhes { font-size: 14px; } .detalhes summary { min-height: 44px; padding: 0.6rem 0; cursor: pointer; } .rodape { margin: 0; font-size: 13px; color: var(--pd-suave); text-align: left; } /* Tela cheia e modo reunião: letra maior e sem cromo. */ .parede { overflow: auto; padding: 1.25rem 1.5rem; border-radius: 0; } .parede .selo, .parede .faixaDoFiltro { display: none; } .parede .manchete { font-size: 32px; } .parede .valor { font-size: 88px; } .parede .denominador, .parede .comparacoes, .parede .tituloDaVisao { font-size: 18px; } .parede .tituloDaFaixa { font-size: 15px; } .saidaDaParede { display: flex; justify-content: flex-end; } .telaFalsa { position: fixed; inset: 0; z-index: 1000; }