# Ficha → widget do produto → spec Vega-Lite

## Conteúdo

- 1. O mapa: `relacao` → widget do Horus BI → spec
  - 1.1 `magnitude` → `Barline` vertical
  - 1.2 `ranking` → `Barline` horizontal
  - 1.3 `tendencia` → `Barline` linha
  - 1.4 `tendencia-inline` → sparkline dentro do `Kpi`
  - 1.5 `parte-todo` → `Pie` donut até 4 categorias, `Barline` a partir de 5
  - 1.6 `distribuicao` → `Barline` binada, ou `Punchcard` se for bidimensional
  - 1.7 `correlacao` → `Barline` scatter
  - 1.8 `desvio` → `Gauge`
  - 1.9 `espacial` → `Map`
  - 1.10 `fluxo` → `Diagram` ou `Svelte`, sempre `custom: true`
  - 1.11 `metrica-unica` → `Kpi` (HTML, não Vega-Lite)
  - 1.12 `lookup` → `Table`, ou `Matrix` se tiver grupo de coluna
- 2. Como o placeholder entra no spec
- 3. Exemplo completo, renderizado e validado
- 4. O campo `forma:` — série com forma declarada, nunca ruído aleatório
- 5. Como o tema é injetado (e a fonte registrada)
- 6. A regra de ordem da paleta — `scale.domain` explícito

Este arquivo é o coração operacional da `lumo-mockup`: pega o campo `relacao:`
que a `lumo-scope` escreveu na ficha e devolve **duas coisas**, não uma. O tipo
de widget do produto que aquele escopo vai virar depois da assinatura, e o spec
Vega-Lite que renderiza o mockup dele agora.

A supressão de [`forma-sem-magnitude.md`](forma-sem-magnitude.md) já vem
aplicada, e o tema de [`identidade-visual.md`](identidade-visual.md) já vem
injetado. Leia ANTES de transformar qualquer ficha em spec.

> **Mudou em 2026-07.** A coluna "widget do produto" é nova, e existe para que a
> ficha aterrisse num nome que a plataforma entrega de verdade
> (`Widget.svelte:50-67`, catalogado em
> [`chassi-horus-bi.md`](chassi-horus-bi.md) §9). O mecanismo de supressão
> deixou de ser `axis: null`, passou brevemente pelo slab (faixa cinza) e hoje é
> o **placeholder sequencial fixo** (`R$ 123.456,78`, o mesmo em todo o widget).
> Todo spec deste arquivo foi reescrito por causa disso.

## 1. O mapa: `relacao` → widget do Horus BI → spec

Leia com a régua de `forma-sem-magnitude.md` §0: o container fica desenhado no
estilo do produto, e o número exibido é o **mesmo placeholder** em todo o
widget. Qual placeholder, é a heurística de escala da §2b de lá.

| # | `relacao` | Widget do produto | mark Vega-Lite | Recebe placeholder | Forma que permanece |
|---|---|---|---|---|---|
| 1 | `magnitude` | `Barline` | `bar` vertical | data label (rótulo do eixo Y sai) | eixo X categórico, gridline Y, barra do zero |
| 2 | `ranking` | `Barline` | `bar` horizontal | data label (rótulo do eixo X sai) | eixo Y ordenado (`sort: "-x"`), gridline X |
| 3 | `tendencia` | `Barline` | `line` | data label, se a ficha o declara | eixo X temporal, marcador por série |
| 4 | `tendencia-inline` | `Kpi` | `line` ≤80×24px | tudo, os dois eixos | só a silhueta |
| 5 | `parte-todo` | `Pie` (≤4); `Barline` ordenado ou empilhado (≥5, ver §1.5) | `arc` ou `bar` | rótulo de fatia | nomes na legenda, ordem dos segmentos |
| 6 | `distribuicao` | `Barline` ou `Punchcard` | `bar` ou `rect` | eixo de frequência | eixo de faixas |
| 7 | `correlacao` | `Barline` | `point` | os dois eixos | direção e forma da nuvem |
| 8 | `desvio` | `Gauge` | `arc` em camadas | o valor e a meta (o ponteiro não é desenhado) | arco, faixas de meta |
| 9 | `espacial` | `Map` | `geoshape` | escala de cor, valor no tooltip | geometria, nomes de local |
| 10 | `fluxo` | `Diagram` ou `Svelte` | `custom: true` | n/a | n/a |
| 11 | `metrica-unica` | `Kpi` | HTML, não Vega-Lite | o valor | overline, direção |
| 12 | `lookup` | `Table` ou `Matrix` | HTML, não Vega-Lite | coluna numérica (`Total` uma escala acima) | coluna `#`, header, ordenação |

