/** * Reel "Sua colinha para 2026" — the visual template of the chat reel: the * WhatsApp-style conversation (voter on the right, "Time 1313" on the left), * the printed-urna candidate cards and the two end screens (the filled colinha * slip and the official dobradinha santinho). Structure ports the reference * reel (dep_padrejoao) with the campaign kit language: official star badge, * Brexter display face, kit palette. * * Pure string builder: fonts and image data URIs are passed in by the builder * (`build-colinha-reel.mjs`); the page is rendered at 540×960 with * deviceScaleFactor 2 → physical 1080×1920 frames. `window.colinhaRender(t)` * sets the whole page state for a millisecond of the timeline (no CSS * animations — every frame is deterministic), and `window.colinhaReady` is the * promise the builder awaits before screenshotting. */ import { COLINHA_END, COLINHA_REEL } from './colinhaReel.mjs' const BRAND = { red: '#e4102f', blue: '#184e92', ink: '#111111', chatGradient: 'linear-gradient(118deg,#5a67ec 0%,#8d37db 58%,#b52fb5 100%)', } const escapeHtml = (value) => String(value) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') const BACK_ICON = '' const VIDEO_ICON = '' const INFO_ICON = '' const CAMERA_ICON = '' const MIC_ICON = '' const IMAGE_ICON = '' const PARTY_STAR = '' /** * `@font-face` blocks as data URIs. Inter (the site's own grotesque) carries * the whole reel — chat, cards, labels; Brexter is the kit display face, kept * for the chat title; Arimo is the local metric-compatible Arial substitute * used only where the drawn row must match the site's own colinha render. */ export const colinhaFontCss = ({ brexterTtf, interWoff2, arimoWoff2 }) => ` @font-face{font-family:ReelBrexter;src:url(data:font/ttf;base64,${brexterTtf}) format('truetype');font-weight:400;font-style:normal} @font-face{font-family:ReelInter;src:url(data:font/woff2;base64,${interWoff2}) format('woff2');font-weight:100 900;font-style:normal} @font-face{font-family:ReelArimo;src:url(data:font/woff2;base64,${arimoWoff2}) format('woff2');font-weight:100 900;font-style:normal} ` const BASE_CSS = ` *,*::before,*::after{box-sizing:border-box;margin:0;padding:0} html,body{width:540px;height:960px;overflow:hidden;background:#fff} body{font-family:ReelInter,Inter,system-ui,sans-serif;color:${BRAND.ink};-webkit-font-smoothing:antialiased;text-rendering:geometricPrecision} .stage{position:relative;width:540px;height:960px;overflow:hidden;background:#fff;perspective:1600px} .brexter{font-family:ReelBrexter,Impact,Haettenschweiler,'Arial Narrow Bold',sans-serif;font-weight:400} img{display:block} /* ---------------- chat ---------------- */ .chat{position:absolute;inset:0;z-index:1;background:#fff;transform-origin:50% 50%} .chat-top{position:absolute;z-index:6;inset:0 0 auto;height:52px;display:flex;align-items:center;gap:9px;padding:0 13px 0 10px;background:#fff;border-bottom:1px solid #eceef1} .chat-top__back{width:18px;height:18px;color:#15181d} .chat-top__avatar{width:35px;height:35px;flex:none;overflow:hidden;border-radius:50%;background:#fff;box-shadow:0 0 0 1px #e7eaee} .chat-top__avatar img{width:100%;height:100%;object-fit:cover} .chat-top__name{font-size:24px;line-height:1;letter-spacing:.005em;white-space:nowrap} .chat-top__icons{display:flex;align-items:center;gap:16px;margin-left:auto;color:#15181d} .chat-top__icons svg{width:22px;height:22px} .messages{position:absolute;z-index:4;top:52px;bottom:53px;left:0;right:0;padding:0 12px 14px;overflow:hidden;background:#fff} .day{margin:13px 0 11px;text-align:center;font-size:13px;font-weight:700;letter-spacing:.19em;color:#8b9099;text-transform:uppercase} .row{display:flex;margin:0 0 8px} .row--right{flex-direction:column;align-items:flex-end} .row--left{flex-direction:column;align-items:flex-start} .row--card{margin-bottom:11px} .bubble{max-width:440px;padding:11px 15px;border-radius:18px;font-size:20px;line-height:1.34;letter-spacing:.001em} .bubble--assistant{background:#f0f1f3;color:#141414;border-bottom-left-radius:5px} .bubble--voter{background:${BRAND.chatGradient};color:#fff;border-bottom-right-radius:5px} .bubble--number{background:${BRAND.chatGradient};color:#fff;border-bottom-right-radius:5px;font-weight:700;letter-spacing:.02em} .seen{display:none;margin:2px 5px 0 0;font-size:15px;color:#9aa0a8} .typing{display:flex;align-items:center;gap:3.8px;padding:12px 13px} .typing i{width:5.4px;height:5.4px;border-radius:50%;background:#b9bec6;display:block} .bubble--card{background:#f0f1f3;padding:7px 7px 8px;border-radius:16px;border-bottom-left-radius:5px;max-width:none} .anotei-bar{height:3px;margin:-7px -7px 7px;border-radius:16px 16px 0 0;background:#dfe3e9;overflow:hidden} .anotei-bar i{display:block;height:100%;width:0;background:linear-gradient(90deg,${BRAND.red},${BRAND.blue})} .anotei-label{margin:0 2px 8px;font-size:19px;color:#141414} /* candidate card (urna language) */ .card{width:420px;padding:11px;display:grid;grid-template-columns:168px minmax(0,1fr);grid-template-rows:auto auto;column-gap:13px;row-gap:9px;background:#fff;border-radius:13px;box-shadow:0 1px 2px rgb(16 24 40 / 10%)} .card__photo{position:relative;grid-column:1;grid-row:1;width:168px;height:208px;overflow:hidden;border-radius:9px;background:linear-gradient(168deg,#fdfaf4 0%,#f2e9da 100%);box-shadow:inset 0 0 0 1px rgb(16 24 40 / 5%)} .card__photo img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:50% 0} .card__info{grid-column:2;grid-row:1;display:flex;flex-direction:column;min-width:0;padding:3px 1px 0 0} .card__office{font-size:14px;font-weight:700;letter-spacing:.11em;color:#31363d;white-space:nowrap} .card__party{display:flex;align-items:center;gap:7px;margin-top:7px;font-size:28px;font-weight:900;line-height:1;letter-spacing:-.01em;color:#0f0f0f} .card__party svg{width:22px;height:22px;color:${BRAND.red}} .card__state{margin-top:9px;font-size:12.5px;font-weight:700;letter-spacing:.145em;color:#767d86} .card__spacer{flex:1 1 auto;min-height:8px} .card__urna{font-size:12.5px;font-weight:700;letter-spacing:.145em;color:#767d86;margin-bottom:5px} .card__digits{display:flex;gap:5px} .card__digit{display:grid;place-items:center;width:43px;height:53px;border-radius:8px;background:#1c1c1c;color:#fff;font-size:32px;font-weight:800;line-height:1;font-variant-numeric:tabular-nums} /* "Sem estadual": the very same card shell as the candidates — photo window on the left (the official Lula mark on a light neutral background) and the info column on the right, with the instruction to pick a state deputy from Lula's team where the urna digits sit. No party is named. */ .card__photo--mark{background:linear-gradient(160deg,#fdfbf6 0%,#efe9dd 100%);display:flex;align-items:center;justify-content:center;padding:0} .card__photo--mark img{position:static;width:96%;height:auto;object-fit:contain} .card__lead{margin-top:6px;font-size:26px;font-weight:900;line-height:1;letter-spacing:-.012em;color:#0f0f0f} .card__name{grid-column:1/-1;grid-row:2;font-size:34px;font-weight:900;line-height:1;letter-spacing:-.012em;color:#0f0f0f;padding:3px 1px 1px} /* composer */ .composer{position:absolute;z-index:6;inset:auto 0 0;height:53px;display:flex;align-items:center;gap:9px;padding:8px 11px 9px;background:#fff} .composer__cam{flex:none;display:grid;place-items:center;width:32px;height:32px;border-radius:50%;background:${BRAND.chatGradient};color:#fff} .composer__cam svg{width:17px;height:17px} .composer__pill{flex:1 1 auto;display:flex;align-items:center;gap:12px;height:35px;padding:0 13px;border:1px solid #e7e9ed;border-radius:18px;color:#9aa0a8} .composer__pill span{font-size:16px} .composer__icons{display:flex;align-items:center;gap:14px;margin-left:auto;color:#15181d} .composer__icons svg{width:19px;height:19px} /* ---------------- end screen: the colinha assembles itself ---------------- */ .screen{position:absolute;inset:0;z-index:2;background:#fff;transform-origin:50% 50%;backface-visibility:hidden;visibility:hidden} .screen--colinha{z-index:3} .colinha-art{position:absolute;inset:0;width:540px;height:960px;object-fit:cover} /* The official group photo (the "Time de você" family shot) fills the art's baked photo band; the wave clip keeps the art's crest in front of it. Each candidate's patch of the blurred copy fades out at their reveal. */ .figures{position:absolute;inset:0;clip-path:var(--wave)} .group{position:absolute;inset:0;background-image:var(--group);background-size:540px 720px;background-position:0 -60px;background-repeat:no-repeat} .group--blur{background-image:var(--group-blur);opacity:1;mask-repeat:no-repeat;-webkit-mask-repeat:no-repeat} /* The art's own red band content is covered, but its coloured crest stays: the cover is clipped by the band's own top curve. */ .band-cover{position:absolute;inset:0;clip-path:var(--band)} .band-logo{position:absolute;opacity:0} .band-logo img{display:block;width:100%;height:auto} /* The slip's own title (with its sky), feathered; it appears last. */ .title-patch{position:absolute;left:90px;top:5px;width:360px;height:135px;opacity:0} .title-patch img{display:block;width:100%;height:100%} .row-mask{position:absolute;background:#fff} .colinha-row{position:absolute;inset:0;opacity:0} .colinha-mask{position:absolute;left:132.3px;top:536.64px;width:309.96px;height:27.84px;background:#fff} .colinha-copy{position:absolute;left:145.26px;top:542.02px;width:285.12px;height:24px;display:flex;align-items:baseline;justify-content:space-between;gap:8.1px} .colinha-copy__office{font-family:ReelArimo,Arial,Helvetica,sans-serif;font-size:13.45px;font-weight:400;line-height:1;color:#202020;white-space:nowrap;flex:none} .colinha-copy__name{font-family:ReelArimo,Arial,Helvetica,sans-serif;font-size:18.36px;font-weight:900;line-height:1;color:${BRAND.red};letter-spacing:-.04em;white-space:nowrap;text-align:right} .colinha-digits{position:absolute;left:139.86px;top:560.64px;display:flex;gap:4.38px} .colinha-digit{display:grid;place-items:center;width:32.94px;height:41.76px;font-family:ReelArimo,Arial,Helvetica,sans-serif;font-size:22.95px;font-weight:900;line-height:1;color:#171717} /* intro fade (white cover over the very first frames) */ .fade{position:absolute;z-index:9;inset:0;background:#fff;pointer-events:none} ` const RENDER_JS = ` ;(() => { const CFG = window.__colinhaConfig const clamp01 = (v) => Math.min(1, Math.max(0, v)) const easeOutCubic = (p) => 1 - Math.pow(1 - p, 3) const easeInOutCubic = (p) => (p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2) const beats = CFG.beats const nodeById = new Map() for (const el of document.querySelectorAll('[data-beat]')) nodeById.set(el.dataset.beat, el) const messages = document.getElementById('messages') const chat = document.getElementById('chat') const screens = { colinha: document.getElementById('screen-colinha') } const fade = document.getElementById('fade') const ends = beats.filter((beat) => beat.kind === 'end') const titlePatch = document.getElementById('title-patch') const figures = new Map() const logos = new Map() const rowMasks = new Map() for (const el of document.querySelectorAll('[data-figure]')) figures.set(el.dataset.figure, el) for (const el of document.querySelectorAll('[data-logo]')) logos.set(el.dataset.logo, el) for (const el of document.querySelectorAll('[data-row]')) rowMasks.set(el.dataset.row, el) const drawnRow = document.getElementById('colinha-drawn') let scrollTargets = [] let prepared = false const prepare = () => { for (const el of nodeById.values()) el.style.display = '' for (const el of document.querySelectorAll('.seen')) el.style.display = 'block' const max = Math.max(0, messages.scrollHeight - messages.clientHeight) scrollTargets = beats.map((beat) => { const el = nodeById.get(beat.id) if (!el) return max const bottom = el.offsetTop + el.offsetHeight + 12 return Math.min(max, Math.max(0, bottom - messages.clientHeight)) }) for (const el of nodeById.values()) el.style.display = '' for (const el of document.querySelectorAll('.seen')) el.style.display = '' fitColinhaName() fitCardNames() prepared = true } /** Long ballot names shrink on the card's one name line instead of wrapping. */ function fitCardNames() { const max = 420 - 22 const ctx = document.createElement('canvas').getContext('2d') if (!ctx) return for (const el of document.querySelectorAll('.card__name')) { el.style.fontSize = '' el.style.whiteSpace = '' const size = Number.parseFloat(getComputedStyle(el).fontSize) ctx.font = getComputedStyle(el).fontWeight + ' ' + size + 'px ReelInter, Inter, system-ui, sans-serif' const width = ctx.measureText(el.textContent ?? '').width if (width > max && width > 0) { el.style.fontSize = Math.max(20, Math.floor((size * max) / width)) + 'px' } el.style.whiteSpace = 'nowrap' } } function fitColinhaName() { const name = document.querySelector('.colinha-copy__name') const office = document.querySelector('.colinha-copy__office') if (!name || !office) return const band = 285.12 const gap = 8.1 const max = band - office.getBoundingClientRect().width - gap const width = name.getBoundingClientRect().width if (width > max && width > 0) { name.style.fontSize = Math.floor((18.36 * max) / width) + 'px' } } const renderTyping = (el, t, enter) => { const dots = el.querySelectorAll('i') dots.forEach((dot, index) => { const phase = ((t - enter) / 420) * Math.PI * 2 - index * 0.7 const lift = Math.max(0, Math.sin(phase)) * 2.6 dot.style.transform = 'translateY(' + (-lift).toFixed(2) + 'px)' dot.style.opacity = (0.55 + 0.45 * Math.max(0, Math.sin(phase))).toFixed(3) }) } window.colinhaReset = () => { prepared = false for (const el of nodeById.values()) el.style.display = '' } window.colinhaRender = (t) => { if (!prepared) prepare() const fadeP = clamp01(t / 180) fade.style.opacity = String(1 - fadeP) let active = -1 for (let i = 0; i < beats.length; i++) { if (t >= beats[i].atMs) active = i else break } for (let i = 0; i < beats.length; i++) { const beat = beats[i] if (beat.kind === 'end') continue const el = nodeById.get(beat.id) if (!el) continue const visible = t >= beat.atMs && (beat.hideAtMs === undefined || t < beat.hideAtMs) if (!visible) { el.style.display = 'none'; continue } el.style.display = '' const p = easeOutCubic(clamp01((t - beat.atMs) / beat.enterMs)) if (beat.kind === 'card') { el.style.opacity = String(p) el.style.transform = 'translateY(' + ((1 - p) * 15).toFixed(2) + 'px) scale(' + (0.965 + 0.035 * p).toFixed(4) + ')' } else if (beat.kind === 'typing') { renderTyping(el, t, beat.atMs) } else { el.style.opacity = String(p) el.style.transform = 'translateY(' + ((1 - p) * 9).toFixed(2) + 'px)' } if (beat.seen) { const seen = el.querySelector('.seen') if (seen) { const sp = easeOutCubic(clamp01((t - beat.atMs - 720) / 260)) seen.style.display = sp > 0 ? 'block' : 'none' seen.style.opacity = String(sp) } } if (beat.kind === 'card') { const bar = el.querySelector('.anotei-bar i') if (bar) { const from = (beat.cardProgressFrom ?? 0) * 100 const to = beat.cardProgress * 100 bar.style.width = (from + (to - from) * easeOutCubic(clamp01((t - beat.atMs) / 620))).toFixed(2) + '%' } } } if (active >= 0) { const from = active > 0 ? scrollTargets[active - 1] : 0 const to = scrollTargets[active] const p = easeInOutCubic(clamp01((t - beats[active].atMs) / 460)) messages.scrollTop = from + (to - from) * p } else { messages.scrollTop = 0 } if (ends.length > 0) { const flip = CFG.flipMs const first = ends[0] const second = ends[1] const out1 = clamp01((t - first.atMs) / flip) if (t < first.atMs) { chat.style.visibility = 'visible' chat.style.transform = 'rotateY(0deg)' } else if (out1 < 1) { chat.style.visibility = 'visible' chat.style.transform = 'rotateY(' + (-90 * easeInOutCubic(out1)).toFixed(3) + 'deg)' } else { chat.style.visibility = 'hidden' } const in1 = clamp01((t - first.atMs - flip) / flip) if (in1 > 0) { screens.colinha.style.visibility = 'visible' const out2 = second ? clamp01((t - second.atMs) / flip) : 0 const rot = second && t >= second.atMs ? -90 * easeInOutCubic(out2) : 90 * (1 - easeInOutCubic(in1)) screens.colinha.style.transform = 'rotateY(' + rot.toFixed(3) + 'deg)' } } for (const reveal of CFG.reveals) { const p = easeOutCubic(clamp01((t - reveal.atMs) / reveal.durationMs)) const figure = figures.get(reveal.candidateId) if (figure) figure.style.opacity = String(1 - p) const logo = logos.get(reveal.candidateId) if (logo) { logo.style.opacity = String(p) logo.style.transform = 'scale(' + (0.82 + 0.18 * p).toFixed(4) + ')' } const mask = rowMasks.get(reveal.candidateId) if (mask) mask.style.opacity = String(1 - p) if (drawnRow && reveal.candidateId === CFG.drawnRow) drawnRow.style.opacity = String(p) } if (CFG.brandAtMs !== undefined) { const brandP = easeInOutCubic(clamp01((t - CFG.brandAtMs) / 900)) if (titlePatch) titlePatch.style.opacity = String(brandP) } } window.colinhaReady = (async () => { await document.fonts.ready await Promise.all([...document.images].map((img) => img.decode().catch(() => undefined))) prepare() window.colinhaRender(0) return true })() })() ` /** * @param {{ beats: Array<{ id: string, kind: string, atMs: number, enterMs: number, text?: string, candidateId?: string, screen?: string, seen?: boolean, hideAtMs?: number, cardProgress?: number }>, candidates: Array<{ id: string, office: string, name: string, number: string, photo: string, figure: Record, logo: Record, row: Record }>, reveals: Array<{ candidateId: string, atMs: number, durationMs: number }>, brandAtMs: number, drawnRow: string, totalMs: number, flipMs: number, fontCss: string, assets: Record, cardFinale?: boolean }} options */ export const colinhaReelHtml = ({ beats, candidates, reveals, brandAtMs, drawnRow, totalMs, flipMs, fontCss, assets, cardFinale = false, }) => { const candidateById = new Map(candidates.map((candidate) => [candidate.id, candidate])) const estadual = candidates[1] const cardCount = candidates.length const rows = beats .filter((beat) => beat.kind !== 'end') .map((beat) => { if (beat.kind === 'typing') { return `
` } if (beat.kind === 'card') { const candidate = candidateById.get(beat.candidateId) if (candidate.generic) { return `
anotei:
Marca oficial de Lula
${escapeHtml(candidate.office)}
${escapeHtml(candidate.name)}
${escapeHtml(COLINHA_REEL.cardState)}
ESCOLHA O SEU
` } const progress = ((candidates.findIndex((c) => c.id === beat.candidateId) + 1) / cardCount) * 100 const digits = [...candidate.number] .map((digit) => `${escapeHtml(digit)}`) .join('') const photoStyle = candidate.photoZoom ? ` style="transform:translateY(${candidate.photoShiftY ?? 0}%) scale(${candidate.photoZoom});transform-origin:${candidate.photoOrigin ?? '50% 0%'}"` : '' return `
anotei:
${escapeHtml(candidate.name)}
${escapeHtml(candidate.office)}
${escapeHtml(COLINHA_REEL.cardParty)}${PARTY_STAR}
${escapeHtml(COLINHA_REEL.cardState)}
NA URNA
${digits}
${escapeHtml(candidate.name)}
` } const seen = beat.seen ? '
Visto
' : '' const variant = beat.kind === 'number' ? 'number' : beat.kind return `
${escapeHtml(beat.text)}
${seen}
` }) .join('\n') const colinhaDigits = [...estadual.number] .map((digit) => `${escapeHtml(digit)}`) .join('') const figuresMarkup = candidates .map((candidate) => { const { cx, cy, rx, ry } = candidate.figure const mask = `radial-gradient(ellipse ${rx}px ${ry}px at ${cx}px ${cy}px, #000 52%, transparent 100%)` return `
` }) .join('\n ') const logosMarkup = candidates .map( (candidate) => ``, ) .join('\n ') const rowMasksMarkup = candidates .map( (candidate) => `
`, ) .join('\n ') let cardIndex = -1 const config = { totalMs, flipMs, reveals: reveals.map((reveal) => ({ ...reveal })), brandAtMs, drawnRow, beats: beats.map((beat) => { const cardProgress = beat.kind === 'card' ? (++cardIndex + 1) / cardCount : undefined return { id: beat.id, kind: beat.kind, atMs: beat.atMs, enterMs: beat.enterMs, ...(beat.candidateId ? { candidateId: beat.candidateId } : {}), ...(beat.screen ? { screen: beat.screen } : {}), ...(beat.seen ? { seen: true } : {}), ...(beat.hideAtMs !== undefined ? { hideAtMs: beat.hideAtMs } : {}), ...(cardProgress !== undefined ? { cardProgress, cardProgressFrom: Math.max(0, cardProgress - 1 / cardCount) } : {}), } }), } return ` ${escapeHtml(COLINHA_REEL.title)}
${BACK_ICON} Jorge Solla 1313 ${escapeHtml(COLINHA_REEL.chatTitle)} ${VIDEO_ICON}${INFO_ICON}
HOJE, ${escapeHtml(COLINHA_REEL.chatHour)}
${rows}
${CAMERA_ICON} Mensagem...${MIC_ICON}${IMAGE_ICON}
${ cardFinale ? `Colinha personalizada gerada pelo site, com o time completo` : `Colinha oficial de votação com o time completo
${figuresMarkup}
${logosMarkup}
Mais Saúde, Mais Futuro
${rowMasksMarkup}
${escapeHtml(estadual.office)} ${escapeHtml(estadual.name)}
${colinhaDigits}
` }
` }