← Biblioteca componentes/dados/cards-cotacoes/ficha.md CódigoTela cheia
dados/cards-cotacoes

Cards de cotação

🟢 estavel complexidade média prefixo coto- cotacao acoes bolsa mercado financeiro cards grade sparkline svg tempo-real feed compliance

Incluir · as linhas que se colam num site novo, depois do core (CLAUDE.md §10)

<link rel="stylesheet" href="componentes/dados/cards-cotacoes/estilo.css">
<script src="componentes/dados/cards-cotacoes/script.js"></script>

markup.html · 172 linhas · 8,5 KB

<!-- ==========================================================================
     CARDS DE COTAÇÃO — a grade no feitio de app de ações
     Prefixo: coto-
     Requer: core (tokens, base, motion.css, motion.js) + estilo.css + script.js

     A forma é a do app de Ações do iPhone e a do painel do Google Finance:
     um card por papel, com símbolo, nome, minigráfico, preço e a variação com
     seta. É a leitura que o público já sabe fazer — e é por isso que ela
     ganha da faixa correndo em quase toda página institucional: em card a
     pessoa PROCURA o papel dela; na faixa, ela espera ele passar.

     O QUE ESTE COMPONENTE NÃO DEIXA VOCÊ FAZER, e é de propósito:

     Preço em corpo grande é uma AFIRMAÇÃO — de que é aquele papel, de que é
     de agora, e de que alguém apurou. As três são invisíveis, e nenhum site
     as escreve. Aqui elas viram estrutura:

       data-em         ISO 8601 COM fuso: o instante da leitura
       data-validade   segundos de vida do dado. 900 = 15 min.
                       0 = "fechamento datado, não cotação ao vivo"
       .coto-fonte     provedor, instante e atraso. Obrigatória.

     Vencido o prazo, o script APAGA os preços (texto e atributo `value`),
     tira a cor de direção, esconde o selo "ao vivo" e mostra o aviso. Um
     painel de cotação que ninguém alimenta é o defeito mais comum do gênero:
     a página segue mostrando a cotação de terça na sexta, e ninguém percebe.

     O SELO "AO VIVO" SÓ EXISTE NO ESTADO VIVO. Ele é a afirmação visual de
     que o número é de agora — o equivalente, em card, ao movimento de uma
     faixa. Quem o concede é a conferência de validade, e mais ninguém: não
     há como escrevê-lo à mão no markup e fazê-lo ficar.

     O FEED ENTRA POR EVENTO, sem global e sem editar este HTML. O alvo é a
     RAIZ, que é o elemento com [data-coto] — a <section> deste bloco:
       raiz.dispatchEvent(new CustomEvent('cotacoes:dados', { detail: {
         em: '2026-09-10T18:05:00-03:00',
         provedor: 'B3',
         atraso: 'atraso de 15 min',
         itens: [ { simbolo: 'PETR4', nome: 'Petrobras PN',
                    preco: 'R$ 38,42', precoNum: 38.42,
                    variacao: 0.0124, serie: [36.9, 37.2, 37.1, 38.4] } ]
       } }));
     `variacao` é NÚMERO e é FRAÇÃO, não porcento: dela saem o sinal, a seta,
     a cor e o texto — assim a seta nunca discorda do número.

     AS AÇÕES ABAIXO SÃO FICTÍCIAS. Meridiano, Altamira, Northbay e Quadrant
     são os nomes inventados da casa, os mesmos da prova/manchetes-imprensa.
     Papel inventado com preço inventado é demonstração; papel REAL com preço
     inventado é informação financeira falsa.

     A COR DE DIREÇÃO VEM DE TOKEN. --alta e --baixa são globais do core desde
     2026-09-10, e o escopo de tom da seção já os calibrou para o fundo em que
     ela está — o verde do escuro cairia para 2,3:1 no papel. Quem usa outro
     par (âmbar/azul) escreve --coto-sobe/--coto-cai no próprio bloco, sem
     tocar no core.

     O NOME É DIREÇÃO, NÃO VEREDITO. Cotação é das poucas peças em que subir
     já é lido como verde por convenção. O dados/grade-indicadores fica de
     fora desses tokens de propósito: na grade dele o patrimônio sobe (boa
     notícia) e o prazo do diagnóstico cai (também boa notícia).

     A DIREÇÃO NUNCA DEPENDE DA COR. Verde e vermelho é exatamente o par que
     8% dos homens não distinguem: ela está na SETA, no sinal, e por extenso
     para o leitor de tela.
     ========================================================================== -->