Arquétipos do produto que **não** têm `relacao` própria e aparecem por
qualificação de uma existente:

| Widget | Quando |
|---|---|
| `Matrix` | `lookup` cujas colunas se agrupam por período ou hierarquia (o DRE é o caso canônico) |
| `Punchcard` | `distribuicao` em duas dimensões discretas (dia × hora) |
| `Forecast` | `tendencia` cuja ficha declara projeção além do último ponto real |
| `Calendar` | `distribuicao` ao longo do calendário |
| `ListBox` | dimensão que a ficha declara como filtro navegável, não como gráfico |

### 1.1 `magnitude` → `Barline` vertical

```json
{
  "layer": [
    {
      "mark": {"type": "bar", "width": 26},
      "encoding": {
        "x": {"field": "cat", "type": "nominal", "sort": null,
              "axis": {"title": null, "labelAngle": 0}},
        "y": {"field": "v", "type": "quantitative",
              "axis": {"title": null, "labels": false}}
      }
    },
    {
      "mark": {"type": "text", "dy": -9, "fontSize": 10, "color": "#000"},
      "encoding": {
        "x": {"field": "cat", "type": "nominal", "sort": null},
        "y": {"field": "v", "type": "quantitative"},
        "text": {"value": "R$ 123.456,78"}
      }
    }
  ]
}
```

O segundo layer é o data label, e repare no `"text": {"value": ...}`: é
`value`, constante, e **nunca** `field`. Amarrar o rótulo a um campo faria o
número variar com a altura da barra, que é exatamente o que a §2 proíbe.

O eixo Y perde `labels` e mantém a gridline, que vem do tema por
`axisQuantitative`. O rótulo de eixo sai por inteiro porque um eixo com escala é
uma régua, e a régua afirma grandeza ponto a ponto, ao contrário do data label
repetido.

`labelAngle: 0` só vale enquanto os nomes couberem. Com categoria longa, o
produto rotaciona a 45°, e o mockup deve rotacionar também.

### 1.2 `ranking` → `Barline` horizontal

Espelho do 1.1, com os canais trocados: `y` recebe a categoria com `sort: "-x"`,
`x` recebe a magnitude com `labels: false`. O data label passa a usar `dx`
positivo e `align: "left"`, porque no produto o rótulo da barra horizontal fica
à direita da barra.

O tema resolve a gridline sozinho: `axisQuantitative` liga o grid no eixo que
carrega magnitude, seja ele X ou Y, sem o spec precisar saber a orientação.

### 1.3 `tendencia` → `Barline` linha

```json
{
  "mark": {"type": "line", "point": true},
  "encoding": {
    "x": {"field": "d", "type": "temporal", "axis": {"title": null, "format": "%d %b %Y"}},
    "y": {"field": "v", "type": "quantitative", "axis": {"title": null, "labels": false}},
    "color": {"field": "serie", "type": "nominal"},
    "shape": {"field": "serie", "type": "nominal"}
  }
}
```

O `shape` por série não é enfeite. `identidade-visual.md` §6 mede que S1 e S2
colapsam sob deuteranopia, e o próprio produto separa série por símbolo em
linha. Sempre que houver 2 ou mais séries, o `shape` acompanha o `color`.

**Armadilha, verificada:** `color` e `shape` sobre o mesmo campo, os dois com
legenda, fazem o Vega-Lite desenhar **duas** legendas lado a lado, e o cliente
vê `Meta  Realizado  Meta  Realizado`. Dar o mesmo `title` aos dois não resolve
(o teste produziu três rótulos por legenda em vez de mesclar). A saída é
suprimir a legenda do `shape`:

