feat: poses ilustradas, animação e ajustes do Capicoda
- 4 poses (start/prof/anot/end) que trocam conforme o estado da conversa; fundo removido via scripts/build_images.py (Pillow) e embutidas em WebP. - capivara grande substitui o botão e surge com animação deslizando da borda. - esconde os links "Acessar/Conhecer o DECODA" quando o widget roda no próprio DECODA (window.CAPICODA_ON_DECODA). - chat: scroll automático ao surgirem os botões e overscroll-behavior:contain para o scroll não vazar para a página.
BIN
app/src/assets/capicoda_anot.jpeg
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
app/src/assets/capicoda_anot.png
Normal file
|
After Width: | Height: | Size: 315 KiB |
BIN
app/src/assets/capicoda_end.jpeg
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
app/src/assets/capicoda_end.png
Normal file
|
After Width: | Height: | Size: 302 KiB |
BIN
app/src/assets/capicoda_prof.jpeg
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
app/src/assets/capicoda_prof.png
Normal file
|
After Width: | Height: | Size: 284 KiB |
BIN
app/src/assets/capicoda_start.jpeg
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
app/src/assets/capicoda_start.png
Normal file
|
After Width: | Height: | Size: 261 KiB |
@@ -22,6 +22,8 @@ const HomePage = () => {
|
|||||||
// autocontido que se anexa ao body; na saída da home removemos o script e o
|
// autocontido que se anexa ao body; na saída da home removemos o script e o
|
||||||
// nó raiz (#dcs-root) para não vazar para outras rotas.
|
// nó raiz (#dcs-root) para não vazar para outras rotas.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Estamos dentro do próprio DECODA: o widget esconde os links "Acessar o DECODA".
|
||||||
|
window.CAPICODA_ON_DECODA = true;
|
||||||
const script = document.createElement("script");
|
const script = document.createElement("script");
|
||||||
script.src = capicodaUrl;
|
script.src = capicodaUrl;
|
||||||
script.defer = true;
|
script.defer = true;
|
||||||
|
|||||||
37
app/src/vendor/capicoda/README.md
vendored
@@ -83,10 +83,43 @@ cta_* ─┬─ Ver repositório (link)
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 🦫 Poses da capivara (imagens)
|
||||||
|
|
||||||
|
O mascote é uma imagem grande (≈190px) que **substitui o botão** e **troca de pose**
|
||||||
|
conforme o estado da conversa. As 4 poses vivem embutidas como data URI (WebP) no
|
||||||
|
`capicoda.js`, então o widget continua autocontido (1 tag de script).
|
||||||
|
|
||||||
|
| Pose | Quando | Nós do diálogo |
|
||||||
|
|------|--------|----------------|
|
||||||
|
| `start` | acenando, no início | `start` |
|
||||||
|
| `prof` | explicando | `oque_decoda`, `setup`, `fluxo_pr` |
|
||||||
|
| `anot` | pedindo respostas | `qualifica`, `area` |
|
||||||
|
| `end` | encerrando | `cta_*`, `aprendiz`, `curioso` |
|
||||||
|
|
||||||
|
O mapa nó→pose é o objeto **`NODE_IMG`** no topo do `capicoda.js` (fácil de ajustar).
|
||||||
|
Todas as imagens têm o mesmo canvas (768×768), então a capivara não muda de tamanho
|
||||||
|
nem se desloca ao trocar de pose.
|
||||||
|
|
||||||
|
### Regenerar as imagens
|
||||||
|
|
||||||
|
As fontes são `app/src/assets/capicoda_{start,prof,anot,end}.jpeg`. Para remover o
|
||||||
|
fundo e reembutir no widget:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/build_images.py # requer Pillow
|
||||||
|
```
|
||||||
|
|
||||||
|
O script remove o fundo (flood-fill a partir das bordas), salva os PNGs transparentes
|
||||||
|
em `app/src/assets/capicoda_*.png` e reescreve o bloco `IMGS` entre os marcadores
|
||||||
|
`/* __IMAGES_START__ */ … /* __IMAGES_END__ */`. **Não edite o `IMGS` à mão.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🎨 Identidade
|
## 🎨 Identidade
|
||||||
|
|
||||||
Capivara marrom de boina vermelha com estrela dourada (SVG inline). Paleta MTST
|
Capivara marrom de boina verde com estrela vermelha. Paleta MTST (vermelho `#c1121f`).
|
||||||
(vermelho `#c1121f`). Tom acolhedor e militante-bem-humorado.
|
Tom acolhedor e militante-bem-humorado. Fallback: SVG inline desenhado à mão, usado se
|
||||||
|
as imagens não carregarem.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
106
app/src/vendor/capicoda/capicoda.js
vendored
152
app/src/vendor/capicoda/scripts/build_images.py
vendored
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Capicoda — processamento das imagens do mascote.
|
||||||
|
|
||||||
|
Para cada pose (capicoda_<estado>.jpeg em app/src/assets):
|
||||||
|
1. Remove o fundo branco (flood-fill a partir das bordas sobre a máscara de
|
||||||
|
pixels claros — preserva os brancos INTERNOS, cercados pelo contorno escuro:
|
||||||
|
olhos, papel do caderno, brilho dos óculos).
|
||||||
|
2. Salva um PNG transparente em alta (canvas quadrado preservado — todas as
|
||||||
|
poses ficam do MESMO tamanho, então a capivara não "pula" ao trocar).
|
||||||
|
3. Gera uma versão reduzida (EMB x EMB), codifica em base64 (WebP se suportado,
|
||||||
|
senão PNG) e injeta o mapa `IMGS` no capicoda.js entre os marcadores
|
||||||
|
/* __IMAGES_START__ */ ... /* __IMAGES_END__ */.
|
||||||
|
|
||||||
|
Uso: python3 scripts/build_images.py
|
||||||
|
Requisitos: Pillow.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
from PIL import Image, features
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
CAPICODA_DIR = os.path.normpath(os.path.join(HERE, ".."))
|
||||||
|
ASSETS = os.path.normpath(os.path.join(HERE, "..", "..", "..", "assets"))
|
||||||
|
CAPICODA_JS = os.path.join(CAPICODA_DIR, "capicoda.js")
|
||||||
|
|
||||||
|
STATES = ["start", "prof", "anot", "end"]
|
||||||
|
WORK = 768 # resolução de processamento/saída do PNG (quadrado, uniforme)
|
||||||
|
EMB = 384 # resolução embutida no JS
|
||||||
|
LIGHT = 190 # canal mínimo p/ considerar um pixel "claro" (fundo/sombra)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_bg(img):
|
||||||
|
"""Torna transparente o fundo claro conectado às bordas."""
|
||||||
|
img = img.convert("RGBA").resize((WORK, WORK), Image.LANCZOS)
|
||||||
|
w, h = img.size
|
||||||
|
px = img.load()
|
||||||
|
|
||||||
|
# 1) máscara de pixels "claros" (fundo branco + sombra clara)
|
||||||
|
light = bytearray(w * h)
|
||||||
|
for y in range(h):
|
||||||
|
row = y * w
|
||||||
|
for x in range(w):
|
||||||
|
r, g, b, _ = px[x, y]
|
||||||
|
if r >= LIGHT and g >= LIGHT and b >= LIGHT:
|
||||||
|
light[row + x] = 1
|
||||||
|
|
||||||
|
# 2) BFS a partir dos pixels claros da borda -> só o fundo EXTERNO
|
||||||
|
visited = bytearray(w * h)
|
||||||
|
dq = deque()
|
||||||
|
|
||||||
|
def seed(x, y):
|
||||||
|
i = y * w + x
|
||||||
|
if light[i] and not visited[i]:
|
||||||
|
visited[i] = 1
|
||||||
|
dq.append((x, y))
|
||||||
|
|
||||||
|
for x in range(w):
|
||||||
|
seed(x, 0)
|
||||||
|
seed(x, h - 1)
|
||||||
|
for y in range(h):
|
||||||
|
seed(0, y)
|
||||||
|
seed(w - 1, y)
|
||||||
|
|
||||||
|
while dq:
|
||||||
|
x, y = dq.popleft()
|
||||||
|
if x > 0:
|
||||||
|
seed(x - 1, y)
|
||||||
|
if x < w - 1:
|
||||||
|
seed(x + 1, y)
|
||||||
|
if y > 0:
|
||||||
|
seed(x, y - 1)
|
||||||
|
if y < h - 1:
|
||||||
|
seed(x, y + 1)
|
||||||
|
|
||||||
|
# 3) zera o alpha do fundo externo
|
||||||
|
cleared = 0
|
||||||
|
for y in range(h):
|
||||||
|
row = y * w
|
||||||
|
for x in range(w):
|
||||||
|
if visited[row + x]:
|
||||||
|
r, g, b, _ = px[x, y]
|
||||||
|
px[x, y] = (r, g, b, 0)
|
||||||
|
cleared += 1
|
||||||
|
return img, cleared
|
||||||
|
|
||||||
|
|
||||||
|
def data_uri(img):
|
||||||
|
"""Reduz para EMB e devolve (data_uri, mime, n_bytes)."""
|
||||||
|
small = img.resize((EMB, EMB), Image.LANCZOS)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
if features.check("webp"):
|
||||||
|
small.save(buf, format="WEBP", quality=90, method=6)
|
||||||
|
mime = "image/webp"
|
||||||
|
else:
|
||||||
|
small.save(buf, format="PNG", optimize=True)
|
||||||
|
mime = "image/png"
|
||||||
|
raw = buf.getvalue()
|
||||||
|
b64 = base64.b64encode(raw).decode("ascii")
|
||||||
|
return "data:%s;base64,%s" % (mime, b64), mime, len(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def patch_js(uris):
|
||||||
|
with open(CAPICODA_JS, "r", encoding="utf-8") as f:
|
||||||
|
src = f.read()
|
||||||
|
lines = [' var IMGS = {']
|
||||||
|
for s in STATES:
|
||||||
|
lines.append(' %s: "%s",' % (s, uris[s]))
|
||||||
|
lines.append(' };')
|
||||||
|
block = "/* __IMAGES_START__ */\n" + "\n".join(lines) + "\n /* __IMAGES_END__ */"
|
||||||
|
new, n = re.subn(
|
||||||
|
r"/\* __IMAGES_START__ \*/.*?/\* __IMAGES_END__ \*/",
|
||||||
|
lambda _: block,
|
||||||
|
src,
|
||||||
|
flags=re.S,
|
||||||
|
)
|
||||||
|
if n != 1:
|
||||||
|
raise SystemExit("ERRO: marcadores __IMAGES_START/END__ não encontrados (n=%d)" % n)
|
||||||
|
with open(CAPICODA_JS, "w", encoding="utf-8") as f:
|
||||||
|
f.write(new)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
uris = {}
|
||||||
|
total = 0
|
||||||
|
for s in STATES:
|
||||||
|
src = os.path.join(ASSETS, "capicoda_%s.jpeg" % s)
|
||||||
|
if not os.path.exists(src):
|
||||||
|
raise SystemExit("ERRO: não encontrei %s" % src)
|
||||||
|
img = Image.open(src)
|
||||||
|
out, cleared = remove_bg(img)
|
||||||
|
|
||||||
|
png_path = os.path.join(ASSETS, "capicoda_%s.png" % s)
|
||||||
|
out.save(png_path, format="PNG", optimize=True)
|
||||||
|
|
||||||
|
uri, mime, nbytes = data_uri(out)
|
||||||
|
uris[s] = uri
|
||||||
|
total += nbytes
|
||||||
|
print(" %-6s -> %s (fundo: %d px) | embutido %s %d KB"
|
||||||
|
% (s, os.path.basename(png_path), cleared, mime, nbytes // 1024))
|
||||||
|
|
||||||
|
patch_js(uris)
|
||||||
|
print("PNGs transparentes (%dx%d) em %s" % (WORK, WORK, ASSETS))
|
||||||
|
print("IMGS embutido no capicoda.js — total embutido: %d KB" % (total // 1024))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||