Aplicação web em Next.js para criar salas temporárias e jogar Contrapista, um jogo online competitivo de investigação dedutiva. A experiência gira em torno de uma sala privada, um dossiê gerado por IA, pistas verdadeiras e falsas distribuídas entre jogadores, rodadas cronometradas e um palpite final avaliado contra a solução oficial. Vence quem acertar a solução do caso primeiro.
O projeto permite:
- criar uma sala privada com código aleatório de 4 números;
- entrar em uma sala por código, com conta pública opcional;
- criar conta e fazer login por email/senha, Google ou GitHub em modal público;
- escolher nickname e cor exclusiva por jogador;
- manter a sessão do jogador no
localStorage; - ajustar parâmetros da partida na ante-sala;
- escolher um caso existente em salas personalizadas;
- marcar participantes como prontos antes da geração do caso;
- gerar um caso automaticamente com IA via OpenRouter;
- salvar o caso na tabela
cases; - vincular o caso ativo na sala por
game_rooms.activecase; - parear jogadores em filas casual e rankeada para mesas clássicas de 4 jogadores;
- oferecer um problema diário sorteado do banco com tentativa individual por usuário;
- conduzir uma partida por fases sincronizadas em
game_rooms.gamestate; - distribuir pistas por jogador a partir dos arrays de pistas verdadeiras e falsas;
- sortear a ordem dos jogadores por roleta;
- compartilhar pistas por turno, com compartilhamento automático se o tempo expirar;
- pular fases coletivas por consenso;
- abrir um palpite final cronometrado, avaliado por IA;
- eliminar quem erra o palpite e liberar o arquivo completo para esse jogador;
- encerrar o caso quando houver resposta correta, declarar o primeiro jogador que acertou como vencedor e retornar todos à ante-sala.
- Next.js 16 App Router
- React 19
- Tailwind CSS 4
- Auth.js / NextAuth 5
- PostgreSQL Neon via
pg - OpenRouter via chamadas compatíveis com Chat Completions
npm install
npm run dev
npm run lint
npm run build
npm run startServidor local padrão:
http://localhost:3000
Crie ou mantenha um arquivo .env com:
DATABASE=postgresql://...
AUTH_SECRET=uma-string-segura-com-32-bytes-ou-mais
BACKEND_TRUSTED_HOSTS=localhost:3000,seudominio.com
AUTH_URL=http://localhost:3000
AUTH_REDIRECT_PROXY_URL=http://localhost:3000/api/auth
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
GOOGLE_GMAIL_SENDER_EMAIL=conta-remetente@gmail.com
GOOGLE_GMAIL_SENDER_NAME=Contrapista
GOOGLE_GMAIL_REDIRECT_URI=http://localhost:3000/api/auth/google-gmail/callback
GOOGLE_GMAIL_REFRESH_TOKEN=...
NEXT_PUBLIC_APP_URL=http://localhost:3000
AUTH_GITHUB_ID=...
AUTH_GITHUB_SECRET=...
LLM_PROVIDER=openrouter
LLM_OPENROUTER_API_KEY=key_1,key_2,key_3
LLM_OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
LLM_MODELS=openai/gpt-4o-mini,google/gemini-2.0-flash-001,meta-llama/llama-3.1-70b-instruct
LLM_DEBUG=falsePara produção, ajuste:
GOOGLE_GMAIL_REDIRECT_URI=https://seudominio.com/api/auth/google-gmail/callback
NEXT_PUBLIC_APP_URL=https://seudominio.com
AUTH_URL=https://seudominio.com
AUTH_REDIRECT_PROXY_URL=https://seudominio.com/api/authNEXT_PUBLIC_APP_URL também define URLs canônicas, sitemap e metadados sociais. AUTH_URL define a origem pública usada pelo Auth.js. AUTH_REDIRECT_PROXY_URL força o OAuth a montar callbacks sempre a partir de /api/auth. Em produção, use sempre o domínio público final nesses valores.
Callbacks para cadastrar no Google Cloud:
Auth.js Google login: https://seudominio.com/api/auth/callback/google
Gmail remetente do sistema: https://seudominio.com/api/auth/google-gmail/callback
O login com Google deve usar um OAuth Client do tipo Web application no Google Cloud. Clientes do tipo aplicativo/desktop/mobile usam outro modelo de callback e podem gerar redirect_uri_mismatch neste fluxo web. No GitHub, cadastre a callback da OAuth App como https://seudominio.com/api/auth/callback/github.
Para gerar GOOGLE_GMAIL_REFRESH_TOKEN, use o email remetente em GOOGLE_GMAIL_SENDER_EMAIL, configure a callback do Gmail no Google Cloud e rode:
node --env-file=.env scripts/generate-google-gmail-refresh-token.mjsAbra a URL exibida, aceite o escopo gmail.send, copie o code retornado pela callback e rode:
node --env-file=.env scripts/generate-google-gmail-refresh-token.mjs --code=COLE_O_CODE_AQUIO backend renova o access token do Gmail no início do processo e periodicamente enquanto o servidor estiver ativo. Se o refresh_token for revogado, removido pelo Google ou perder consentimento, é necessário gerar um novo token com o fluxo acima.
Variáveis opcionais para roteamento no OpenRouter:
LLM_OPENROUTER_APP_NAME=Contrapista
LLM_OPENROUTER_APP_URL=https://...
LLM_OPENROUTER_ALLOW_FALLBACKS=true
LLM_OPENROUTER_DATA_COLLECTION=deny
LLM_OPENROUTER_ZDR=true
LLM_OPENROUTER_REQUIRE_PARAMETERS=true
LLM_OPENROUTER_ONLY=openai,google
LLM_OPENROUTER_IGNORE=...O projeto usa os modelos na ordem de LLM_MODELS, separados por vírgula. Cada chamada à IA usa somente o primeiro modelo disponível da lista; os demais ficam ignorados até serem necessários. Se esse modelo falhar por erro de API, ele entra em espera por 24 horas no processo atual. Se retornar uma resposta inválida pela validação local, entra em espera por 5 minutos. A próxima chamada passa para o próximo modelo disponível da lista.
LLM_OPENROUTER_API_KEY também aceita uma lista separada por vírgula. A fila percorre primeiro todos os modelos elegíveis com a primeira chave; quando todos os modelos dessa chave estiverem em espera, passa para a segunda chave e recomeça a ordem de modelos. Falhas específicas de modelo entram em espera por combinação chave + modelo. Falhas de escopo da chave, como 401, 403 ou 429 de cota/rate limit do OpenRouter, colocam todos os modelos daquela chave em espera e avançam diretamente para a próxima chave. Os valores reais das chaves não são registrados nem expostos nos logs.
Cada resposta bem-sucedida do OpenRouter é registrada em public.openrouter_request_usage, com o slot da chave (1–7), modelo e tokens de uso. A tabela não guarda a API key e alimenta o painel administrativo no projeto separado adm-contrapista. Para criar a tabela manualmente antes do primeiro uso, rode psql "$DATABASE" -v ON_ERROR_STOP=1 -f scripts/migrate-openrouter-usage.sql.
Na geração de caso, o backend continua tentando os modelos fora de espera antes de retornar erro. Requisições com JSON estruturado usam provider.require_parameters para evitar provedores incompatíveis com os parâmetros enviados. Quando um modelo rejeita response_format com HTTP 400 ou 404 de incompatibilidade de parâmetros, a mesma chamada é repetida uma vez sem response_format, mantendo a validação local do JSON. Erros HTTP 400/404 de parâmetros e respostas inválidas entram em espera curta de 5 minutos. Se nenhum modelo conseguir gerar um caso válido, a sala é resetada para a ante-sala e a UI informa que os modelos de IA estão indisponíveis.
Técnicas de economia e consistência aplicadas:
session_idestável por fluxo de IA para favorecer sticky routing e prompt caching no OpenRouter.- Fila em memória por
session_id: requisições da mesma sala/fluxo executam uma de cada vez, evitando rajadas simultâneas para a mesma sala. - Prompt de caso separado entre instruções fixas cacheáveis e configuração variável curta.
- Filtro por Models API do OpenRouter para ignorar modelos não generativos, como embed, rerank, safety, moderation e guard.
- Uso de
response_formatapenas quando o modelo anuncia suporte; caso contrário, o backend usa prompt JSON e validação local. - Logs de lifecycle de IA para acompanhar seleção, envio, retry, sucesso, falha e ação de standoff por
requestId,apiKeySlot,modelSlote modelo, sem expor valores de chaves. - Registro de prompt, resposta bruta,
usagee métricas de cache quandoLLM_DEBUG=true. - Mensagem específica quando o limite diário gratuito do OpenRouter é atingido.
- Na home, o jogador cria uma sala ou informa um código de 4 números.
- Ao criar uma sala, o navegador gera/reutiliza um
browserIdem UUID e envia para a API. A API gravagame_rooms, cria a configuração padrão emgame_rooms_config, adiciona o primeiro usuário como participante pendente sem nickname/cor e redireciona para/sala/[code]. - Ao informar um código, o mesmo
browserIdé enviado para/join. Se esse navegador já estiver na sala, o backend reutiliza o participante existente; caso contrário, só adiciona um novo participante pendente se a sala ainda estiver na ante-sala. Salas criando caso ou com jogo ativo recusam novos participantes. - Na ante-sala, o participante pendente escolhe nickname e cor exclusiva. O botão de pronto só aparece depois dessa identificação, e o backend recusa prontidão sem nome/cor.
- O primeiro participante da sala atua como líder e pode alterar os parâmetros do dossiê antes de existir um caso ativo.
- Alterar identificação ou configuração coloca jogadores como não prontos.
- Clientes na ante-sala, na criação de caso e no jogo enviam heartbeat; se um participante fica 2 minutos sem contato, ele é considerado desconectado.
- Na ante-sala, desconectados saem da sala. No jogo, desconectados são eliminados como se tivessem errado um palpite final: saem da ordem, não votam e suas pistas ficam expostas aos demais.
- Leituras de sala usam snapshot sem efeitos colaterais; avanço automático de fase e limpeza de desconectados rodam em chamadas mutáveis autenticadas.
- Quando todos ficam prontos, os clientes redirecionam para
/sala/[code]/criando-caso. - A tela de criação chama
POST /api/rooms/[code]/case/start. - O backend valida a sala, gera um caso com IA, salva em
casese grava o id emgame_rooms.activecase. - Todos são enviados para
/sala/[code]/jogo. - No jogo, todos confirmam prontidão novamente para iniciar a leitura.
- A fase de leitura mostra o dossiê principal e os fragmentos privados de cada jogador.
- A roleta define a ordem da rodada.
- Em cada turno, o jogador da vez compartilha um fragmento. Se o tempo acaba, uma pista aleatória desse jogador é compartilhada automaticamente.
- Após cada pista, todos têm uma janela de análise coletiva.
- Ao final da ordem, há uma pausa entre rodadas e o ciclo continua.
- Em fases coletivas, todos podem votar para pular; a fase só avança quando todos votam.
- Um jogador pode abrir a conclusão final, pausando o jogo para registrar um palpite cronometrado.
- O backend compara o palpite com a solução oficial usando IA e publica o resultado.
- Se o palpite estiver errado, o jogador é eliminado da disputa, o jogo segue, esse jogador passa a ver todas as pistas classificadas e recebe uma linha provisória no histórico com o próprio palpite.
- Se o palpite estiver certo, o histórico da partida é gravado para todos os participantes com conta, todos veem a solução oficial e podem voltar à ante-sala.
- Se todos os jogadores ativos forem eliminados ou desconectados antes de alguém acertar, a partida termina sem vencedor e também gera histórico.
- O caso ativo só é limpo quando todos retornam à ante-sala; os jogadores ficam como não prontos para uma nova sessão.
- Se todos os participantes saírem ou forem removidos por desconexão na ante-sala, a sala é excluída de
game_rooms.
O projeto usa o App Router do Next.js. As telas de experiência são Client Components porque dependem de localStorage, timers, polling e navegação client-side. As regras de domínio ficam em lib/rooms.ts e lib/cases.ts, chamadas por Route Handlers em app/api.
app/page.tsx Home e manual de jogo
app/sala/[code]/page.tsx Ante-sala, perfil, participantes e configuração
app/sala/[code]/criando-caso/page.tsx Tela de progresso da geração do caso
app/sala/[code]/jogo/page.tsx Partida, fases, pistas e palpite final
app/not-found.tsx Página 404
POST /api/rooms Cria sala
GET /api/rooms/[code] Lê sala e avança fases expiradas
POST /api/rooms/[code]/join Entra na sala
POST /api/rooms/[code]/heartbeat Atualiza presença do participante
POST /api/rooms/[code]/leave Sai da sala
PATCH /api/rooms/[code]/users/[userId] Atualiza nickname e cor
POST /api/rooms/[code]/ready Marca pronto na ante-sala
PATCH /api/rooms/[code]/config Atualiza configuração da sala
GET /api/rooms/[code]/case/start Lê estimativa da última geração
POST /api/rooms/[code]/case/start Gera ou reutiliza caso ativo
POST /api/rooms/[code]/game/ready Confirma prontidão dentro do jogo
POST /api/rooms/[code]/game/skip Vota para pular fase coletiva
POST /api/rooms/[code]/clues Compartilha pista do turno
POST /api/rooms/[code]/events Publica palpite final e resultado
POST /api/rooms/[code]/case/return Marca retorno à ante-sala
GET /api/cases/[caseId] Lê caso salvo
lib/rooms.ts Estado e regras de sala, jogo, eventos e config
lib/cases.ts Prompt, validação, geração e persistência de casos
lib/match-history.ts Schema, gravação e leitura do histórico de partidas
lib/db.ts Pool PostgreSQL
lib/auth-users.ts Schema, cadastro e autenticação de usuários
lib/player-colors.ts Cores permitidas
lib/ai Configuração, fallback e cliente de IA
auth.ts Configuração Auth.js com credentials, Google e GitHub
O projeto cria e ajusta o schema em runtime por meio de ensureSchema em lib/rooms.ts e ensureCaseSchema em lib/cases.ts. O script scripts/migrate-game-rooms.sql cobre uma migração legada de nomes de coluna, mas o runtime atual também garante as tabelas principais.
Usada para salas e estado atual da partida.
Campos relevantes:
idroom_codeactivecaseselectedcaseactiveeventgamestateusersmodeconfig_idcreated_atupdated_at
Regras:
room_codeé o código de 4 números da sala;usersguarda jogadores em JSON;modeindicacustom,casualouranked;- cada jogador tem
id,nickname,color,readyejoinedAt; colordeve ser única por sala;activecaseaponta paracases.id;selectedcaseaponta para um caso escolhido na ante-sala personalizada antes do início;activeeventsincroniza eventos de palpite e resultado;gamestatesincroniza fases, roleta, turno atual, pistas compartilhadas, votos para pular, eliminados e retorno à ante-sala;- sala vazia é removida imediatamente.
- salas
casualerankedsão pareadas automaticamente, usam configuração clássica fixa e não possuem líder/configuração editável. - salas e modos de jogo exigem usuário logado com
usernamedefinido; - na ante-sala, o nickname vem da conta e o jogador escolhe apenas uma cor antes de marcar pronto.
- em salas personalizadas, o primeiro participante pode escolher um caso existente;
- ao escolher um caso, todos voltam para aguardando;
- quando todos ficam prontos com um caso escolhido, o backend valida se há pistas suficientes e inicia o jogo direto;
- se a validação falhar, todos voltam para aguardando e a seleção é removida.
Usada para parear jogadores em filas públicas.
Campos relevantes:
idbrowser_iduser_idmoderatingmatched_room_coderoom_user_idcreated_atupdated_at
Regras:
casualpareia os primeiros 4 jogadores disponíveis;rankedexige usuário logado e pareia 4 jogadores com rating próximo;- a fila é vinculada ao usuário logado e ao navegador usado na busca;
- entradas em fila usam heartbeat; jogadores sem comunicação recente deixam de ser candidatos ao pareamento;
- a tela de busca mostra quantos jogadores compatíveis já estão na fila até fechar 4;
- quando a mesa fecha, uma sala
game_roomsé criada commodecorrespondente, nomes vindos das contas e cores ainda vazias; - sair da tela de busca cancela a entrada ainda não pareada;
- a ante-sala pareada não mostra painel de configuração nem líder da sala.
Usada para guardar o caso diário escolhido.
Campos relevantes:
problem_datecase_idcreated_at
Regras:
- há no máximo um problema por dia;
- o caso é sorteado aleatoriamente da tabela
casesna primeira abertura do dia ou pelo scheduler iniciado junto com o servidor; - o scheduler reexecuta a verificação na virada UTC e usa
ON CONFLICTpara evitar duplicatas; - dias anteriores permanecem registrados e podem ser abertos pelo calendário;
- o calendário exibe o mês completo, mas só habilita datas presentes em
daily_problems.
Usada para controlar o último palpite individual do problema diário.
Campos relevantes:
user_idproblem_datesubmitted_answeris_correctcooldown_untilanswered_atcreated_atupdated_at
Regras:
- chave primária composta por
user_ideproblem_date; - há no máximo um palpite salvo por usuário em cada problema diário;
- ao enviar um novo palpite, o palpite anterior expirado é removido e substituído;
- quem acerta passa a ver sua própria resposta e a resposta oficial;
- quem erra mantém apenas o último palpite enviado e entra em cooldown de 1 hora;
- acertos incrementam
user_achievements.daily_problems_solved; - as pistas são exibidas juntas e embaralhadas de forma estável, sem indicar quais são verdadeiras ou falsas.
Usada para parâmetros da partida.
Campos relevantes:
room_idreading_time_secondsclue_selection_time_secondsrevealed_clue_analysis_time_secondsround_analysis_time_secondsfinal_guess_time_secondstrue_clues_per_playerclues_per_player
Usada para contas públicas do site.
Campos relevantes:
idnameusernameemailemail_normalizedproviderpassword_hashemail_verifiedemail_verified_atprivacy_acknowledgedprivacy_acknowledged_atprivacy_versionterms_acceptedterms_accepted_atterms_versioncreated_atupdated_at
Regras:
email_normalizedé único;usernameé único quando definido e é o nome público exibido no site;- OAuth não usa automaticamente o nome vindo de Google/GitHub; o usuário escolhe
usernamedepois do primeiro login; providerindicacredentials,googleougithub;- o mesmo email só pode autenticar pelo provider usado no primeiro cadastro;
- o aceite dos termos é obrigatório ao efetivar o cadastro, seja no cadastro por email/senha ou na escolha do
usernameapós OAuth; - contas por email/senha só são criadas em
usersdepois da confirmação do email; terms_accepted,privacy_acknowledgedeemail_verifiedbloqueiam login/ações quando falsos;terms_accepted_ateprivacy_acknowledged_atregistram quando o aceite/ciência foi feito;terms_versioneprivacy_versionguardam as versões aceitas;- a tela de perfil permite excluir a conta com confirmação em modal por código hexadecimal gerado pelo backend; a UI avisa 30 segundos e o servidor guarda o código por 40 segundos;
- senhas são armazenadas como hash
scryptcom salt individual; - Auth.js usa sessão JWT;
- login/cadastro aparecem em modal no cabeçalho público, não em páginas próprias.
/termose/privacidadesão páginas públicas e não exibem o modal obrigatório de escolha de nome, permitindo leitura antes do aceite.
Usada para confirmar contas cadastradas com email e senha.
Campos relevantes:
iduser_idtoken_hashemailusernamepassword_hashexpires_atused_atcreated_at
Regras:
- o token salvo é hash SHA-256, não o token bruto enviado por email;
- antes da confirmação,
email,usernameepassword_hashficam apenas nessa tabela temporária; - cada novo envio invalida tokens pendentes do mesmo email ou nome de usuário;
- o link expira em 1 hora;
- confirmação cria a linha em
usersjá comemail_verified = true; user_idsó é usado para compatibilidade com tokens antigos gerados quando a conta ainda era criada antes da confirmação.
Usada para métricas de perfil conectadas ao usuário.
Campos relevantes:
user_idtotal_matches_playedranked_matches_playedtotal_matches_wonranked_matches_wonranked_ratingdaily_problems_solvedcreated_atupdated_at
Regras:
user_idreferenciausers.idcom remoção em cascata;- uma linha é criada automaticamente quando o usuário é cadastrado ou consolidado via OAuth;
- valores começam em
0, excetoranked_rating, que começa em1000; - partidas encerradas atualizam totais jogados e vitórias junto da gravação de
match_history.
Usada para listar partidas encerradas no perfil.
Campos relevantes:
idmatch_idcase_iduser_idusernamewinner_user_idwinner_usernameofficial_final_answerwinning_final_guessuser_final_guessuser_wonfinalized_atstats_recordedcreated_at
Regras:
- um palpite errado cria ou atualiza uma linha provisória para o jogador eliminado, com
winner_user_id,winner_usernameewinning_final_guessainda nulos; - quando a partida termina, o histórico é fechado para todos os participantes com conta ainda registrados na sala;
- linhas provisórias recebem
winner_user_id,winner_usernameewinning_final_guessse outro jogador vencer depois; winner_user_id,winner_usernameewinning_final_guessficam nulos quando a partida termina sem vencedor;user_final_guessguarda o palpite enviado pelo próprio usuário naquela partida, quando existir;case_idreferenciacases.ide permite abrir o caso completo no perfil;stats_recordedevita somar partidas e vitórias mais de uma vez emuser_achievements.
Valores padrão:
readingTimeSeconds=120
clueSelectionTimeSeconds=10
revealedClueAnalysisTimeSeconds=30
roundAnalysisTimeSeconds=60
finalGuessTimeSeconds=60
trueCluesPerPlayer=3
cluesPerPlayer=6
Usada para os casos gerados pela IA.
Campos usados:
idtitlecase_textfinal_answertrue_cluesfalse_cluescreated_at
O caso é gerado como JSON estruturado. true_clues e false_clues são arrays JSONB com quantidade calculada por número de jogadores e configuração da sala.
O estado da partida fica em game_rooms.gamestate:
ready -> reading -> roulette -> turn -> shared_clue -> turn/pause -> ...
Fases principais:
ready: todos confirmam presença dentro do jogo.reading: leitura inicial do dossiê e dos fragmentos privados.roulette: sorteio animado da ordem de jogadores da rodada.turn: jogador atual escolhe uma pista para compartilhar.shared_clue: pista revelada fica visível para todos durante a análise.pause: intervalo coletivo entre rodadas.
A função GET /api/rooms/[code] também avança fases expiradas quando a sala é consultada. Isso mantém a sincronização com polling sem servidor em tempo real.
Cada jogador recebe uma quantidade igual de fragmentos do caso ativo.
A tela de jogo monta os fragmentos de forma determinística:
- usa a posição do jogador na lista da sala;
- redistribui
true_cluesefalse_cluessem reutilizar pistas; - descarta sobras que não fecham divisão igual, priorizando descartar pistas falsas;
- embaralha com seed baseada em
case.ideuserId; - registra em
gamestate.sharedClueIdsquais fragmentos já foram revelados.
O backend valida a vez do jogador antes de aceitar uma pista compartilhada.
O estado também guarda finalGuessesByUserId, com o último palpite final enviado por cada jogador da sala, e matchHistoryRecordedAt, usado para evitar gravação duplicada do histórico quando a partida já foi encerrada.
A camada de IA fica em lib/ai.
Uso atual:
- geração do caso e suas pistas em
lib/cases.ts; - reparo de JSON inválido quando possível;
- avaliação do palpite final contra a resposta oficial em
lib/rooms.ts.
A geração exige:
- JSON válido;
- campos
title,case_text,true_clues,false_clues,final_answer; - quantidade exata de pistas conforme a configuração;
- perguntas centrais explícitas no texto do caso;
- resposta final começando com
Resposta:e contendoContexto:.
- Existe login público opcional por Auth.js, com email/senha, Google e GitHub.
- A sessão de conta é independente da sessão de jogador da sala.
- A página
/jogaré pública, mas suas ações derivadas exigem conta logada com nome público definido. - Rotas protegidas por navegação:
/jogar/busca,/jogar/diarioe/sala/*. - APIs protegidas:
/api/rooms/*,/api/matchmaking,/api/daily-problem,/api/cases/*e/api/users/*. - A sessão do jogador fica em
localStoragecom a chavecontrapista-session. - Há marcadores locais por sala para evitar que um jogador que já voltou à ante-sala seja puxado de volta para o caso ativo.
- A sincronização entre jogadores usa polling a cada 2 segundos e estado persistido em PostgreSQL.
- Eventos efêmeros de palpite ficam em
activeevent; fases e progresso ficam emgamestate.
As cores permitidas ficam em lib/player-colors.ts:
red
blue
green
yellow
purple
orange
O frontend desabilita cores já usadas e o backend também bloqueia duplicidade.
O site tem identidade visual escura por padrão e regras adicionais para modo claro em app/globals.css.
Quando o navegador está em modo claro:
- fundos ficam claros;
- textos ficam escuros;
- inputs e painéis usam contraste claro.
- A aplicação pressupõe um único banco PostgreSQL configurado por
DATABASE. - O estado não usa WebSocket ou SSE; a coordenação acontece por polling.
- A geração de caso tem trava em memória por sala para evitar criações simultâneas no mesmo processo.
- Como parte do estado depende de memória do processo, como espera temporária de modelos e tempo estimado da última geração, esses dados não são compartilhados entre múltiplas instâncias.