```json
"shape": {"field": "serie", "type": "nominal", "legend": null}
```

O símbolo continua diferenciando a série **no gráfico**, que é o que a regra de
deuteranopia exige; só a legenda duplicada some.

O `format` do eixo temporal segue o padrão brasileiro observado no produto
(`14 Abr 2021`). O formato é forma e deve aparecer; a data é conhecida no
escopo, não é magnitude.

### 1.4 `tendencia-inline` → sparkline dentro do `Kpi`

Linha de no máximo 80×24px, dentro do card, com `axis: null` nos dois canais e
sem ponto. É o único lugar onde `axis: null` continua correto, porque a
sparkline do produto também não tem eixo nenhum.

### 1.5 `parte-todo` → `Pie` donut até 4 categorias, `Barline` a partir de 5

**A trava:**

> **Até 4 categorias → `Pie` com `innerSize: "60%"`. Sempre donut, nunca pizza
> sólida.**
> **5 ou mais → `Barline` horizontal ordenada (§1.2).**

Sem exceção, nem quando o dashboard de referência do cliente usa pizza. O
argumento completo, e o porquê de a fronteira epistêmica sobreviver aos dois
lados da trava, está em `forma-sem-magnitude.md` §5.

**Com 5 ou mais, a barra tem duas formas, e a ficha decide qual.** Este é o
ponto em que a regra genérica sozinha não basta:

| A pergunta do widget é | Forma | Sinal na ficha |
|---|---|---|
| "quem é o maior?" (ordem) | barra horizontal **ordenada**, uma por categoria | `legenda: nao` |
| "como se reparte o todo?" (composição) | barra **empilhada 100%**, com legenda de cor | `legenda: sim` |

As duas são `Barline`, e confundir troca a pergunta que a tela responde: barra
ordenada some com a noção de todo, e empilhada some com a comparação direta
entre categorias.

Quando os dois sinais divergirem, **a ficha vence**. Se ela declara
`legenda: sim` e a `mensagem:` fala de concentração ou participação, é
composição, mesmo que a regra genérica sugerisse ranking. É a mesma disciplina
do `SKILL.md`: o que vincula é a ficha, não a figura.

O gate roda contra o número de categorias da ficha, então não depende de dado
real. Em Vega-Lite, `innerSize: "60%"` do Highcharts corresponde a
`innerRadius` igual a 60% do `outerRadius`:

```json
{
  "mark": {"type": "arc", "innerRadius": 54, "outerRadius": 90},
  "encoding": {
    "theta": {"field": "v", "type": "quantitative"},
    "color": {"field": "cat", "type": "nominal",
              "legend": {"orient": "bottom"}}
  }
}
```

A legenda do donut vai para o rodapé, ao contrário do default do tema. É o que o
produto faz em `Pie.svelte`, e é o que os prints mostram.

Nenhum rótulo de fatia é emitido: percentual de fatia é magnitude pura.

### 1.6 `distribuicao` → `Barline` binada, ou `Punchcard` se for bidimensional

Barra sobre faixas, com o eixo de frequência sem rótulo. As faixas em si são forma
e ficam rotuladas (`0-30 dias`, `31-60 dias`), porque o corte das faixas é
decisão de escopo que o cliente precisa aprovar.

Quando a distribuição cruza duas dimensões discretas (dia da semana × hora),
o widget do produto é `Punchcard`, e a intensidade da célula é magnitude: as
células ficam sem preenchimento, só com a grade.

### 1.7 `correlacao` → `Barline` scatter

`point` com os dois eixos em `labels: false`. A nuvem comunica direção e
dispersão, que é forma; nenhum dos dois eixos ganha número.

### 1.7b Comparação com referência: nunca linha × linha

Vale para meta, orçamento, ano anterior, benchmark, qualquer par
medido × referência.

> **Duas linhas leem como "duas séries temporais independentes", e não como
> "medido contra um alvo".** O olho compara as duas curvas entre si, em vez de
> ler uma delas como régua.