<section class="sec-deep sec-pad coto-sec"
         data-coto
         data-em="2026-09-10T18:05:00-03:00"
         data-validade="900">
  <div class="wrap">

    <div class="coto-topo">
      <div class="coto-titulo">
        <span class="eyebrow" data-rv>Mercado</span>
        <h2 class="sec-title ttl">O que a carteira <em>fez hoje</em></h2>
      </div>

      <p class="coto-fonte">
        <span class="coto-pulso"><span class="coto-ponto" aria-hidden="true"></span>ao vivo</span>
        <span class="coto-provedor">Ações fictícias — demonstração</span>
        <time class="coto-em" datetime="2026-09-10T18:05:00-03:00">10 set 2026, 18:05</time>
        <span class="coto-atraso">atraso de 15 min</span>
      </p>
    </div>

    <ul class="coto-grade" data-coto-grade>

      <li class="coto-card" data-dir="sobe" data-rv>
        <div class="coto-cab">
          <span class="coto-simbolo">MRD3</span>
          <span class="coto-nome">Meridiano Energia</span>
        </div>
        <svg class="coto-linha" viewBox="0 0 120 36" preserveAspectRatio="none" aria-hidden="true" focusable="false">
          <path class="coto-area" d="M0,27 L11,25 L22,28 L33,22 L44,24 L55,18 L65,20 L76,14 L87,16 L98,10 L109,12 L120,7 L120,36 L0,36 Z"/>
          <polyline class="coto-traco" points="0,27 11,25 22,28 33,22 44,24 55,18 65,20 76,14 87,16 98,10 109,12 120,7"/>
        </svg>
        <div class="coto-pe">
          <data class="coto-preco" value="38.42">R$ 38,42</data>
          <data class="coto-var" value="0.0124">
            <span class="coto-seta" aria-hidden="true"></span>
            <span class="visually-hidden">alta de </span>+1,24%
          </data>
        </div>
      </li>

      <li class="coto-card" data-dir="cai" data-rv data-d="1">
        <div class="coto-cab">
          <span class="coto-simbolo">ALT4</span>
          <span class="coto-nome">Altamira Papel</span>
        </div>
        <svg class="coto-linha" viewBox="0 0 120 36" preserveAspectRatio="none" aria-hidden="true" focusable="false">
          <path class="coto-area" d="M0,9 L11,12 L22,10 L33,15 L44,13 L55,19 L65,17 L76,23 L87,21 L98,26 L109,24 L120,29 L120,36 L0,36 Z"/>
          <polyline class="coto-traco" points="0,9 11,12 22,10 33,15 44,13 55,19 65,17 76,23 87,21 98,26 109,24 120,29"/>
        </svg>
        <div class="coto-pe">
          <data class="coto-preco" value="21.08">R$ 21,08</data>
          <data class="coto-var" value="-0.0063">
            <span class="coto-seta" aria-hidden="true"></span>
            <span class="visually-hidden">queda de </span>&#8722;0,63%
          </data>
        </div>
      </li>

      <li class="coto-card" data-dir="sobe" data-rv data-d="2">
        <div class="coto-cab">
          <span class="coto-simbolo">NBY11</span>
          <span class="coto-nome">Northbay Properties</span>
        </div>
        <svg class="coto-linha" viewBox="0 0 120 36" preserveAspectRatio="none" aria-hidden="true" focusable="false">
          <path class="coto-area" d="M0,20 L11,21 L22,19 L33,20 L44,18 L55,20 L65,19 L76,21 L87,18 L98,19 L109,18 L120,17 L120,36 L0,36 Z"/>
          <polyline class="coto-traco" points="0,20 11,21 22,19 33,20 44,18 55,20 65,19 76,21 87,18 98,19 109,18 120,17"/>
        </svg>
        <div class="coto-pe">
          <data class="coto-preco" value="104.90">R$ 104,90</data>
          <data class="coto-var" value="0.0017">
            <span class="coto-seta" aria-hidden="true"></span>
            <span class="visually-hidden">alta de </span>+0,17%
          </data>
        </div>
      </li>

      <li class="coto-card" data-dir="cai" data-rv data-d="3">
        <div class="coto-cab">
          <span class="coto-simbolo">QDR3</span>
          <span class="coto-nome">Quadrant Logística</span>
        </div>
        <svg class="coto-linha" viewBox="0 0 120 36" preserveAspectRatio="none" aria-hidden="true" focusable="false">
          <path class="coto-area" d="M0,7 L11,9 L22,8 L33,13 L44,16 L55,14 L65,20 L76,22 L87,20 L98,26 L109,28 L120,31 L120,36 L0,36 Z"/>
          <polyline class="coto-traco" points="0,7 11,9 22,8 33,13 44,16 55,14 65,20 76,22 87,20 98,26 109,28 120,31"/>
        </svg>
        <div class="coto-pe">
          <data class="coto-preco" value="9.76">R$ 9,76</data>
          <data class="coto-var" value="-0.0215">
            <span class="coto-seta" aria-hidden="true"></span>
            <span class="visually-hidden">queda de </span>&#8722;2,15%
          </data>
        </div>
      </li>

    </ul>

    <!-- Região viva. Nasce [hidden]; quem a revela é o script, no vencimento.
         role="status" e não role="alert": entra na fila do leitor de tela em
         vez de interromper o que estiver sendo lido. -->
    <p class="coto-aviso" role="status" hidden>
      <span class="coto-aviso-txt">Cotações indisponíveis.</span>
      <span class="coto-aviso-det">A última leitura passou do prazo de validade e foi retirada.</span>
    </p>

  </div>
</section>

estilo.css · 265 linhas · 10,9 KB

/* ==========================================================================
   CARDS DE COTAÇÃO — a grade no feitio de app de ações
   Prefixo: coto-
   Tokens usados: --bg --fg --fg-2 --fg-muted --rule --rule-2 --accent
                  --display --sans --ease --radius-media --radius-pill

   O ESTADO MANDA, e o estado vem do script:
     (sem atributo)          sem JavaScript. Cards datados, sem selo "ao vivo".
     data-estado="vivo"      leitura dentro da validade. SÓ AQUI o selo aparece.
     data-estado="datado"    data-validade="0" — fechamento, não tempo real.
     data-estado="vencido"   passou do prazo. Preços retirados, aviso à mostra.

   UMA SÓ FONTE DE DIREÇÃO. O [data-dir] do card resolve --coto-dir, e o
   minigráfico, a seta e o percentual leem essa variável. Não há como o traço
   subir e a seta apontar para baixo: os três saem do mesmo atributo, que o
   script escreve a partir do NÚMERO da variação.

   Nenhuma cor está escrita neste arquivo. As duas cores de direção são
   custom properties DO COMPONENTE (§3 permite), com token no fallback: quem
   quiser o verde/vermelho de mercado escreve --coto-sobe e --coto-cai no
   próprio bloco, sem tocar aqui e sem pedir um token global de estado.
   ========================================================================== */

