portal_publico/static/js/seasonal-theme.js

541 lines
29 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* Temas sazonais do Portal — trocam a marca P.I.D. (favicon, ícone do login,
* ícone/assinatura da sidebar) por uma variante temática durante uma janela
* de datas. Dois hoje: "Halloween" (vampiro, 16 a 31/10) e "Aniversário"
* (56 anos da De Paula Contadores, só 15/10 — balões + intro de festa) — as
* duas janelas são mutuamente exclusivas por desenho (15 vs 16-31), nunca
* ativas ao mesmo tempo. Fora de qualquer janela, tudo volta ao P.I.D. normal
* (`pid-icon-eye`/`is-blinking`). Ver `docs/temas-sazonais/temas-sazonais.md`.
* Consumido por `sidebar-brand.js`, `login-logo-blink.js` e `auth.js` (troca
* de intro); incluído em toda página (mesmo padrão de `confirm-modal.js`)
* por isso mora aqui, não num arquivo específico de página.
*
* PID_HALLOWEEN_PREVIEW_FORCE/PID_ANIVERSARIO_PREVIEW_FORCE forçam o tema
* ligado o ano todo pra revisão visual; os dois já foram validados e
* desligados pelo usuário — a partir daqui só a checagem de data abaixo
* decide (todo ano). As constantes ficam aqui de propósito, em vez de
* removidas, pra revisar o visual de novo sem ter que mexer no relógio.
*
* O Halloween só passou a começar em 16/10 (antes: outubro inteiro) porque
* 15/10 é o aniversário da empresa e ganhou tema próprio — os dois nunca
* disputavam o mesmo dia antes disso por acaso, agora é por definição.
*/
const PID_HALLOWEEN_PREVIEW_FORCE = false;
const PID_ANIVERSARIO_PREVIEW_FORCE = false;
const PID_HALLOWEEN_MES = 9; // outubro, 0-indexed (Date#getMonth())
const PID_HALLOWEEN_DIA_INICIO = 16; // 16 a 31/10 — dia 15 é do tema de aniversário
const PID_ANIVERSARIO_MES = 9; // outubro, 0-indexed
const PID_ANIVERSARIO_DIA = 15; // aniversário da De Paula Contadores
const PID_HALLOWEEN_SCARE_MS = 900;
const PID_ANIVERSARIO_PARTY_MS = 650;
/* Preferência do usuário (menu da conta → "Temas sazonais") pra desligar
qualquer tema sazonal (hoje só o Halloween, mas vale pra futuros também)
e manter sempre o P.I.D. padrão — motivos: gosto pessoal ou, no caso do
Halloween, aracnofobia. Mesmo padrão de armazenamento do tema claro/escuro
(`theme.js`, só `localStorage`, nunca migrado pro banco — é preferência
de navegador, não dado de negócio). Tem prioridade sobre tudo, inclusive
`PID_HALLOWEEN_PREVIEW_FORCE`: se o usuário desligou, fica desligado. */
const PID_SEASONAL_OPT_OUT_KEY = "pid_seasonal_theme_opt_out";
function pidSeasonalThemesOptedOut() {
try {
return localStorage.getItem(PID_SEASONAL_OPT_OUT_KEY) === "1";
} catch (e) {
return false;
}
}
function pidSetSeasonalThemesOptedOut(optedOut) {
try {
if (optedOut) {
localStorage.setItem(PID_SEASONAL_OPT_OUT_KEY, "1");
} else {
localStorage.removeItem(PID_SEASONAL_OPT_OUT_KEY);
}
} catch (e) {
// localStorage indisponível (modo privado, storage bloqueado etc.) — a
// preferência simplesmente não persiste, mas a página continua funcionando.
}
}
function pidIsHalloweenActive(data) {
if (pidSeasonalThemesOptedOut()) return false;
if (PID_HALLOWEEN_PREVIEW_FORCE) return true;
const hoje = data || new Date();
return hoje.getMonth() === PID_HALLOWEEN_MES && hoje.getDate() >= PID_HALLOWEEN_DIA_INICIO;
}
function pidIsAniversarioActive(data) {
if (pidSeasonalThemesOptedOut()) return false;
if (PID_ANIVERSARIO_PREVIEW_FORCE) return true;
const hoje = data || new Date();
return hoje.getMonth() === PID_ANIVERSARIO_MES && hoje.getDate() === PID_ANIVERSARIO_DIA;
}
/* `iconFile` é o nome do arquivo em `static/img/` a usar como favicon
(`pid-halloween-icone-escuro.svg` ou `pid-aniversario-icone-escuro.svg`) —
sempre a variante "ícone/cores completas" de cada tema, nunca um favicon
dedicado simplificado: o favicon "sem balões"/"sem capa" pensado só pra
tamanhos bem pequenos destoava do resto da marca sazonal na aba do
navegador (mesmo motivo pelo qual o P.I.D. normal também usa
`pid-icone-escuro.svg` como favicon em vez do `pid-favicon.svg` dedicado —
ver "Logos em `static/img/`" no CLAUDE.md). */
function pidApplySeasonalFavicon(iconFile) {
const link = document.querySelector('link[rel="icon"]');
if (link && link.href.indexOf("pid-icone-escuro.svg") !== -1) {
link.href = link.href.replace("pid-icone-escuro.svg", iconFile);
}
}
const PID_VAMP_FACE_FRIENDLY = `
<circle cx="30" cy="58" r="5" fill="#F2EDE3"></circle>
<circle cx="46" cy="58" r="5" fill="#F2EDE3"></circle>
<circle cx="32" cy="56.2" r="1.6" fill="#241C33"></circle>
<circle cx="48" cy="56.2" r="1.6" fill="#241C33"></circle>
<path d="M26 63.2 q4 2.6 8 -0.5" stroke="#7A2733" stroke-width="1.3" fill="none" stroke-linecap="round"></path>
<path d="M42 63.2 q4 2.6 8 -0.5" stroke="#7A2733" stroke-width="1.3" fill="none" stroke-linecap="round"></path>
<path d="M31 67 q7 6 14 0" stroke="#C6A24A" stroke-width="3" fill="none" stroke-linecap="round"></path>
<path d="M33.6 68.6 L36.4 68.9 L35 72 Z" fill="#F2EDE3"></path>
<path d="M40.2 68.9 L43 68.6 L41.6 72 Z" fill="#F2EDE3"></path>`;
const PID_VAMP_FACE_SCARE = `
<circle cx="30" cy="57.4" r="5.6" fill="#F2EDE3"></circle>
<circle cx="46" cy="57.4" r="5.6" fill="#F2EDE3"></circle>
<circle cx="30" cy="57.4" r="2.1" fill="#9E3241"></circle>
<circle cx="46" cy="57.4" r="2.1" fill="#9E3241"></circle>
<path d="M24.6 51.4 L34.6 54.2" stroke="#7A2733" stroke-width="1.9" fill="none" stroke-linecap="round"></path>
<path d="M51.4 51.4 L41.4 54.2" stroke="#7A2733" stroke-width="1.9" fill="none" stroke-linecap="round"></path>
<path d="M29 66.4 q9 7.4 18 0 q-9 3 -18 0 Z" fill="#0C0814"></path>
<path d="M29 66.4 q9 7.4 18 0" stroke="#C6A24A" stroke-width="3" fill="none" stroke-linecap="round"></path>
<path d="M31.4 67.4 L34.6 67.9 L32.9 73 Z" fill="#F2EDE3"></path>
<path d="M41.4 67.9 L44.6 67.4 L43.1 73 Z" fill="#F2EDE3"></path>`;
function pidVampireIconRigMarkup() {
return `
<g class="pid-vamp-cape-group">
<path d="M4 9 L20 19 Q38 24 56 19 L72 9 C74 32 74 56 71 78 L5 78 C2 56 2 32 4 9 Z" fill="#2E2740"></path>
<path class="pid-vamp-collar" d="M9 16 L21 22 Q38 26 55 22 L67 16 C68 26 68 31 66 36 L10 36 C8 31 8 26 9 16 Z" fill="#7A2733"></path>
</g>
<path d="M16 22 h34 l14 14 v40 a2 2 0 0 1 -2 2 H14 a2 2 0 0 1 -2 -2 V24 a2 2 0 0 1 2 -2 z" fill="#7B5BA8"></path>
<rect x="28" y="22" width="20" height="16" fill="#C6A24A"></rect>
<rect x="40" y="25" width="5" height="10" fill="#7B5BA8"></rect>
<rect x="18" y="46" width="40" height="28" rx="2" fill="#241C33"></rect>
<g class="pid-vamp-face pid-vamp-face--friendly">${PID_VAMP_FACE_FRIENDLY}</g>
<g class="pid-vamp-face pid-vamp-face--scare">${PID_VAMP_FACE_SCARE}</g>`;
}
/* Substitui o conteúdo de um <svg> de ícone (viewBox 0 0 64 64, o P.I.D. de
sempre) pelo vampiro (viewBox 0 0 76 80) — usado tanto no ícone da sidebar
colapsada quanto no botão da logo do login. `dataset.vampApplied` evita
reaplicar se a função rodar de novo (não deveria, mas por segurança). */
function pidApplyVampireIcon(svg) {
if (!svg || svg.dataset.vampApplied) return;
svg.setAttribute("viewBox", "0 0 76 80");
svg.innerHTML = `<g class="pid-vamp-icon-rig">${pidVampireIconRigMarkup()}</g>`;
svg.dataset.vampApplied = "1";
}
/* Idem, pra assinatura horizontal (ícone + "P.I.D." + tagline) da sidebar
expandida — viewBox 0 0 320 96 (a versão vampiro é um pouco mais larga que
os 300x80 do P.I.D. normal, por causa da capa). */
function pidApplyVampireFull(svg) {
if (!svg || svg.dataset.vampApplied) return;
svg.setAttribute("viewBox", "0 0 320 96");
svg.innerHTML = `
<g transform="translate(4,8)"><g class="pid-vamp-icon-rig">${pidVampireIconRigMarkup()}</g></g>
<text x="96" y="50" font-family="'Space Grotesk',Helvetica,Arial,sans-serif" font-size="34" font-weight="700" letter-spacing="1.4" fill="#F2EDE3">P.I.D.</text>
<text x="98" y="69" font-family="'DM Mono',ui-monospace,monospace" font-size="9.5" letter-spacing="2.6" fill="#C6A24A">PORTAL INTERNO DA DE PAULA</text>`;
svg.dataset.vampApplied = "1";
}
/* Dispara a animação de "susto" (`.is-scaring`/`pidVampShake` etc. em
base.css) em todo `.pid-vamp-icon-rig` dentro de `root` — substitui a
piscada de olhos (`.pid-icon-eye`/`is-blinking`) enquanto o tema Halloween
está ativo. Remove e reaplica a classe (em vez de só adicionar) pra
permitir cliques repetidos re-tocarem a animação do zero. */
function pidTriggerVampireScare(root) {
root.querySelectorAll(".pid-vamp-icon-rig").forEach((rig) => {
rig.classList.remove("is-scaring");
void rig.getBoundingClientRect();
rig.classList.add("is-scaring");
});
}
/* Tema "Aniversário" (56 anos da De Paula Contadores, só 15/10) — porta da
marca comemorativa autorada em "P.I.D. Logo Design Aniversário/pid-marca-56"
(pasta de logos, fora do repositório; ver leiame.md lá: "o personagem
continua o mesmo, o acento é o número"). Ao contrário do Halloween, a cara
de repouso não muda (mesmos olhos/sorriso do P.I.D. normal, por isso o
markup abaixo preserva os dois `.pid-icon-eye` — a piscada ao clicar
continua funcionando fora do clique de festa) — só ganha três balões
(azul/âmbar/rosa) segurados por um braço saindo da lateral direita do
corpo, mais um bico-de-pato/confete escondidos que só aparecem no clique
de festa (ver `pidTriggerAniversarioParty()` abaixo). Cópias dos SVGs de
referência vivem em `static/img/pid-aniversario-*.svg` (mesmo papel de
`pid-halloween-*.svg`: não usadas diretamente pela UI, que injeta o markup
inline — só o favicon aponta pro arquivo de verdade). "56 ANOS" é
específico deste ano — ano que vem, atualizar o número aqui, no rótulo da
pílula abaixo e nos 3 arquivos `pid-aniversario-*.svg` a partir de uma
pasta `pid-marca-57` nova (mesmo espírito de "marca temporária" do
leiame.md original).
Estrutura em dois `<g>` aninhados: `.pid-aniv-hop` (sem nenhum `transform`
de atributo XML — recebe o "pulo" via CSS/`is-partying`, ver base.css) por
fora, `.pid-aniv-icon-rig` (o `transform="translate(...) scale(...)"`
estático que posiciona o desenho dentro da caixa 64×64/300×80) por dentro
— separar os dois evita o mesmo problema já documentado em `.pid-icon-eye`
(base.css): um `transform` de CSS aplicado ao MESMO elemento que já tem um
`transform` de atributo substitui o atributo inteiro, perdendo a posição. */
function pidAniversarioIconRigMarkup() {
return `
<g class="pid-aniv-balloons">
<path d="M43.5 17 Q47 23.5 53.5 29.5" stroke="#B9AECB" stroke-width="0.99" fill="none" stroke-linecap="round"></path>
<path d="M53 11.5 Q53.5 20 53.5 29.5" stroke="#B9AECB" stroke-width="0.99" fill="none" stroke-linecap="round"></path>
<path d="M59 20 Q57 24.5 53.5 29.5" stroke="#B9AECB" stroke-width="0.99" fill="none" stroke-linecap="round"></path>
<ellipse cx="43.5" cy="11" rx="4.6" ry="5.6" fill="#2E86DE"></ellipse>
<path d="M42.2 16.4 h2.6 l-1.3 1.9 z" fill="#2E86DE"></path>
<ellipse cx="53" cy="5.5" rx="4.6" ry="5.6" fill="#F2A93B"></ellipse>
<path d="M51.7 10.9 h2.6 l-1.3 1.9 z" fill="#F2A93B"></path>
<ellipse cx="59" cy="14" rx="4.6" ry="5.6" fill="#E8446E"></ellipse>
<path d="M57.7 19.4 h2.6 l-1.3 1.9 z" fill="#E8446E"></path>
</g>
<g class="pid-aniv-arm">
<path d="M42 45 Q50.5 43 53.5 32" stroke="#7B5BA8" stroke-width="3.5" fill="none" stroke-linecap="round"></path>
<circle cx="53.5" cy="30" r="3.4" fill="#7B5BA8"></circle>
</g>
<g class="pid-aniv-confetti">
<rect x="47" y="12" width="2.6" height="2.6" fill="#C6A24A"></rect>
<rect x="60" y="8" width="2.2" height="2.2" fill="#2E86DE"></rect>
<circle cx="52" cy="4" r="1.4" fill="#E8446E"></circle>
<rect x="63" y="19" width="2" height="2" fill="#F2A93B"></rect>
<circle cx="41" cy="7" r="1.2" fill="#F2EDE3"></circle>
<rect x="55" y="24" width="2.2" height="2.2" fill="#E8446E"></rect>
<circle cx="65" cy="13" r="1.3" fill="#C6A24A"></circle>
<rect x="37" y="13" width="1.8" height="1.8" fill="#2E86DE"></rect>
</g>
<g transform="translate(2,18) scale(0.72)">
<path d="M10 4 h34 l14 14 v40 a2 2 0 0 1 -2 2 H8 a2 2 0 0 1 -2 -2 V6 a2 2 0 0 1 2 -2 z" fill="#7B5BA8"></path>
<rect x="22" y="4" width="20" height="16" fill="#C6A24A"></rect>
<rect x="34" y="7" width="5" height="10" fill="#7B5BA8"></rect>
<rect x="12" y="26" width="40" height="30" rx="2" fill="#241C33"></rect>
<g class="pid-icon-eye">
<circle cx="24" cy="38" r="5" fill="#F2EDE3"></circle>
<circle cx="26" cy="36.2" r="1.6" fill="#241C33"></circle>
</g>
<g class="pid-icon-eye">
<circle cx="40" cy="38" r="5" fill="#F2EDE3"></circle>
<circle cx="42" cy="36.2" r="1.6" fill="#241C33"></circle>
</g>
<path class="pid-aniv-smile" d="M25 47 q7 6 14 0" stroke="#C6A24A" stroke-width="3" fill="none" stroke-linecap="round"></path>
<ellipse class="pid-aniv-pucker-outer" cx="32" cy="48.5" rx="4" ry="3.4" fill="#C6A24A"></ellipse>
<ellipse class="pid-aniv-pucker-inner" cx="32" cy="48.5" rx="2" ry="1.7" fill="#241C33"></ellipse>
</g>
<g class="pid-aniv-horn">
<path class="pid-aniv-horn-tube" d="M25 53 q12.6 -5 30 -11" stroke="#E8446E" stroke-width="2.4" fill="none" stroke-linecap="round"></path>
<path class="pid-aniv-horn-dash" d="M25 53 q12.6 -5 30 -11" stroke="#F2EDE3" stroke-width="1" stroke-dasharray="1.6 2.4" fill="none" stroke-linecap="round"></path>
<circle class="pid-aniv-horn-puff" cx="58" cy="41" r="2.2" fill="#F2A93B"></circle>
<circle class="pid-aniv-horn-puff" cx="63" cy="38" r="1.4" fill="#C6A24A"></circle>
</g>`;
}
/* Substitui o conteúdo de um <svg> de ícone (viewBox 0 0 64 64, mesmo do
P.I.D. normal — ao contrário do vampiro, não precisa alargar a caixa) pelo
ícone com balões. `dataset.aniversarioApplied` evita reaplicar (mesmo
padrão de `dataset.vampApplied`). */
function pidApplyAniversarioIcon(svg) {
if (!svg || svg.dataset.aniversarioApplied) return;
svg.innerHTML = `<g class="pid-aniv-hop"><g class="pid-aniv-icon-rig" transform="translate(0.1,4.1) scale(0.913)">${pidAniversarioIconRigMarkup()}</g></g>`;
svg.dataset.aniversarioApplied = "1";
}
/* Idem, pra assinatura horizontal da sidebar expandida — viewBox 0 0 300 80,
igual ao P.I.D. normal (os balões cabem na mesma caixa, sem alargar como
o vampiro precisou por causa da capa). Ganha uma pílula dourada "56 ANOS"
ao lado do wordmark, no lugar da tagline normal. */
function pidApplyAniversarioFull(svg) {
if (!svg || svg.dataset.aniversarioApplied) return;
svg.innerHTML = `
<g transform="translate(8,8)"><g class="pid-aniv-hop"><g class="pid-aniv-icon-rig" transform="translate(0.1,4.1) scale(0.913)">${pidAniversarioIconRigMarkup()}</g></g></g>
<text x="86" y="42" font-family="'Space Grotesk',Helvetica,Arial,sans-serif" font-size="34" font-weight="700" letter-spacing="1.4" fill="#F2EDE3">P.I.D.</text>
<rect x="192" y="21" width="86" height="23" rx="11.5" fill="#C6A24A"></rect>
<text x="235" y="36.5" text-anchor="middle" font-family="'DM Mono',ui-monospace,monospace" font-size="10.5" font-weight="500" letter-spacing="1.8" fill="#4B2E75">56 ANOS</text>
<text x="88" y="61" font-family="'DM Mono',ui-monospace,monospace" font-size="9.5" letter-spacing="2.15" fill="#C6A24A">PORTAL INTERNO DA DE PAULA</text>`;
svg.dataset.aniversarioApplied = "1";
}
/* Dispara o "clique de festa" (`.is-partying`, keyframes em base.css) em
todo `.pid-aniv-hop` dentro de `root` — o personagem pula, o braço joga
confete pro alto e a língua-de-sogra desenrola da boca, num ciclo só de
`PID_ANIVERSARIO_PARTY_MS`. Ao contrário do susto do vampiro, isso não
substitui a piscada de olhos — os dois convivem (a piscada continua
disparando `.pid-icon-eye`/`is-blinking` de dentro do próprio clique de
festa, ver `sidebar-brand.js`/`login-logo-blink.js`). Mesmo truque de
remover+reaplicar a classe (em vez de só adicionar) de
`pidTriggerVampireScare` pra permitir cliques repetidos re-tocarem do
zero. */
function pidTriggerAniversarioParty(root) {
root.querySelectorAll(".pid-aniv-hop").forEach((hop) => {
hop.classList.remove("is-partying");
void hop.getBoundingClientRect();
hop.classList.add("is-partying");
});
}
/* Teia de aranha decorativa de canto (`static/css/halloween-cobweb.css`) —
markup só existe em memória (nunca hardcoded no template) pra não sobrar
nada no HTML fora da janela do Halloween. `.pw-spider`/`.pw-legs` (mesmas
classes usadas pela animação em CSS) andam/mexem sozinhas via
`@keyframes` contínuo, sem precisar de mais JS depois de injetadas. */
const PID_COBWEB_SVG = `<svg viewBox="0 0 200 200" role="img" aria-hidden="true">
<g class="pw-strands">
<line x1="0" y1="0" x2="200.0" y2="10.5"></line>
<line x1="0" y1="0" x2="200.0" y2="53.6"></line>
<line x1="0" y1="0" x2="200.0" y2="106.3"></line>
<line x1="0" y1="0" x2="200.0" y2="180.1"></line>
<line x1="0" y1="0" x2="134.9" y2="200.0"></line>
<line x1="0" y1="0" x2="76.8" y2="200.0"></line>
<line x1="0" y1="0" x2="35.3" y2="200.0"></line>
<line x1="0" y1="0" x2="7.0" y2="200.0"></line>
<line class="pw-strand-stray" x1="0" y1="0" x2="296.3" y2="46.9"></line>
<path class="pw-ring" d="M 30.0,1.6 Q 32.4,5.2 30.0,8.0 Q 32.4,13.0 30.0,16.0 Q 32.4,23.2 30.0,27.0 Q 27.1,30.8 20.2,30.0 Q 17.1,32.4 11.5,30.0 Q 9.1,32.4 5.3,30.0 Q 3.4,32.4 1.0,30.0"></path>
<path class="pw-ring" d="M 58.0,3.0 Q 62.6,10.0 58.0,15.5 Q 62.6,25.0 58.0,30.8 Q 62.6,44.9 58.0,52.2 Q 52.4,59.5 39.1,58.0 Q 33.1,62.6 22.3,58.0 Q 17.5,62.6 10.2,58.0 Q 6.6,62.6 2.0,58.0"></path>
<path class="pw-ring" d="M 90.0,4.7 Q 97.2,15.6 90.0,24.1 Q 97.2,38.9 90.0,47.9 Q 97.2,69.6 90.0,81.0 Q 81.4,92.4 60.7,90.0 Q 51.4,97.2 34.5,90.0 Q 27.2,97.2 15.9,90.0 Q 10.3,97.2 3.1,90.0"></path>
<path class="pw-ring" d="M 126.0,6.6 Q 136.1,21.8 126.0,33.8 Q 136.1,54.4 126.0,67.0 Q 136.1,97.4 126.0,113.5 Q 113.9,129.3 85.0,126.0 Q 72.0,136.1 48.4,126.0 Q 38.1,136.1 22.2,126.0 Q 14.4,136.1 4.4,126.0"></path>
<path class="pw-ring" d="M 166.0,8.7 Q 179.3,28.7 166.0,44.5 Q 179.3,71.7 166.0,88.3 Q 179.3,128.4 166.0,149.5 Q 150.1,170.4 112.0,166.0 Q 94.9,179.3 63.7,166.0 Q 50.2,179.3 29.3,166.0 Q 18.9,179.3 5.8,166.0"></path>
</g>
<g class="pw-spider">
<g class="pw-legs">
<path d="M-3,-2 L-14,-8 L-22,-6"></path>
<path d="M-2,-1 L-13,2 L-21,5"></path>
<path d="M-2,1 L-13,7 L-20,13"></path>
<path d="M-3,2 L-12,11 L-18,18"></path>
<path d="M3,-2 L14,-8 L22,-6"></path>
<path d="M2,-1 L13,2 L21,5"></path>
<path d="M2,1 L13,7 L20,13"></path>
<path d="M3,2 L12,11 L18,18"></path>
</g>
<ellipse class="pw-abdomen" cx="0" cy="6" rx="7" ry="9"></ellipse>
<circle class="pw-head" cx="0" cy="-6" r="4.5"></circle>
<circle class="pw-eye" cx="-1.6" cy="-7" r="0.9"></circle>
<circle class="pw-eye" cx="1.6" cy="-7" r="0.9"></circle>
</g>
<g class="pw-skull">
<circle cx="0" cy="0" r="6.5"></circle>
<ellipse class="pw-skull-socket" cx="-2.4" cy="-0.6" rx="1.5" ry="1.9"></ellipse>
<ellipse class="pw-skull-socket" cx="2.4" cy="-0.6" rx="1.5" ry="1.9"></ellipse>
<path d="M-1.2 3.4 L-0.4 2.2 L0.4 3.4 L1.2 2.2"></path>
</g>
</svg>`;
/* Quais aranhas já "morreram" (clicadas numa visita anterior) — persistido
pra sobreviver a um F5/nova navegação, pedido explícito do usuário
("guardar que a aranha foi morta... até executar o comando tab+b que
reseta a aranha"). Cada aranha tem um id próprio (`${containerId}__tl`/
`__br` — os dois cantos de cada uma das 2 páginas, 4 ids possíveis no
total) guardado como array JSON num único `localStorage`, mesmo padrão
de armazenamento das outras preferências sazonais (`pid_theme`,
`pid_seasonal_theme_opt_out`) — nunca migrado pro banco. */
const PID_DEAD_SPIDERS_KEY = "pid_seasonal_dead_spiders";
function pidGetDeadSpiders() {
try {
const raw = JSON.parse(localStorage.getItem(PID_DEAD_SPIDERS_KEY) || "[]");
return Array.isArray(raw) ? raw : [];
} catch (e) {
return [];
}
}
function pidMarkSpiderDead(spiderId) {
try {
const dead = pidGetDeadSpiders();
if (!dead.includes(spiderId)) {
dead.push(spiderId);
localStorage.setItem(PID_DEAD_SPIDERS_KEY, JSON.stringify(dead));
}
} catch (e) {
// localStorage indisponível — a aranha "morre" só nesta visita, sem persistir
}
}
function pidResetDeadSpiders() {
try {
localStorage.removeItem(PID_DEAD_SPIDERS_KEY);
} catch (e) {
// ignorado
}
}
/* Preenche o container (já presente e `hidden` no template, `#pw-cobwebs-login`
em index.html ou `#pw-cobwebs-portal` em portal.html) com os 2 cantos
(superior-esquerdo/inferior-direito, pedido explícito do usuário) e revela.
Se a aranha daquele canto já morreu numa visita anterior
(`pidGetDeadSpiders()`), nasce direto com `.is-dead` (sem tocar a
animação de novo — só um "estado final" instantâneo). Senão, liga o
clique (`.pw-spider`, `pointer-events:auto` só nela — o resto da teia
continua `pointer-events:none`): "morre" (cai girando e some, pernas
encolhem — `.is-dying`/`pwSpiderDie` em halloween-cobweb.css) e uma
caveirinha sobe da teia (`.pw-skull`, `.is-rising`/`pwSkullRise`),
deixando a teia vazia — e grava o óbito (`pidMarkSpiderDead()`), que só
é desfeito pelo atalho de teclado Tab+B (ver `pidBindSeasonalResetShortcut()`
abaixo). É um no-op seguro em qualquer outra página (o id não existe ali). */
function pidApplyHalloweenCobwebs(containerId) {
const el = document.getElementById(containerId);
if (!el || el.dataset.cobwebApplied) return;
el.innerHTML =
`<div class="pw-corner pw-corner--tl">${PID_COBWEB_SVG}</div>` +
`<div class="pw-corner pw-corner--br">${PID_COBWEB_SVG}</div>`;
el.hidden = false;
el.dataset.cobwebApplied = "1";
const deadSpiders = pidGetDeadSpiders();
el.querySelectorAll(".pw-corner").forEach((cornerEl) => {
const corner = cornerEl.classList.contains("pw-corner--tl") ? "tl" : "br";
const spiderId = `${containerId}__${corner}`;
const spider = cornerEl.querySelector(".pw-spider");
if (!spider) return;
if (deadSpiders.includes(spiderId)) {
spider.classList.add("is-dead");
return;
}
spider.addEventListener(
"click",
() => {
const skull = spider.parentElement.querySelector(".pw-skull");
spider.classList.add("is-dying");
if (skull) skull.classList.add("is-rising");
pidMarkSpiderDead(spiderId);
},
{ once: true }
);
});
}
/* Bolo de aniversário decorativo, tema sazonal Aniversário — só na tela
Principal (`portal.html`), fixo no canto inferior-direito da JANELA
(`position:fixed`, não da página — sempre visível, mesmo com scroll).
Clicar apaga as velinhas (`.is-blowing`/`pidCakeFlameOut` +
`pidCakeSmokeRise` em `aniversario-decor.css`, 1x só), e elas ficam
apagadas até o próximo carregamento da página.
**O estado apagado NÃO é persistido**, ao contrário da aranha morta do
Halloween — e isso é deliberado, não esquecimento. A persistência chegou a
existir aqui (copiada de `pidMarkSpiderDead`) e foi removida: as velas
nasciam apagadas pro resto da vida depois do primeiro clique, sem nenhuma
pista disso na tela, e só voltavam com o atalho Tab+B. O usuário relatou
como bug ("não aparece o fogo das velinhas"), porque é um easter egg que
some sozinho e não volta. A aranha persiste porque o usuário PEDIU
explicitamente; aqui ele pediu só "ao clicar nele as velas se apagam". */
/* As chamas nascem exatamente no topo de cada vela (`y` do `<rect>`), não
alguns pontos acima — na primeira versão flutuavam soltas bem longe do
pavio. O gotejamento de cada camada é da cor da camada DE CIMA (creme
escorrendo sobre o dourado, dourado escorrendo sobre o roxo), senão fica
invisível contra a própria camada.
`.pid-cake-halo` é o halo de luz no ar em volta das velas (o brilho colado
em cada chama vem de um `drop-shadow` no CSS) — fica DEPOIS das camadas do
bolo e ANTES das velas, pra a luz lavar o topo do bolo sem desbotar as
chamas, que continuam nítidas por cima. O `<radialGradient>` mora num
`<defs>` com id fixo: o bolo é injetado uma vez só, em portal.html, então
não há risco de id duplicado (mesmo padrão do `clipPath` da intro do
Halloween em index.html). */
const PID_CAKE_SVG = `<svg viewBox="0 0 120 110" role="img" aria-hidden="true">
<defs>
<radialGradient id="pidCakeHaloGrad">
<stop offset="0%" stop-color="#F2A93B" stop-opacity="0.42"></stop>
<stop offset="45%" stop-color="#F2A93B" stop-opacity="0.14"></stop>
<stop offset="100%" stop-color="#F2A93B" stop-opacity="0"></stop>
</radialGradient>
</defs>
<ellipse class="pid-cake-shadow" cx="60" cy="102" rx="42" ry="6"></ellipse>
<path class="pid-cake-tier2" d="M18 74 h84 v24 a2 2 0 0 1 -2 2 H20 a2 2 0 0 1 -2 -2 z"></path>
<path class="pid-cake-drip2" d="M18 74 q7 9 14 0 q7 9 14 0 q7 9 14 0 q7 9 14 0 q7 9 14 0 q7 9 14 0 Z"></path>
<circle class="pid-cake-dot pid-cake-dot--blue" cx="32" cy="88" r="2.4"></circle>
<circle class="pid-cake-dot pid-cake-dot--pink" cx="60" cy="91" r="2.4"></circle>
<circle class="pid-cake-dot pid-cake-dot--amber" cx="88" cy="88" r="2.4"></circle>
<path class="pid-cake-tier1" d="M32 50 h56 v24 H32 z"></path>
<path class="pid-cake-drip1" d="M32 50 q5.6 7 11.2 0 q5.6 7 11.2 0 q5.6 7 11.2 0 q5.6 7 11.2 0 q5.6 7 11.2 0 Z"></path>
<ellipse class="pid-cake-halo" cx="60" cy="30" rx="48" ry="30" fill="url(#pidCakeHaloGrad)"></ellipse>
<g class="pid-cake-candle">
<rect x="42" y="34" width="4" height="16" fill="#2E86DE"></rect>
<path class="pid-cake-smoke" d="M44 32 q4 -5 0 -10"></path>
<path class="pid-cake-flame" d="M44 34 q4 -5 0 -9 q-4 4 0 9 z"></path>
</g>
<g class="pid-cake-candle">
<rect x="58" y="30" width="4" height="20" fill="#C6A24A"></rect>
<path class="pid-cake-smoke" d="M60 28 q4 -5 0 -10"></path>
<path class="pid-cake-flame" d="M60 30 q4 -5 0 -9 q-4 4 0 9 z"></path>
</g>
<g class="pid-cake-candle">
<rect x="74" y="34" width="4" height="16" fill="#E8446E"></rect>
<path class="pid-cake-smoke" d="M76 32 q4 -5 0 -10"></path>
<path class="pid-cake-flame" d="M76 34 q4 -5 0 -9 q-4 4 0 9 z"></path>
</g>
</svg>`;
/* Preenche o container (já presente e `hidden` no template,
`#pid-aniv-cake` em portal.html) e liga o clique — botão porque é
interativo (`aria-label`, foco por teclado funcionam de graça). O clique
adiciona `.is-blowing` (toca `pidCakeFlameOut`/`pidCakeSmokeRise` uma vez)
e, depois de `PID_CAKE_BLOW_MS`, troca pra `.is-blown` (estado final em
repouso, sem `animation` nenhuma rodando) — que dura só até o próximo
carregamento, ver o comentário acima. É um no-op seguro em qualquer outra
página (o id não existe ali). */
const PID_CAKE_BLOW_MS = 1400;
function pidApplyAniversarioCake(containerId) {
const el = document.getElementById(containerId);
if (!el || el.dataset.cakeApplied) return;
el.innerHTML = `<button type="button" class="pid-aniv-cake__btn" aria-label="Assoprar as velinhas">${PID_CAKE_SVG}</button>`;
el.hidden = false;
el.dataset.cakeApplied = "1";
const btn = el.querySelector(".pid-aniv-cake__btn");
if (!btn) return;
btn.addEventListener(
"click",
() => {
el.classList.add("is-blowing");
setTimeout(() => {
el.classList.remove("is-blowing");
el.classList.add("is-blown");
}, PID_CAKE_BLOW_MS);
},
{ once: true }
);
}
/* Atalho de teclado Tab+B (segurar Tab e apertar B) — reseta as aranhas
"mortas" (`pidResetDeadSpiders()`) salvas e recarrega a página, mesmo
raciocínio do toggle de opt-out (`account.js`): o estado já foi aplicado
no carregamento atual, não dá pra desfazer in-place. Não chama
`preventDefault()` no Tab (continuaria navegando o foco normalmente,
acessibilidade intacta) — só rastreia se ele está pressionado no momento
em que o B chega. Incluído em toda página (mesmo padrão de
`confirm-modal.js`), mas só tem efeito onde há aranha salva como morta. O
bolo do tema de Aniversário não entra aqui: as velinhas reacendem sozinhas
a cada carregamento, então não há o que resetar. */
document.addEventListener("DOMContentLoaded", () => {
let tabHeld = false;
document.addEventListener("keydown", (event) => {
if (event.key === "Tab") {
tabHeld = true;
return;
}
if (tabHeld && (event.key === "b" || event.key === "B") && pidGetDeadSpiders().length) {
pidResetDeadSpiders();
window.location.reload();
}
});
document.addEventListener("keyup", (event) => {
if (event.key === "Tab") tabHeld = false;
});
});