A separação tem que estar no **mark**, não só na cor. É a mesma disciplina que o
`wireframe-kit.md` já enuncia ("real = preenchido, plano/meta = contorno") e que
o flint-chart da Microsoft formaliza ao fazer do bullet um tipo com canal `goal`
**obrigatório**.

| Contexto da ficha | Encoding |
|---|---|
| medido × referência ao longo do tempo | **barra** (realizado, preenchido) + **linha** (meta), eixo Y compartilhado |
| medido × referência por categoria | **bullet**: barra do realizado mais um `tick` na posição da referência |
| um único par, sem série | `Kpi` com a referência no `subtitle`, sem gráfico |

```json
{
  "layer": [
    {"mark": {"type": "bar", "width": 24},
     "encoding": {
       "x": {"field": "m", "type": "nominal", "sort": null},
       "y": {"field": "real", "type": "quantitative", "axis": {"labels": false}},
       "color": {"datum": "Realizado", "legend": {"title": null, "orient": "top"}}}},
    {"mark": {"type": "line", "point": true, "strokeWidth": 2},
     "encoding": {
       "x": {"field": "m", "type": "nominal", "sort": null},
       "y": {"field": "meta", "type": "quantitative"},
       "color": {"datum": "Meta"}}}
  ]
}
```

O `"color": {"datum": "..."}` é o que dá legenda aos dois layers sem precisar de
um campo de série no dado, e mantém uma legenda só.

Duas barras agrupadas também servem quando a ficha quer comparação lado a lado
com a mesma força visual para os dois (típico de "este ano × ano passado"). O
que não serve, em nenhuma hipótese, é linha × linha.

### 1.8 `desvio` → `Gauge`

O `Gauge` do produto é um velocímetro com arco em faixas, ponteiro, e o
percentual em destaque embaixo (`Visão Diretoria`, `Resumo do mês`).

Aqui a fronteira morde igual ao donut: **a posição do ponteiro é a magnitude.**
Um ponteiro em qualquer posição afirma um percentual de atingimento, e colocá-lo
no meio do arco afirma 50%.

Regra: **o mockup desenha o arco e as faixas de meta, e não desenha o
ponteiro.** O valor abaixo do arco recebe o placeholder, e o rótulo da meta
(`Objetivo: R$ 877.738,00` nos prints) também, porque a meta do cliente é
número que ninguém confirmou nesta fase.

O que sobra comunica com precisão o que está sendo vendido: "esta tela terá um
velocímetro de atingimento de meta, com faixas". Que é exatamente o escopo.

### 1.9 `espacial` → `Map`

Depende de asset externo (GeoJSON de UF ou município). Nome de local é forma e
aparece; a escala de cor do choropleth é magnitude e não aparece, então o mapa
sai com a geometria desenhada e preenchimento neutro.

Se a ficha vende o choropleth como recurso, a barra de legenda gradiente pode
ser desenhada **sem os números das pontas**, porque a existência da escala é
forma e os limites dela são magnitude.

### 1.10 `fluxo` → `Diagram` ou `Svelte`, sempre `custom: true`

Sankey e afins ficam fora do catálogo Vega-Lite nativo desta skill. Marque
`custom: true` na ficha e sinalize tier XL no sizing, para gerenciar expectativa
de custo cedo.

### 1.11 `metrica-unica` → `Kpi` (HTML, não Vega-Lite)

Não passa por spec nenhum. O card é HTML, seguindo os slots do `KpiV2`
detalhados em `forma-sem-magnitude.md` §3 e `chassi-horus-bi.md` §6.

### 1.12 `lookup` → `Table`, ou `Matrix` se tiver grupo de coluna

Também HTML. A tabela do produto tem marcas próprias (coluna `#`, header sem
fundo, valor à direita, linha `Total`, scrollbar) catalogadas em
`chassi-horus-bi.md` §7. Cada coluna numérica repete o mesmo placeholder em
todas as linhas, e a linha `Total` usa o de uma escala acima
(`forma-sem-magnitude.md` §2c).