.coto-sec{
  --coto-min:230px;        /* largura mínima do card antes de quebrar coluna */
  --coto-gap:14px;
  --coto-pad:18px;         /* respiro interno do card */
  --coto-linha-h:44px;     /* altura do minigráfico */
  --coto-preco:26px;       /* corpo do preço */
  /* Os dois leem os tokens de estado do core, que o escopo de tom da seção já
     calibrou para o fundo em que ela está. Continuam sendo parafuso: um
     projeto que não queira o par de mercado escreve outro valor aqui, sem
     tocar no core e sem que este arquivo precise saber. */
  --coto-sobe:var(--alta);
  --coto-cai:var(--baixa);
}

/* ---- cabeçalho da dobra ---- */
.coto-topo{
  display:flex;flex-wrap:wrap;align-items:flex-end;justify-content:space-between;
  gap:20px clamp(24px,4vw,56px);
  margin-bottom:clamp(28px,4vh,44px);
}
.coto-titulo{flex:1 1 320px}

/* A linha de procedência é estrutura, não rodapé decorativo: sem provedor e
   sem instante, o preço acima não é cotação — é um número grande. */
.coto-fonte{
  display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 12px;
  font-family:var(--sans);
  font-size:11px;letter-spacing:.14em;text-transform:uppercase;
  color:var(--fg-muted);
}
.coto-em::before,
.coto-atraso::before{content:"·";margin-inline-end:12px;color:var(--rule-2)}

/* ---- o selo "ao vivo" ----
   Só existe no estado vivo, e é a afirmação visual de que o número é de
   agora — o equivalente, em card, ao movimento de uma faixa. Não há como
   escrevê-lo no markup e fazê-lo ficar: quem o mostra é o seletor de estado. */
.coto-pulso{display:none;align-items:center;gap:7px;color:var(--fg-2)}
.coto-sec[data-estado="vivo"] .coto-pulso{display:inline-flex}
.coto-ponto{
  width:7px;height:7px;border-radius:var(--radius-pill);
  /* --alta e não --accent: aqui o token está fazendo o único trabalho de
     VEREDITO que ele faz nesta peça — "a leitura está boa" —, e é a convenção
     que todo indicador de "ao vivo" usa, o que poupa uma legenda. Vale
     registrar porque é a exceção: em todo o resto do componente o par é
     DIREÇÃO, não julgamento. */
  background:var(--alta);
  animation:cotoPulso 2.4s var(--ease) infinite;
}
@keyframes cotoPulso{
  0%,100%{opacity:1;transform:scale(1)}
  50%{opacity:.35;transform:scale(.8)}
}
/* Colado à regra que desliga, como o §6.6 pede. O ponto FICA — ele diz que a
   leitura está dentro do prazo, e isso é informação, não enfeite. Some só a
   pulsação. */
@media(prefers-reduced-motion:reduce){
  .coto-ponto{animation:none}
}

/* ---- a grade ----
   auto-fit e não auto-fill: com quatro papéis numa tela larga, o auto-fill
   deixaria colunas vazias no fim da fileira e os cards ficariam estreitos
   sem motivo. */
.coto-grade{
  display:grid;
  grid-template-columns:repeat(auto-fit,minmax(var(--coto-min),1fr));
  gap:var(--coto-gap);
}

.coto-card{
  /* POSICIONADO DE PROPÓSITO, e não pelo desenho: é o bloco de contenção do
     .visually-hidden que diz a direção por extenso. Sem isto o span absoluto
     sobe até a <section> para achar onde se ancorar, SAI do recorte do
     trilho de celular (overflow-x:auto não corta descendente absoluto cujo
     contêiner está fora dele) e estica a página. Medido em 390px pelo
     bin/varredura.js: documento de 937px, 547 de estouro — a página inteira
     abria afastada no celular, e nada no inspetor apontava o card. */
  position:relative;
  display:grid;gap:12px;
  padding:var(--coto-pad);
  border:1px solid var(--rule);
  border-radius:var(--radius-media);
  /* Superfície derivada de --fg, e não de uma cor: é o que faz o mesmo
     arquivo servir a .sec-deep e a .sec-paper sem uma variação de código. */
  background:color-mix(in srgb,var(--fg) 4%,transparent);
  transition:border-color .4s var(--ease),background .4s var(--ease);
}
.coto-card:hover{
  border-color:var(--rule-2);
  background:color-mix(in srgb,var(--fg) 7%,transparent);
}

/* A direção resolvida uma vez, aqui. Todo o resto do card lê --coto-dir. */
.coto-card[data-dir="sobe"]{--coto-dir:var(--coto-sobe)}
.coto-card[data-dir="cai"]{--coto-dir:var(--coto-cai)}
.coto-card[data-dir="zero"]{--coto-dir:var(--fg-muted)}

/* ---- cabeça do card ---- */
.coto-cab{display:grid;gap:3px}
.coto-simbolo{
  font-family:var(--display);font-weight:700;
  font-size:15px;letter-spacing:.04em;
  color:var(--fg);
}
/* O nome corta em uma linha: papel com razão social longa ("Companhia
   Energética de ...") empurraria o preço para baixo e desalinharia a
   fileira inteira de cards. */
.coto-nome{
  font-size:12.5px;letter-spacing:.02em;line-height:1.3;
  color:var(--fg-muted);
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
}