Quando as colunas se agrupam por período ou hierarquia, o widget é `Matrix`, e
o mockup precisa mostrar o header de dois níveis, a indentação das linhas e as
linhas de subtotal, porque essa estrutura **é** o escopo que está sendo
aprovado.

## 2. Como o placeholder entra no spec

O placeholder é `mark: "text"` num layer próprio, com o valor **constante**.

```json
{
  "mark": {"type": "text", "dy": -9, "fontSize": 10, "color": "#000"},
  "encoding": {
    "x": {"field": "cat", "type": "nominal", "sort": null},
    "y": {"field": "v", "type": "quantitative"},
    "text": {"value": "R$ 123.456,78"}
  }
}
```

| Propriedade | Valor | Por quê |
|---|---|---|
| `text` | `{"value": "..."}` | **constante**. `{"field": ...}` faria o número acompanhar a barra, que é o que a fronteira proíbe |
| `fontSize` | 10 | tamanho do data label no produto |
| `color` | `#000` | `Barline.svelte:158` |
| `dy` / `dx` | desloca para fora da barra | replica a posição do rótulo no produto |

Qual string entra no `value` sai da heurística de escala
(`forma-sem-magnitude.md` §2b), nunca de escolha ad hoc por widget.

### O eixo de magnitude não recebe placeholder

O rótulo do eixo sai por inteiro (`"axis": {"labels": false}`) e só a gridline
fica. Isso não contradiz o data label: **um eixo é uma régua**, e régua afirma
grandeza em cada tick, de forma contínua. Repetir o mesmo número em todos os
ticks leria como bug de renderização e não como placeholder, porque um eixo com
todos os valores iguais é impossível por construção.

O data label é outra coisa: é um rótulo por elemento, e rótulos iguais em
elementos diferentes leem como padrão declarado.

### A caixinha do data label

O produto desenha o rótulo sobre um fundo `#f6f6f6aa` de raio 5
(`Barline.svelte:145-153`). Reproduzi-la exige um layer de `rect` atrás do
`text`. É opcional: vale quando o gráfico tem fundo carregado (barra densa, área
preenchida) e é dispensável em gráfico limpo.

## 3. Exemplo completo, renderizado e validado

Ficha:

```yaml
- widget: 3
  titulo: "Faturamento por Período"
  relacao: magnitude
  forma: tendencia-alta
  confianca: confirmado
```

Spec e render:

```python
import json, re, vl_convert as vlc

SKILL = "<SKILL_DIR>"
vlc.register_font_directory(f"{SKILL}/assets/fonts")     # obrigatório, ver §5
theme = json.load(open(f"{SKILL}/references/horus-theme.json"))

vals  = [round(0.25 + 0.055 * i, 4) for i in range(8)]   # forma: tendencia-alta
meses = ["Mai", "Jun", "Jul", "Ago", "Set", "Out", "Nov", "Dez"]
data  = [{"cat": m, "v": v} for m, v in zip(meses, vals)]

spec = {
  "data": {"values": data},
  "width": 420, "height": 200,
  "layer": [
    {"mark": {"type": "bar", "width": 26},
     "encoding": {
       "x": {"field": "cat", "type": "nominal", "sort": None,
             "axis": {"title": None, "labelAngle": 0}},
       "y": {"field": "v", "type": "quantitative",
             "axis": {"title": None, "labels": False}}}},
    {"mark": {"type": "rect", "cornerRadius": 5, "color": "#E2E8F0",
              "height": 12, "width": 40, "yOffset": -11},
     "encoding": {
       "x": {"field": "cat", "type": "nominal", "sort": None},
       "y": {"field": "v", "type": "quantitative"}}}
  ]
}

svg = vlc.vegalite_to_svg(spec, config=theme)
```

**A prova, executada de verdade e não estimada** (saída de
`exemplo/render-prova.py`):

```
=== escalas desta tela ===
  receita  R$ 123.456,78   total R$ 1.234.567,89
  ticket   R$ 123,45       qtde  123

=== widgets ===
  [ok ] magnitude/Barline
  [ok ] ranking/Barline
  [ok ] parte-todo/Pie
  [ok ] tendencia/Barline

=== resumo ===
  mag          6725 bytes   Poppins=True   placeholder=True
  rank         5518 bytes   Poppins=True   placeholder=True
  donut        5215 bytes   Poppins=True   placeholder=False
  tend        11550 bytes   Poppins=True   placeholder=False
```