/* ---- o minigráfico ----
   preserveAspectRatio="none" no SVG faz a série esticar até a largura do
   card, seja ela qual for — é o que permite a mesma série servir a um card
   de 230px e a um de 400px. O preço disso é que a ESCALA fica anisotrópica,
   e sem vector-effect o traço engorda na horizontal e afina na vertical. */
.coto-linha{
  display:block;width:100%;height:var(--coto-linha-h);
  overflow:visible;   /* a linha encosta na borda do viewBox; sem isto, corta */
}
.coto-traco{
  fill:none;
  stroke:var(--coto-dir,var(--fg-muted));
  stroke-width:1.75;
  stroke-linecap:round;stroke-linejoin:round;
  vector-effect:non-scaling-stroke;
}

/* A área sob a linha é o que separa "gráfico pequeno" de "card de mercado".
   Opacidade baixa e cor de direção: ela não acrescenta informação nenhuma
   que a linha já não dê — o papel dela é dar peso ao card e ancorar a curva
   no rodapé, para o traço não flutuar no vazio.

   Fica em opacity, e não numa cor mais fraca, porque a cor vem de
   --coto-dir: qualquer valor fixo aqui quebraria a promessa de o mesmo
   arquivo servir a .sec-deep e a .sec-paper. */
.coto-area{
  fill:var(--coto-dir,var(--fg-muted));
  opacity:.13;
  stroke:none;
}

/* ---- pé do card: preço e variação ---- */
.coto-pe{
  display:flex;flex-wrap:wrap;align-items:baseline;justify-content:space-between;
  gap:4px 10px;
}

/* tabular-nums não é estética: sem ele, a troca de um dígito na atualização
   do feed muda a largura do preço e o bloco inteiro dança a cada leitura. */
.coto-preco{
  font-family:var(--display);font-weight:700;
  font-size:var(--coto-preco);letter-spacing:-.02em;line-height:1.1;
  font-variant-numeric:tabular-nums;
  color:var(--fg);
}

.coto-var{
  display:inline-flex;align-items:center;gap:6px;
  font-family:var(--sans);font-weight:400;
  font-size:13px;letter-spacing:.02em;
  font-variant-numeric:tabular-nums;
  color:var(--coto-dir,var(--fg-2));
}

/* ---- a seta ----
   Triângulo de borda, e não glifo: ▲ e ▼ têm peso e altura diferentes em
   cada família tipográfica, e num tema de fonte própria a seta de alta
   apareceria maior que a de queda. O triângulo de borda é a mesma forma em
   qualquer fonte, e herda a cor de direção sozinho. */
.coto-seta{
  width:0;height:0;
  border-inline:4.5px solid transparent;
  border-block-end:6px solid currentColor;
}
.coto-card[data-dir="cai"] .coto-seta{
  border-block-end:0;
  border-block-start:6px solid currentColor;
}
/* Estável não tem seta: um traço. Zerar as bordas é obrigatório — sem isso,
   o triângulo de alta continuaria desenhado por baixo do traço. */
.coto-card[data-dir="zero"] .coto-seta{
  border:0;
  width:9px;height:1.5px;
  background:currentColor;
}

/* ---- vencido ----
   O script troca o texto por travessão e apaga o atributo value. Isto aqui é
   só o tom do que sobra — apagar por CSS (opacity, color:transparent)
   deixaria o preço velho intacto na árvore de acessibilidade, e o leitor de
   tela seguiria anunciando a cotação que a página acabou de retirar. */
.coto-sec[data-estado="vencido"] .coto-card{
  border-style:dashed;
  background:none;
}
.coto-sec[data-estado="vencido"] .coto-preco{color:var(--fg-muted)}
.coto-sec[data-estado="vencido"] .coto-linha{opacity:.25}

/* ---- o aviso ----
   Nasce [hidden] no markup. A regra abaixo é obrigatória: esta declaração de
   display venceria o [hidden] do navegador e o aviso apareceria sempre (§6.5). */
.coto-aviso{
  display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 10px;
  margin-top:20px;
  font-size:13px;
}
.coto-aviso[hidden]{display:none}
.coto-aviso-txt{color:var(--fg-2)}
.coto-aviso-det{color:var(--fg-muted)}

/* ==========================================================================
   Abaixo de 720px a grade vira TRILHO, e não uma coluna de cards empilhados.
   É o que o app de ações e o painel do Google fazem no celular, e a razão é
   a mesma: quatro papéis empilhados ocupam a tela inteira e empurram o resto
   da página para baixo, enquanto o trilho mostra dois e sugere o terceiro.
   ========================================================================== */
@media(max-width:720px){
  .coto-sec{--coto-preco:22px;--coto-linha-h:38px}
  .coto-grade{
    grid-template-columns:none;
    grid-auto-flow:column;
    grid-auto-columns:min(64vw,240px);
    overflow-x:auto;
    scroll-snap-type:x mandatory;
    overscroll-behavior-x:contain;
    /* O trilho sangra até a borda da tela e devolve o respiro por padding,
       para o primeiro card nascer alinhado à coluna e o último não encostar
       no vidro. Margem negativa em vez de sair do .wrap porque a grade é
       filha do cabeçalho, que precisa continuar na coluna (§4). */
    margin-inline:calc(var(--pad) * -1);
    padding-inline:var(--pad);
    /* Barra escondida: o card cortado na borda já diz que a fila continua. */
    scrollbar-width:none;
    -ms-overflow-style:none;
  }
  .coto-grade::-webkit-scrollbar{width:0;height:0;display:none}
  .coto-card{scroll-snap-align:start}
}

script.js · 286 linhas · 12,1 KB

/* ==========================================================================
   CARDS DE COTAÇÃO — a conferência de validade, o feed e o minigráfico.

   Este arquivo NÃO anima nada: a pulsação do selo é CSS, com o @media de
   movimento reduzido colado a ela. O que ele faz é decidir se a página tem o
   direito de afirmar que o número é de agora — compara `data-em` com
   `data-validade` e escreve `data-estado` na raiz. O CSS lê o estado.

   Preço em corpo grande é uma afirmação, e o defeito mais comum do gênero é
   que ela nunca expira: o feed morre, ninguém percebe, e a página segue
   mostrando a cotação de terça na sexta. Aqui ela expira — o preço sai, a cor
   de direção sai, o selo "ao vivo" some e o aviso entra.

   Quatro coisas, nesta ordem:
     1. confere a validade e escreve o estado
     2. no vencimento, retira os preços do DOM (texto e atributo `value`)
     3. escuta 'cotacoes:dados' para o feed real entrar sem editar o HTML
     4. desenha o minigráfico a partir da série que o feed mandar

   O [data-dir] do card é escrito a partir do NÚMERO da variação, e o CSS faz
   traço, seta e percentual lerem esse mesmo atributo. É o que impede o
   minigráfico de subir enquanto a seta aponta para baixo.
   ========================================================================== */