`placeholder=False` no donut e na tendência está correto: nenhum dos dois emite
data label, então não há número a exibir.

### O assert anti-vazamento

Números agora **aparecem**, então o assert deixou de ser "nenhum dígito" e
passou a ser "todo dígito é conhecido":

```python
from placeholder import TODOS      # scripts/placeholder.py

def render(spec, nome, categorias=()):
    svg = vlc.vegalite_to_svg(spec, config=theme, vl_version="6.4")
    ok = set(TODOS) | set(categorias)          # placeholders válidos + categorias da ficha
    vazou = [t for t in re.findall(r"<text[^>]*>([^<]*)</text>", svg)
             if re.search(r"\d", t) and t.strip() not in ok]
    assert not vazou, f"{nome} vazou magnitude: {vazou}"
    return svg
```

O parâmetro `categorias` existe porque nome de categoria pode conter dígito e
continua sendo forma. Isso não é teórico: o primeiro render de um donut de aging
quebrou o assert com `['Vencido até 30d', 'Vencido 31-90d', 'Vencido +90d']`,
que são rótulos legítimos da ficha. Passar as categorias declaradas resolve sem
afrouxar a regra: um `R$ 147.892,33` continua quebrando.

## 4. O campo `forma:` — série com forma declarada, nunca ruído aleatório

### 4.1 O defeito real que motiva esta seção

Não é hipotético. Uma tentativa anterior de mockup dentro do Lumo gerou
dashboards com dado fake na própria plataforma e diagnosticou, entre outros
problemas: *"sparkline e séries que são ruído aleatório, não forma de negócio"*.

Picos sem sentido numa série temporal **denunciam dado falso** tanto quanto um
valor de KPI plausível, só que pela porta oposta: em vez de afirmar uma
magnitude específica, uma série aleatória nega que exista qualquer forma de
negócio por trás dela, e quem olha um gráfico executivo sabe que receita,
vendas e pedidos quase nunca têm essa cara. A mesma evidência registra o
contra-exemplo que funciona: o gráfico de Compras por Fornecedor tinha "a forma
de pareto certa", que é a prova de que forma declarada salva o widget.

### 4.2 A extensão da ficha

`forma:` é campo irmão de `relacao:`:

```yaml
- widget: 5
  titulo: "Receita Líquida por Loja — mês corrente"
  relacao: parte-todo
  forma: pareto        # sazonal | pareto | tendencia-alta | plato | cauda-longa
  confianca: confirmado
```

Quando ausente, o gerador cai num default determinístico por tipo de `relacao`
(`magnitude` e `tendencia` usam `tendencia-alta`; `ranking`, `distribuicao` e
`parte-todo` usam `pareto`). **Nunca** `random.random()`. O default é outra
forma determinística da lista, não a ausência de forma.

### 4.3 As 5 formas, geradas e não sorteadas

Cada forma é uma função determinística e parametrizada, sem `import random` em
lugar nenhum:

```python
import math

def serie_sazonal(n=12, base=0.5, amp=0.35):
    """Onda periódica: pico e vale se repetem no ciclo."""
    return [round(base + amp * math.sin(2 * math.pi * i / n), 4) for i in range(n)]

def serie_pareto(n=5, decay=0.62):
    """Decaimento geométrico ordenado: poucas categorias dominam."""
    return [round(decay ** i, 4) for i in range(n)]

def serie_tendencia_alta(n=12, start=0.25, slope=0.055):
    """Rampa linear crescente, sem ruído."""
    return [round(start + slope * i, 4) for i in range(n)]

def serie_plato(n=12, ceiling=1.0, start=0.15, decay=0.72):
    """Aproximação assintótica de um teto: cresce, depois satura."""
    return [round(ceiling - (ceiling - start) * decay ** i, 4) for i in range(n)]

def serie_cauda_longa(n=8, decay=0.5):
    """Mesma família do pareto, para distribuição de frequência."""
    return [round(decay ** i, 4) for i in range(n)]
```