(function () {
  var raizes = document.querySelectorAll('[data-coto]');
  if (!raizes.length) return;                            /* guard */

  var RECONFERIR = 30000;   /* ms — de quanto em quanto se revisita a validade.
                               Aba deixada aberta a tarde inteira é o caso
                               comum, não o raro: sem isto o estado `vivo`
                               seria concedido uma vez e valeria para sempre. */

  var MESES = ['jan', 'fev', 'mar', 'abr', 'mai', 'jun',
               'jul', 'ago', 'set', 'out', 'nov', 'dez'];

  /* Caixa do minigráfico, em unidades do viewBox do markup. A folga vertical
     existe porque a série encosta nos extremos: sem ela, o topo e o fundo do
     traço ficariam cortados na metade da espessura. */
  var G = { w: 120, h: 36, folga: 4 };

  var SVG = 'http://www.w3.org/2000/svg';

  for (var i = 0; i < raizes.length; i++) ligar(raizes[i]);

  /* ----------------------------------------------------------------- raiz */
  function ligar(raiz) {
    /* Parte da raiz, nunca do documento (§7.6): a página pode ter dois
       painéis, um de ações e outro de câmbio, com feeds e prazos diferentes. */
    var grade = raiz.querySelector('[data-coto-grade]');
    var aviso = raiz.querySelector('.coto-aviso');
    if (!grade) return;

    raiz.addEventListener('cotacoes:dados', function (e) {
      receber(e.detail);
    });

    conferir();
    /* Só o que pode vencer precisa de relógio. O modo datado não vence. */
    if (prazo() > 0) {
      setInterval(conferir, RECONFERIR);
      /* Aba dormindo congela o setInterval em vários navegadores. Ao voltar,
         a primeira coisa que a pessoa vê tem de ser o estado de agora, não o
         de quando ela saiu. */
      document.addEventListener('visibilitychange', function () {
        if (!document.hidden) conferir();
      });
    }

    /* ------------------------------------------------------------ estado */
    function prazo() {
      var v = parseInt(raiz.getAttribute('data-validade'), 10);
      return isNaN(v) ? 900 : v;                         /* 15 min de padrão */
    }

    function conferir() {
      var validade = prazo();

      /* validade="0" é uma declaração, não a ausência de uma: "isto é um
         fechamento datado, não uma cotação ao vivo". Os preços ficam, e o
         selo "ao vivo" nunca aparece — porque a página nunca o afirmou. */
      if (validade === 0) { aplicar('datado'); return; }

      var em = Date.parse(raiz.getAttribute('data-em'));
      if (isNaN(em)) { aplicar('vencido'); return; }

      /* Idade negativa é data no futuro: relógio da máquina errado, fuso
         esquecido no ISO, ou dado montado à mão. Nenhum dos três autoriza a
         afirmação — e tratar futuro como "fresquíssimo" seria justamente o
         jeito de burlar a conferência sem querer. */
      var idade = (Date.now() - em) / 1000;
      aplicar(idade < 0 || idade > validade ? 'vencido' : 'vivo');
    }

    function aplicar(estado) {
      if (raiz.getAttribute('data-estado') === estado) return;   /* nada mudou */
      raiz.setAttribute('data-estado', estado);

      if (estado === 'vencido') apagarPrecos();
      if (aviso) aviso.hidden = estado !== 'vencido';
    }

    /* ---------------------------------------------------------- vencimento */
    /* Apagar de verdade: o texto sai e o atributo `value` sai junto. Esconder
       por CSS deixaria o preço velho na árvore de acessibilidade e o leitor
       de tela seguiria anunciando a cotação que a página acabou de retirar.
       O [data-dir] sai também — sem número não há direção, e uma seta verde
       sobre um traço vazio continuaria afirmando alta. */
    function apagarPrecos() {
      var campos = grade.querySelectorAll('.coto-preco, .coto-var');
      for (var k = 0; k < campos.length; k++) {
        campos[k].textContent = '—';                     /* travessão */
        campos[k].removeAttribute('value');
      }
      var cards = grade.querySelectorAll('.coto-card');
      for (var c = 0; c < cards.length; c++) cards[c].removeAttribute('data-dir');

      var det = raiz.querySelector('.coto-aviso-det');
      var em  = raiz.querySelector('.coto-em');
      if (det && em) det.textContent = 'Última leitura: ' + em.textContent + '.';
    }

    /* --------------------------------------------------------------- feed */
    /* O contrato do evento está no comentário do markup.html. Aqui vale a
       decisão que o sustenta: `variacao` chega como NÚMERO e o sinal, a seta,
       a cor e o texto saem todos dele. Aceitar "+1,24%" pronto deixaria a
       seta discordar do valor no dia em que alguém montasse a string errada. */
    function receber(dados) {
      if (!dados || !dados.itens || !dados.itens.length) return;

      grade.innerHTML = '';
      for (var k = 0; k < dados.itens.length; k++) {
        grade.appendChild(montarCard(dados.itens[k]));
      }

      var quando = dados.em ? new Date(dados.em) : new Date();
      raiz.setAttribute('data-em', quando.toISOString());

      var em = raiz.querySelector('.coto-em');
      if (em) {
        em.setAttribute('datetime', quando.toISOString());
        em.textContent = legivel(quando);
      }
      escrever(raiz.querySelector('.coto-provedor'), dados.provedor);
      escrever(raiz.querySelector('.coto-atraso'), dados.atraso);

      /* O estado precisa cair antes de subir de novo: `aplicar` sai cedo
         quando o estado não muda, e um feed que chega com a página já em
         `vencido` não voltaria a `vivo` sem este passo. */
      raiz.removeAttribute('data-estado');
      conferir();
    }

    function montarCard(item) {
      var v = Number(item.variacao);
      var dir = isNaN(v) ? 'zero' : (v > 0 ? 'sobe' : v < 0 ? 'cai' : 'zero');

      var li = document.createElement('li');
      li.className = 'coto-card';
      li.setAttribute('data-dir', dir);

      /* ---- cabeça: símbolo e nome ---- */
      var cab = document.createElement('div');
      cab.className = 'coto-cab';
      cab.appendChild(texto('span', 'coto-simbolo', item.simbolo));
      if (item.nome) cab.appendChild(texto('span', 'coto-nome', item.nome));
      li.appendChild(cab);

      /* ---- minigráfico: só quando o feed mandou série ----
         Card sem série fica sem gráfico, e não com um gráfico reto: linha
         plana desenhada por falta de dado seria a mesma mentira que o preço
         vencido, só que em forma de curva. */
      if (item.serie && item.serie.length > 1) {
        li.appendChild(montarLinha(item.serie));
      }

      /* ---- pé: preço e variação ---- */
      var pe = document.createElement('div');
      pe.className = 'coto-pe';

      var preco = document.createElement('data');
      preco.className = 'coto-preco';
      preco.value = item.precoNum != null ? String(item.precoNum) : String(item.preco);
      preco.textContent = item.preco;
      pe.appendChild(preco);

      if (!isNaN(v)) pe.appendChild(montarVar(v, dir));
      li.appendChild(pe);

      return li;
    }

    function montarVar(v, dir) {
      var el = document.createElement('data');
      el.className = 'coto-var';
      el.value = String(v);

      var seta = document.createElement('span');
      seta.className = 'coto-seta';
      seta.setAttribute('aria-hidden', 'true');

      /* A direção nunca é dita só pela cor nem só pela seta — e "+" quase
         sempre é engolido pelo leitor de tela. A palavra resolve os dois. */
      var dito = document.createElement('span');
      dito.className = 'visually-hidden';
      dito.textContent = dir === 'sobe' ? 'alta de '
                       : dir === 'cai'  ? 'queda de ' : 'estável, ';

      var sinal = dir === 'sobe' ? '+' : dir === 'cai' ? '−' : '';
      var num   = (Math.abs(v) * 100).toFixed(2).replace('.', ',');

      el.appendChild(seta);
      el.appendChild(dito);
      el.appendChild(document.createTextNode(sinal + num + '%'));
      return el;
    }

    /* ------------------------------------------------------------- gráfico */
    /* A série é normalizada pelo PRÓPRIO mínimo e máximo, não por uma escala
       comum a todos os cards. É o que o app de ações faz, e é o certo aqui:
       cada card mostra a FORMA do dia daquele papel, não a comparação entre
       papéis — que exigiria eixo, e eixo não cabe em 44px de altura. */
    function montarLinha(serie) {
      var min = Math.min.apply(null, serie);
      var max = Math.max.apply(null, serie);
      var alt = G.h - G.folga * 2;
      /* Série constante daria divisão por zero; ela vira uma reta no meio. */
      var amp = (max - min) || 1;

      var pts = [];
      for (var k = 0; k < serie.length; k++) {
        var x = (k / (serie.length - 1)) * G.w;
        var y = G.folga + alt - ((serie[k] - min) / amp) * alt;
        pts.push(round(x) + ',' + round(y));
      }

      var svg = document.createElementNS(SVG, 'svg');
      svg.setAttribute('class', 'coto-linha');
      svg.setAttribute('viewBox', '0 0 ' + G.w + ' ' + G.h);
      /* Estica a série até a largura do card, seja ela qual for. O preço
         disso é a escala anisotrópica, que o vector-effect do CSS corrige. */
      svg.setAttribute('preserveAspectRatio', 'none');
      svg.setAttribute('aria-hidden', 'true');
      svg.setAttribute('focusable', 'false');

      /* A área entra ANTES da linha na ordem do documento: SVG não tem
         z-index, e quem pinta por último fica por cima. Invertido, o
         preenchimento cobriria metade do traço. */
      var area = document.createElementNS(SVG, 'path');
      area.setAttribute('class', 'coto-area');
      area.setAttribute('d', 'M' + pts.join(' L') +
                             ' L' + G.w + ',' + G.h + ' L0,' + G.h + ' Z');

      var linha = document.createElementNS(SVG, 'polyline');
      linha.setAttribute('class', 'coto-traco');
      linha.setAttribute('points', pts.join(' '));

      svg.appendChild(area);
      svg.appendChild(linha);
      return svg;
    }
  }

  /* ------------------------------------------------------------ utilitários */
  function texto(tag, classe, conteudo) {
    var el = document.createElement(tag);
    el.className = classe;
    el.textContent = conteudo;
    return el;
  }

  function escrever(el, conteudo) {
    if (el && conteudo) el.textContent = conteudo;
  }

  function round(n) { return Math.round(n * 10) / 10; }

  /* Formato à mão, e não Intl: o mês abreviado do pt-BR sai com ponto em
     alguns motores e sem ponto em outros, e a linha de procedência é o lugar
     em que a página menos pode variar de aparência entre navegadores. */
  function legivel(d) {
    return d.getDate() + ' ' + MESES[d.getMonth()] + ' ' + d.getFullYear() +
           ', ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
  }
  function pad(n) { return (n < 10 ? '0' : '') + n; }
})();

A grade no feitio do app de Ações do iPhone e do painel do Google Finance: um card por papel, com símbolo, nome, minigráfico de área, preço em corpo grande e a variação com seta.

A forma é essa porque é a leitura que o público já sabe fazer. E ela ganha da faixa de cotações correndo em quase toda página institucional por uma razão simples: em card a pessoa procura o papel dela; na faixa, ela espera ele passar.

O que o componente tem além da forma cabe numa frase: o selo "ao vivo" só existe enquanto a leitura está dentro da validade declarada. Vencido o prazo, os preços saem do DOM, a cor de direção some, a borda vira tracejada e um aviso diz desde quando.


Compliance

Esta seção não é conselho editorial. É a razão de o componente existir dessa forma.

Um preço em corpo grande, numa página de instituição financeira, é informação financeira ao público. Ele carrega três afirmações, e nenhuma delas está escrita em lugar nenhum:

  1. que este é o preço daquele papel;
  2. que ele é de agora;
  3. que alguém apurou — não é a conta do site.

O componente responde às três com estrutura, não com texto:

E o que fica por sua conta:


Quando usar

Quando não usar

O irmão mais próximo, e quando ele ganha

dados/grade-indicadores tem a mesma anatomia — 4 a 6 células, valor grande, variação com seta, data na peça — e por isso a escolha entre os dois merece uma linha:

grade-indicadores cards-cotacoes
O que exibe o que já foi apurado o que está sendo cotado agora
O tempo a janela de apuração, impressa em texto uma validade que expira e retira o valor
Script nenhum, CSS puro motor de validade e socket de feed
Sem alimentação continua correto para sempre entra em vencido, e isso é o certo

A pergunta que decide: o número muda enquanto a página está aberta? Se não muda — patrimônio sob gestão, número de clientes, prazo médio —, é a grade, e ela ganha por ser CSS puro. Se muda, e ficar desatualizado é um problema, é este.

E há uma diferença de cor que segue dessa: na grade, direção não é veredito — o patrimônio sobe (boa notícia) e o prazo do diagnóstico cai (também boa notícia) —, então ela não usa --alta/--baixa. Cotação é das poucas peças em que subir já é lido como verde por convenção.


Padrão editorial

Quatro a seis papéis. Quatro fecham a fileira numa tela de 1240px sem sobra; acima de seis a segunda fileira começa a pedir hierarquia que o componente não tem.

O símbolo é o código; o nome é quem o leitor reconhece. MRD3 em cima, Meridiano Energia embaixo. Invertido, o card fica bonito e ilegível para quem não é do mercado.

O nome corta em uma linha, e isso é decisão de layout. Razão social longa ("Companhia Energética de …") empurraria o preço para baixo e desalinharia a fileira. Use o nome de pregão, não o do estatuto.

Casas decimais constantes por papel. Ação em duas, câmbio em quatro. O tabular-nums segura o dígito, não a quantidade deles.

Escolha por relevância para o leitor, não por variedade. Os papéis que a casa de fato acompanha — não seis nomes porque enchem melhor a fileira.


Como usar

<link rel="stylesheet" href="componentes/dados/cards-cotacoes/estilo.css">
<script src="componentes/dados/cards-cotacoes/script.js"></script>

Cole a seção, troque os papéis, e ligue o feed — sem feed o componente entra em vencido no primeiro carregamento depois do prazo, que é o comportamento correto e não um defeito.

Ganchos

Gancho O quê
[data-coto] a raiz, na <section>. O script parte dela, e é nela que o evento de feed é disparado
data-em ISO 8601 com fuso. O instante da leitura
data-validade segundos de vida do dado. 900 = 15 min. 0 = fechamento datado
[data-coto-grade] a <ul> dos cards. O feed reescreve o conteúdo dela
data-dir sobe, cai ou zero no card. Resolve --coto-dir para traço, seta e percentual
data-estado escrito pelo script: vivo, datado ou vencido. Não escreva à mão

Ligando o feed

O feed entra por CustomEvent na raiz. Sem global, sem editar o HTML, sem tocar no script.js:

var raiz = document.querySelector('[data-coto]');

raiz.dispatchEvent(new CustomEvent('cotacoes:dados', { detail: {
  em: '2026-09-10T18:05:00-03:00',   // ISO com fuso; ausente, assume agora
  provedor: 'B3',
  atraso: 'atraso de 15 min',
  itens: [
    { simbolo: 'PETR4', nome: 'Petrobras PN',
      preco: 'R$ 38,42', precoNum: 38.42,
      variacao: 0.0124,
      serie: [36.9, 37.2, 37.1, 37.8, 38.4] },
    { simbolo: 'USDBRL', nome: 'Dólar comercial',
      preco: 'R$ 5,1420', variacao: -0.0026 }     // sem série: card sem gráfico
  ]
} }));