`pareto` e `cauda_longa` usam a mesma matemática de propósito. A diferença é
semântica de uso: `pareto` descreve categorias nomeadas ordenadas,
`cauda_longa` descreve uma distribuição de frequência onde a maioria das faixas
tem contagem baixa e uma concentra o volume.

### 4.4 Por que os valores em si não importam, e por que isso não é uma brecha

Os números que essas funções produzem entram de verdade no `data.values` do
spec, porque não dá para desenhar uma barra sem um número por trás dela. A
pergunta honesta é se isso não é a mesma magnitude fictícia que a fronteira
proíbe.

**Não é, pelo mesmo motivo que uma barra ranqueada sem eixo numérico não afirma
o faturamento do cliente.** O número nunca é impresso em lugar nenhum do
artefato (o assert do §3 garante isso a cada render); ele só determina a
posição relativa de um elemento gráfico contra outro. Trocar `0.62` por `620`
produziria o mesmo desenho pixel a pixel, porque o Vega-Lite normaliza pelo
domínio dos dados. Essa é a prova de que o valor absoluto é irrelevante para o
que o mockup comunica.

O que importaria, e é isso que a função determinística protege, é a **relação
entre os pontos**. Um ranking `[1.0, 0.9, 0.89, 0.88, 0.87]` conta uma história
de negócio diferente de `[1.0, 0.62, 0.38, 0.24, 0.15]` mesmo sem eixo nenhum, e
é essa "forma relativa" que `forma-sem-magnitude.md` §1 autoriza mostrar. Gerar
a relação de um `random.random()` faria o mockup afirmar uma forma de negócio
que ninguém validou. Gerar de uma função com nome amarra a forma exibida à forma
que a ficha declarou, a mesma disciplina que já rege categoria e magnitude.

## 5. Como o tema é injetado (e a fonte registrada)

`horus-theme.json` nunca é escrito dentro do spec de um widget. Ele entra uma
vez, como `config`, na chamada de render:

```python
import json, vl_convert as vlc

vlc.register_font_directory(f"{SKILL_DIR}/assets/fonts")
theme = json.load(open(f"{SKILL_DIR}/references/horus-theme.json"))
svg   = vlc.vegalite_to_svg(spec, config=theme, vl_version="6.4")
```

**`register_font_directory` não é opcional, e esquecê-lo falha em silêncio.**
Poppins não costuma estar instalada no sistema. Sem registrar o diretório, o
PNG sai com **todos os rótulos ausentes** e nenhum erro é levantado; o SVG do
mesmo spec sai correto, porque ali a fonte é só um nome que o visualizador
resolve depois. Verificado: 43.584 bytes sem texto contra 54.511 bytes com
texto, mesmo spec. Detalhes em `assets/fonts/README.md`.

A chave `_fonte` do tema é metadado. Foi verificado que o Vega-Lite a tolera sem
erro, então o pipeline não precisa removê-la antes de passar o dicionário.

## 6. A regra de ordem da paleta — `scale.domain` explícito

`range.category` é um array **posicional**: a cor S1 (`#6567ea`) vai para o
primeiro valor do domínio **ordenado**, que o Vega-Lite resolve por ordem
alfabética quando o spec não diz nada. Ou seja, qual categoria do cliente recebe
o indigo é decisão de dado, e não de tema.

Quando importa qual categoria pega qual cor (destaque numa série específica,
ou coerência entre dois widgets que mostram as mesmas categorias), fixe no
encoding do spec, nunca no tema global:

```json
"color": {
  "field": "cat", "type": "nominal",
  "scale": {"domain": ["Loja Sul", "Loja Centro", "Loja Norte"],
            "range": ["#6567ea", "#30b1ec", "#10b981"]}
}
```

E lembre do teto de `identidade-visual.md` §7: **no máximo 5 séries
simultâneas**, porque a partir da sexta a paleta repete matiz (S6 e S9 são o
mesmo azul de S2). Ficha que pede mais precisa mudar de arquétipo, com small
multiples ou com a dimensão virando filtro.