preco é a string que aparece — símbolo de moeda, separador e casas são decisão sua, e o componente não reformata. precoNum é opcional e vai para o atributo value do <data>, para quem lê a página por máquina.

variacao é número, e é fração — não porcento. 0.0124, não "+1,24%". Dela saem o sinal, a seta, a cor e o texto. Aceitar a string pronta deixaria a seta discordar do número no dia em que alguém montasse a string errada.

serie é opcional: um vetor de preços na ordem do dia. Sem ela, o card sai sem minigráfico.

Repita o dispatchEvent a cada leitura nova. Cada um renova o data-em e devolve o painel a vivo — inclusive saindo de vencido, se ele tinha expirado.

Parafusos

Variável Padrão O quê
--coto-min 230px largura mínima do card antes de quebrar coluna
--coto-gap 14px vão entre cards
--coto-pad 18px respiro interno
--coto-linha-h 44px altura do minigráfico
--coto-preco 26px corpo do preço
--coto-sobe var(--alta) cor da alta
--coto-cai var(--baixa) cor da queda

Variações

Variação Como
Outro par de estado --coto-sobe e --coto-cai no bloco consumidor. O padrão já é o par de mercado, vindo de --alta/--baixa
Sem minigráfico apagar o <svg class="coto-linha">. Vira o card do app de Ações em modo lista
Fechamento datado data-validade="0". Preços ficam, selo "ao vivo" nunca aparece
No tom claro trocar .sec-deep por .sec-paper. Nenhuma linha de CSS muda
Dois painéis duas seções [data-coto], com validades diferentes. Cada uma vence sozinha

Gotchas


Movimento reduzido / toque

O único movimento próprio é a pulsação do ponto do selo "ao vivo". Com prefers-reduced-motion: reduce o ponto fica e a pulsação sai — o ponto diz que a leitura está dentro do prazo, e isso é informação, não enfeite.

A entrada dos cards é o data-rv do core, já desligado por lá.

No toque não há hover, e nada se perde: o realce de borda é enfeite, e no celular a grade é um trilho com scroll-snap, arrastável com o dedo.


Histórico

Origem Quando O quê
autoral 2026-08 mapeado na fila como "ticker de índices" e travado: cotação fictícia em site financeiro é compliance, não placeholder
autoral 2026-09-10 construído como faixa em esteira e reprovado na revisão pela forma
autoral 2026-09-10 refeito em cards, com o motor de validade inteiro aproveitado

Por que demorou. O padrão estava na fila autoral desde a primeira leva, com a situação registrada como "previsto — compliance: só com feed real e data; valor fictício num site financeiro é problema, não placeholder". A fila estava certa em segurá-lo: um painel de cotação construído do jeito comum é um componente que mente por padrão, porque o estado natural de um feed é morrer sem avisar.

O que o destravou. Não foi decidir mostrar número falso com um aviso. Foi inverter a pergunta: em vez de "como avisar que o dado pode estar velho?", "o que faz este componente afirmar que o dado é novo?". A resposta virou estrutura — data-em, data-validade, a .coto-fonte obrigatória e um selo de liveness que só a conferência concede.

A forma mudou no caminho, e a primeira foi descartada. A construção original era uma faixa em esteira, no padrão de ticker de bolsa: símbolo, valor e variação correndo de lado, com a regra "movimento é uma afirmação de que o dado está vivo" — a esteira só andava no estado vivo. Funcionava e passava na conformidade. Foi reprovada na revisão pela forma, não pelo motor: em faixa a pessoa espera o papel dela passar; em card, ela procura. Numa página institucional, quem abre a dobra de mercado quer conferir um papel específico, e a faixa transforma isso em espera. O motor de validade migrou inteiro; o que se perdeu foi a esteira, e o selo "ao vivo" ocupou o lugar que o movimento tinha como afirmação de liveness.

Precedente de raciocínio. A prova/manchetes-imprensa, de seis dias antes, pôs <time datetime> e o href da matéria no markup como estrutura pela mesma razão. Os dois casos são o mesmo caso, e vale como método: quando a peça faz uma afirmação que não pode sustentar sozinha, o que falta vira estrutura do markup — não linha da ficha. Ficha ninguém lê na hora de colar; estrutura está no arquivo que a pessoa copia, e some junto se for apagada.

O verde e o vermelho custaram um token novo, e a conversa que o §3 exige. Este foi o componente que trouxe --alta e --baixa para o core/tokens.css. O §3 trata token global como decisão de arquitetura a discutir antes, e foi o que se fez — a alternativa, deixar as duas cores como parafuso local do componente, adiava o problema para o próximo que precisasse (o pill-status-pulsante está na fila do gabriel-rocha-studio esperando exatamente isso).

Duas decisões saíram junto, e as duas são do token, não deste componente:

E a regra que veio anexada ao token: cor nunca é o único portador da direção. O par verde/vermelho é exatamente o que 8% dos homens não distinguem. Aqui a direção está na seta, no sinal e por extenso em .visually-hidden — o componente continuaria legível em preto e branco.

Calibragem no primeiro uso. Espere ajustar --coto-min ao número de papéis: 230px dá quatro colunas em 1240px, e cinco papéis pedem 190px ou uma segunda fileira. Confira o data-validade contra a cadência real do seu feed — prazo mais curto que o intervalo de atualização faz o painel piscar entre vivo e vencido, que é pior que qualquer um dos dois. E meça o .coto-nome contra os seus nomes reais: o corte em uma linha é silencioso.