primocode 8.12.0 → 8.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -290,15 +290,73 @@ function ultimaMensagem(messages) {
290
290
  /** Devolve a decisão pro onEvent — tool_call, ou assistant_message (com
291
291
  * degradação pra texto puro quando o bloco marcado não veio, em vez de
292
292
  * travar o turno em silêncio). */
293
- function emitirDecisao(textoLido, onEvent) {
293
+ /* ─────────────────────────────────────────────── o que não deu para ler ──
294
+ O motor às vezes devolve algo que este lado não sabe processar: bloco com
295
+ sintaxe torta, ou uma ferramenta que não existe. O usuário via isso na
296
+ tela — "O motor Claude Code devolveu uma decisão que não consegui ler" —
297
+ e a tarefa parava.
298
+
299
+ Erro de forma não é assunto do usuário. Quem errou a forma foi o motor, e
300
+ quem sabe consertar é ele. Então o problema volta PARA ELE, dizendo
301
+ exatamente o que estava errado, e ele reescreve a ação. Só depois de
302
+ algumas tentativas seguidas é que alguém precisa saber.
303
+
304
+ `pedirCorrecao` é preenchido por quem chamou (o caminho headless ou o
305
+ ORCA), porque só eles sabem como falar com o motor de novo. */
306
+ function diagnosticar(cru, nomesValidos) {
307
+ if (cru.includes(MARCA_INICIO) && !cru.includes(MARCA_FIM)) {
308
+ return 'você abriu ' + MARCA_INICIO + ' e nunca fechou com ' + MARCA_FIM + '.';
309
+ }
310
+ if (!cru.includes(MARCA_INICIO) && cru.includes(MARCA_FIM)) {
311
+ return 'apareceu ' + MARCA_FIM + ' sem o ' + MARCA_INICIO + ' antes.';
312
+ }
313
+ const dentro = /<<<PRIMOCODE>>>([\s\S]*?)<<<FIM>>>/.exec(cru);
314
+ if (dentro) {
315
+ try {
316
+ JSON.parse(dentro[1].trim());
317
+ return 'o JSON até é válido, mas não tem "tool_call" nem "assistant_message" na raiz.';
318
+ } catch (e) {
319
+ return 'o conteúdo entre as marcas não é JSON válido: ' + e.message
320
+ + '. Lembre de escapar quebra de linha como \\n dentro das strings.';
321
+ }
322
+ }
323
+ if (nomesValidos) return 'a ferramenta pedida não existe. As que existem: ' + nomesValidos + '.';
324
+ return 'não achei um bloco marcado na sua resposta.';
325
+ }
326
+
327
+ function textoDeCorrecao(cru, motivo) {
328
+ return 'A sua última resposta não pôde ser executada: ' + motivo
329
+ + '\n\nO que você mandou foi:\n---\n' + String(cru).slice(0, 1200) + '\n---\n\n'
330
+ + 'Reescreva AGORA a mesma intenção numa ação que o sistema reconheça. '
331
+ + 'Responda com exatamente UM bloco:\n'
332
+ + MARCA_INICIO + '\n{"tool_call":{"name":"<uma ferramenta do catálogo>","arguments":{...}}}\n' + MARCA_FIM
333
+ + '\nou, se era só texto para a pessoa, ' + MARCA_INICIO
334
+ + '\n{"assistant_message":"..."}\n' + MARCA_FIM
335
+ + '\nNada fora do bloco.';
336
+ }
337
+
338
+ const MAX_CORRECOES = 3;
339
+
340
+ async function emitirDecisao(textoLido, onEvent, pedirCorrecao, tentativa, validos) {
341
+ tentativa = tentativa || 0;
294
342
  const decisao = extrairDecisao(textoLido);
295
343
  if (!decisao) {
296
344
  const cru = String(textoLido || '').trim();
297
345
  // Traz a marca do protocolo mas o bloco não dá para ler? O motor
298
- // TENTOU chamar ferramenta e errou a sintaxe. Antes isso virava
299
- // desistência na tela; agora vira o mesmo erro que o loop já sabe
300
- // repetir ("Failed to call a function" dispara retry da rodada).
346
+ // TENTOU chamar ferramenta e errou a sintaxe. Em vez de mostrar isso
347
+ // ao usuário, devolvemos o problema ao motor com o diagnóstico e
348
+ // pedimos a mesma intenção escrita direito.
301
349
  if (cru.includes(MARCA_INICIO) || cru.includes(MARCA_FIM)) {
350
+ if (pedirCorrecao && tentativa < MAX_CORRECOES) {
351
+ const motivo = diagnosticar(cru);
352
+ try {
353
+ const novo = await pedirCorrecao(textoDeCorrecao(cru, motivo));
354
+ return await emitirDecisao(novo, onEvent, pedirCorrecao, tentativa + 1, validos);
355
+ } catch (e) {
356
+ // A correção em si falhou (motor fora do ar, tempo
357
+ // esgotado). Cai no caminho antigo, que o loop repete.
358
+ }
359
+ }
302
360
  onEvent({ erro: 'Failed to call a function: o motor Claude devolveu um bloco ilegível — repetindo a rodada.' });
303
361
  return;
304
362
  }
@@ -307,10 +365,27 @@ function emitirDecisao(textoLido, onEvent) {
307
365
  return;
308
366
  }
309
367
  if (decisao.tool_call) {
368
+ // Ferramenta que não existe é o outro jeito de o motor devolver algo
369
+ // que este lado não sabe processar. Antes viraria uma ação falhada
370
+ // com "ferramenta desconhecida" — ruído para o usuário por um erro
371
+ // que é do motor. Volta para ele, com a lista do que existe.
372
+ const nome = decisao.tool_call.name;
373
+ if (validos && validos.length && validos.indexOf(nome) === -1) {
374
+ if (pedirCorrecao && tentativa < MAX_CORRECOES) {
375
+ try {
376
+ const novo = await pedirCorrecao(textoDeCorrecao(
377
+ JSON.stringify(decisao),
378
+ 'a ferramenta "' + nome + '" não existe. As que existem: ' + validos.join(', ') + '.'));
379
+ return await emitirDecisao(novo, onEvent, pedirCorrecao, tentativa + 1, validos);
380
+ } catch (e) { /* motor mudo: segue e deixa o loop tratar */ }
381
+ }
382
+ onEvent({ erro: 'Failed to call a function: ferramenta "' + nome + '" não existe — repetindo a rodada.' });
383
+ return;
384
+ }
310
385
  // lib/act.js usa tc.id pra parear a mensagem role:'tool' com este
311
386
  // tool_call (messages.push({ tool_call_id: tc.id, ... })) — sem id
312
387
  // aqui, esse pareamento quebra.
313
- onEvent({ tool_call: { id: proximoId(), name: decisao.tool_call.name, arguments: decisao.tool_call.arguments || {} } });
388
+ onEvent({ tool_call: { id: proximoId(), name: nome, arguments: decisao.tool_call.arguments || {} } });
314
389
  }
315
390
  else if (decisao.assistant_message) {
316
391
  // O modelo pode confundir a forma com a do tool_call (que É um
@@ -359,7 +434,19 @@ async function requestActViaOrca(payload, onEvent) {
359
434
  // uma string única (`text`). Confirmado rodando de verdade (Task 9).
360
435
  const t = leitura.result.terminal || {};
361
436
  sessao.cursor = t.nextCursor != null ? String(t.nextCursor) : sessao.cursor;
362
- emitirDecisao((t.tail || []).join('\n'), onEvent);
437
+ // No terminal a correção é só mais uma mensagem na MESMA sessão: o
438
+ // motor já tem o protocolo e a conversa toda em contexto, então
439
+ // basta dizer o que saiu errado.
440
+ const pedirCorrecao = (aviso) => {
441
+ rodarOrca(['terminal', 'send', '--terminal', sessao.handle, '--text', aviso, '--enter']);
442
+ esperarIdleOrca(sessao.handle, 120000);
443
+ const outra = JSON.parse(rodarOrca(['terminal', 'read', '--terminal', sessao.handle,
444
+ '--cursor', sessao.cursor, '--json']));
445
+ const t2 = outra.result.terminal || {};
446
+ sessao.cursor = t2.nextCursor != null ? String(t2.nextCursor) : sessao.cursor;
447
+ return (t2.tail || []).join('\n');
448
+ };
449
+ await emitirDecisao((t.tail || []).join('\n'), onEvent, pedirCorrecao, 0, nomesDasTools(payload));
363
450
  } catch (e) {
364
451
  // Handle obsoleto (terminal fechado, ORCA reiniciou) — documentado
365
452
  // pelo próprio `orca agent-context` como terminal_handle_stale. Zera
@@ -414,7 +501,26 @@ async function requestActViaHeadless(payload, onEvent) {
414
501
  '--dangerously-skip-permissions',
415
502
  ], { encoding: 'utf-8', timeout: 180000, input: prompt, maxBuffer: 32 * 1024 * 1024 });
416
503
  const r = JSON.parse(saida);
417
- emitirDecisao(r.result || '', onEvent);
504
+ // A correção é uma segunda ida ao motor, com o mesmo protocolo e o
505
+ // diagnóstico do que saiu errado. Barata: o prompt é curto, e evita que
506
+ // um erro de sintaxe do motor vire erro na cara do usuário.
507
+ const pedirCorrecao = (aviso) => {
508
+ const s = execFileSync('claude', [
509
+ '-p', '--output-format', 'json', '--dangerously-skip-permissions',
510
+ ], {
511
+ encoding: 'utf-8', timeout: 120000, maxBuffer: 32 * 1024 * 1024,
512
+ input: corpoBootstrap + '\n\n' + aviso,
513
+ });
514
+ return JSON.parse(s).result || '';
515
+ };
516
+ await emitirDecisao(r.result || '', onEvent, pedirCorrecao, 0, nomesDasTools(payload));
517
+ }
518
+
519
+ /** Os nomes que o sistema realmente sabe executar, tirados do payload. */
520
+ function nomesDasTools(payload) {
521
+ return ((payload && payload.tools) || [])
522
+ .map((t) => (t && t.function && t.function.name) || t.name)
523
+ .filter(Boolean);
418
524
  }
419
525
 
420
526
  /** Mesma forma de api.requestAct(server, payload, onEvent) — `server` não
@@ -440,4 +546,5 @@ module.exports = {
440
546
  SKILL_DIR, SKILL_PATH, garantirSkill,
441
547
  temClaudeCode, temOrca, orcaAlcancavel, escolherCaminho,
442
548
  garantirEngine, requestAct, emitirDecisao,
549
+ _emitirDecisao: emitirDecisao,
443
550
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "8.12.0",
3
+ "version": "8.14.0",
4
4
  "description": "PrimoCode — agente de engenharia com IA e cursor próprio. Modelos grátis. Cria arquivos, roda comandos, controla navegador e desktop: abre apps, clica em botões e ícones pelo nome, digita e usa atalhos.",
5
5
  "main": "bin/primocode.js",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node bin/primocode.js",
11
- "test": "node test/tools.test.js && node test/ui.test.js && node test/modo.test.js && node test/pasta.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/effort.test.js && node test/claude-engine.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/studio-spec.test.js && node test/studio-versoes.test.js && node test/voz.test.js"
11
+ "test": "node test/tools.test.js && node test/ui.test.js && node test/modo.test.js && node test/pasta.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/effort.test.js && node test/claude-engine.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/studio-spec.test.js && node test/studio-versoes.test.js && node test/voz.test.js && node test/editor.test.js"
12
12
  },
13
13
  "engines": {
14
14
  "node": ">=18.17.0"
@@ -137,8 +137,53 @@ Sem declarar nada, os elementos já entram em cascata na ordem em que aparecem.
137
137
  `meia-noite` `claro` `papel` `neon` `brasa` `floresta` `retro` `oceano` `doce` `mono`
138
138
 
139
139
  São esses dez, e só. Nome fora da lista é recusado: escuro é `meia-noite`, não
140
- `noite`. Cor de marca não vira tema — ela entra em `cor` nos elementos.
141
- Vale no documento ou por cena. Uma cena com tema diferente no meio cria respiro —
140
+ `noite`. Vale no documento ou por cena.
141
+
142
+ ### Identidade — o que separa um vídeo genérico de um da marca
143
+
144
+ Uma peça sem identidade sai igual a todas as outras. Três coisas resolvem, e
145
+ custam uma linha cada:
146
+
147
+ ```json
148
+ "marca": { "logo": "/midia/logo.png", "cor": "#ff5a36", "canto": "inferior-direita" }
149
+ ```
150
+
151
+ `marca` vale no **documento**: o logo aparece em todas as cenas, no canto, e
152
+ `cor` vira a cor de destaque de números, gráficos e detalhes. Ponha
153
+ `"semMarca": true` na capa se ela já tiver o logo grande no meio.
154
+
155
+ Se o usuário citar a empresa dele e não houver logo na galeria, **peça o
156
+ arquivo** — `studio_midia` sem `arquivo` lista o que já existe. Não invente
157
+ logo, e não deixe o vídeo sem nenhum quando ele é institucional.
158
+
159
+ E varie de cena para cena:
160
+
161
+ - **`fundo` diferente** ao longo do vídeo. Onze existem: `malha` `brilho`
162
+ `grade` `pontos` `suave` `vinheta` `aurora` `nevoa` `ondas` `raios`
163
+ `xadrez`. Dez cenas com o mesmo fundo é o que faz parecer template.
164
+ - **`tom`** por cena muda a cor do destaque só ali — bom para separar
165
+ capítulos.
166
+ - **`cor`** no elemento de texto, quando uma palavra precisa saltar.
167
+ - **`tema` por cena**: uma citação em `papel` no meio de um deck escuro é
168
+ respiro. Uma vez, não cinco.
169
+
170
+ ### Objetos 3D
171
+
172
+ Volume de verdade, com perspectiva — serve para produto, capa e abertura.
173
+
174
+ ```json
175
+ { "tipo": "objeto3d", "forma": "cubo", "tamanho": 320, "girar": 14,
176
+ "faces": ["Produto", "Dados", "IA", "Automação", "Nuvem", "Escala"] }
177
+ ```
178
+
179
+ `forma`: `cubo` · `caixa` (achatada) · `cartao` (com espessura) · `prisma` ·
180
+ `painel` (superfície flutuando). `faces` aceita texto **ou** URL de imagem —
181
+ o logo nas seis faces de um cubo girando é abertura pronta. `girar` é a volta
182
+ completa em segundos (`0` deixa parado no `angulo` dado), `inclinar` levanta
183
+ ou baixa a câmera, `cores` pinta face a face.
184
+
185
+ Aceita `chaves` como qualquer elemento: dá para o cubo atravessar a cena
186
+ girando. Use **um** por cena — dois objetos girando disputam o olho. Uma cena com tema diferente no meio cria respiro —
142
187
  use uma vez, não cinco.
143
188
 
144
189
  ### Mídia do computador
@@ -185,6 +185,33 @@ Na cena, `transicao` define como ela **entra**:
185
185
 
186
186
  Vale o mesmo conselho das animações: varie. Tudo em `fade` parece slide, não vídeo.
187
187
 
188
+ ## Chaves de animação
189
+
190
+ `anim` faz o elemento **entrar** e parar por ali. Para ele se mexer ao longo da
191
+ cena — atravessar, crescer devagar, sumir no fim — use `chaves`: uma lista de
192
+ instantes, com `t` de 0 (começo da cena) a 1 (fim).
193
+
194
+ ```json
195
+ { "tipo": "texto", "texto": "Atravessa a tela",
196
+ "chaves": [
197
+ { "t": 0, "x": -400, "opacidade": 0 },
198
+ { "t": 0.25, "x": 0, "opacidade": 1 },
199
+ { "t": 0.75, "x": 0, "opacidade": 1 },
200
+ { "t": 1, "x": 400, "opacidade": 0 }
201
+ ] }
202
+ ```
203
+
204
+ Campos de uma chave, todos opcionais: `x` e `y` (**deslocamento** em pixels a
205
+ partir de onde o elemento está — não é posição absoluta), `escala` (1 = tamanho
206
+ normal), `giro` (graus), `opacidade` (0 a 1) e `desfoque` (pixels).
207
+
208
+ No elemento: `"repetir": true` roda sem parar, e `"suavizar": false` dá
209
+ movimento constante em vez de acelerar e frear.
210
+
211
+ Duas chaves já bastam. Quem tem `chaves` ignora `anim` — as duas disputariam o
212
+ mesmo movimento, e a chave é mais específica. Use em **um ou dois** elementos
213
+ por vídeo: tudo se mexendo ao mesmo tempo cansa e esconde a mensagem.
214
+
188
215
  ## Acabamento
189
216
 
190
217
  Textura por cima da cena inteira — é o que tira a cara de template:
package/studio/server.py CHANGED
@@ -16,6 +16,7 @@ import mimetypes
16
16
  import os
17
17
  import re
18
18
  import sys
19
+ import threading
19
20
  import time
20
21
  import unicodedata
21
22
  import uuid
@@ -299,6 +300,113 @@ def preparar_voz(doc):
299
300
  pass
300
301
 
301
302
 
303
+ # ------------------------------------------------------- narração em arquivo --
304
+ #
305
+ # O usuário testou e disse: "as vozes tá a mesma merda". Estava certo, e a
306
+ # causa era tempo, não motor. A narração era sintetizada NA HORA DE TOCAR:
307
+ # quem apertava play antes de o Piper terminar de baixar ouvia o motor de
308
+ # reserva — a voz robótica de sempre. Como o download leva minutos e o vídeo
309
+ # fica pronto em segundos, era o que acontecia SEMPRE na primeira vez.
310
+ #
311
+ # Agora a narração vira ARQUIVO assim que o roteiro é gravado. O trabalho
312
+ # roda no fundo, espera o motor bom ficar pronto (esperar é de graça: ninguém
313
+ # está olhando) e grava um .wav por cena. O player já preferia
314
+ # `cena.narracaoAudio` quando existe — passa a existir sempre.
315
+ #
316
+ # De quebra, conserta duas coisas que ninguém tinha ligado: o vídeo gravado
317
+ # em MP4 carrega a voz boa, e tocar de novo não re-sintetiza nada.
318
+
319
+ _narrando = set()
320
+ _tranca_narracao = threading.Lock()
321
+
322
+
323
+ def _assinatura_voz(texto, voz):
324
+ corpo = "%s|%s|%s|%s" % (texto, voz.get("voz") or "", voz.get("grave") or "",
325
+ voz.get("velocidade") or "")
326
+ return hashlib.sha1(corpo.encode("utf-8")).hexdigest()[:10]
327
+
328
+
329
+ def _gravar_narracao(arq_id):
330
+ """Sintetiza a narração de cada cena e guarda em midia/."""
331
+ try:
332
+ # Esperar o motor: são minutos, mas ninguém está esperando na frente
333
+ # da tela — e o resultado é a diferença entre voz de gente e voz de
334
+ # robô no vídeo que a pessoa vai apresentar.
335
+ for _ in range(120):
336
+ if voz_lib.pronta():
337
+ break
338
+ voz_lib.instalar()
339
+ time.sleep(5)
340
+ if not voz_lib.pronta():
341
+ return
342
+
343
+ a = ler(arq_id)
344
+ if not a:
345
+ return
346
+ doc = a.get("doc") or {}
347
+ voz = doc.get("voz") if isinstance(doc.get("voz"), dict) else {}
348
+ mudou = False
349
+
350
+ for i, c in enumerate(doc.get("cenas") or []):
351
+ if not isinstance(c, dict):
352
+ continue
353
+ texto = c.get("narracao")
354
+ if not isinstance(texto, str) or not texto.strip():
355
+ continue
356
+ assinatura = _assinatura_voz(texto, voz)
357
+ # Já gravada e com o mesmo texto e a mesma voz: nada a fazer.
358
+ if c.get("narracaoVoz") == assinatura and c.get("narracaoAudio"):
359
+ continue
360
+ # Gravação do próprio usuário nunca é sobrescrita — a voz dele
361
+ # vale mais que qualquer síntese.
362
+ if c.get("narracaoAudio") and not c.get("narracaoVoz"):
363
+ continue
364
+ try:
365
+ audio = voz_lib.sintetizar(
366
+ texto, voz.get("voz"),
367
+ grave=bool(voz.get("grave")),
368
+ velocidade=float(voz.get("velocidade") or 1))
369
+ except Exception as e:
370
+ sys.stderr.write("[voz] cena %d falhou: %s\n" % (i + 1, e))
371
+ continue
372
+ if not audio:
373
+ continue
374
+ nome = "narr-%s-%d-%s.wav" % (arq_id[:28], i + 1, assinatura)
375
+ with open(os.path.join(MIDIA, nome), "wb") as fh:
376
+ fh.write(audio)
377
+ c["narracaoAudio"] = "/midia/" + nome
378
+ c["narracaoVoz"] = assinatura
379
+ mudou = True
380
+
381
+ if mudou:
382
+ # Relê antes de gravar: o agente pode ter mexido no documento
383
+ # enquanto a síntese rodava, e o trabalho dele vale mais.
384
+ atual = ler(arq_id)
385
+ if atual:
386
+ cenas = (atual.get("doc") or {}).get("cenas") or []
387
+ for i, c in enumerate(doc.get("cenas") or []):
388
+ if i < len(cenas) and c.get("narracaoAudio") \
389
+ and cenas[i].get("narracao") == c.get("narracao"):
390
+ cenas[i]["narracaoAudio"] = c["narracaoAudio"]
391
+ cenas[i]["narracaoVoz"] = c["narracaoVoz"]
392
+ gravar(atual)
393
+ except Exception as e:
394
+ sys.stderr.write("[voz] narração em arquivo falhou: %s\n" % e)
395
+ finally:
396
+ with _tranca_narracao:
397
+ _narrando.discard(arq_id)
398
+
399
+
400
+ def narrar_em_arquivo(arq_id, doc):
401
+ if not _tem_narracao(doc):
402
+ return
403
+ with _tranca_narracao:
404
+ if arq_id in _narrando:
405
+ return
406
+ _narrando.add(arq_id)
407
+ threading.Thread(target=_gravar_narracao, args=(arq_id,), daemon=True).start()
408
+
409
+
302
410
  def mesclar(antigo, novo):
303
411
  """O documento novo por cima do antigo, chave a chave.
304
412
 
@@ -436,12 +544,14 @@ def criar(dados):
436
544
 
437
545
  preparar_voz(doc)
438
546
  arq_id = ferramenta + "-" + slugify(titulo) + "-" + uuid.uuid4().hex[:5]
439
- return gravar({
547
+ salvo = gravar({
440
548
  "id": arq_id,
441
549
  "ferramenta": ferramenta,
442
550
  "titulo": titulo,
443
551
  "doc": doc or exemplo(ferramenta),
444
552
  })
553
+ narrar_em_arquivo(arq_id, salvo.get("doc") or {})
554
+ return salvo
445
555
 
446
556
 
447
557
  # Frases que só existem nos EXEMPLOS das specs. Documento que chega com uma
@@ -512,7 +622,8 @@ def validar_doc(modelo, doc):
512
622
  # quando o texto estava noutro campo. Era o "vídeo com música mas tudo
513
623
  # preto": documento válido na forma, invisível na tela.
514
624
  TIPOS_VALIDOS = {"texto", "lista", "cartoes", "numeros", "tabela", "codigo",
515
- "citacao", "forma", "imagem", "icone", "grafico", "no", "conector"}
625
+ "citacao", "forma", "imagem", "icone", "grafico", "no", "conector",
626
+ "objeto3d"}
516
627
 
517
628
  # Tema inventado tinha o mesmo destino silencioso do tipo inventado: o CSS
518
629
  # não casa com [data-tema="noite"], nenhuma cor é definida e a peça sai
@@ -1163,6 +1274,7 @@ class Handler(SimpleHTTPRequestHandler):
1163
1274
  if "doc" in dados:
1164
1275
  a["doc"] = novo
1165
1276
  salvo = gravar(a)
1277
+ narrar_em_arquivo(a["id"], salvo.get("doc") or {})
1166
1278
  if versao:
1167
1279
  # O agente vê isto no resultado da tool: se ele estragou algo, o
1168
1280
  # caminho de volta está na resposta, não numa doc que ele não leu.
@@ -381,9 +381,18 @@ input.titulo-arq:focus { outline: none; border-color: var(--primo); background:
381
381
  }
382
382
  .grupo { border-bottom: 1px solid var(--linha); padding: 14px; }
383
383
  .grupo > .rotulo { display: block; margin-bottom: 11px; }
384
- .linha-campo { display: grid; grid-template-columns: 62px 1fr; align-items: center; gap: 8px; margin-bottom: 8px; }
384
+ /* Rótulo em cima, controle embaixo.
385
+
386
+ A coluna do rótulo tinha 62px fixos. "Acabamento" não cabia e saía
387
+ cortado; "Cor do fundo" e "Tema da cena" quebravam em duas linhas e
388
+ desalinhavam a altura de tudo. Era metade da sensação de "estranho" no
389
+ painel. Empilhado, o nome cabe inteiro e o controle usa a largura toda. */
390
+ .linha-campo { display: grid; gap: 4px; margin-bottom: 11px; }
385
391
  .linha-campo:last-child { margin-bottom: 0; }
386
- .linha-campo > span { font-size: 11.5px; color: var(--fraco2); }
392
+ .linha-campo > span {
393
+ font-size: 10.5px; color: var(--fraco2);
394
+ letter-spacing: .04em; text-transform: uppercase;
395
+ }
387
396
  .dois { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
388
397
  .tres { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
389
398
  .opcoes { display: flex; gap: 4px; }
@@ -721,3 +730,125 @@ body:has(.transporte) .aviso { bottom: 80px; }
721
730
  .topo { gap: 6px; padding: 0 8px; }
722
731
  .marca em { display: none; }
723
732
  }
733
+
734
+ /* ══════════════════════════════════════════════════════════════════════════
735
+ Reordenar, modelos e ações de camada
736
+
737
+ Três coisas que faltavam e faziam o editor parecer travado: arrastar não
738
+ dava retorno nenhum, camada não tinha ação à mão, e "nova cena" abria uma
739
+ tela em branco. O visual aqui é de propósito discreto — a peça que a
740
+ pessoa está fazendo é que tem de chamar atenção, não a ferramenta.
741
+ ═════════════════════════════════════════════════════════════════════════ */
742
+
743
+ /* enquanto arrasta: o item sai de cena, o alvo se anuncia */
744
+ .arrastando { opacity: .35; }
745
+ .camada.alvo-solta, .miniatura.alvo-solta, .clipe.alvo-solta {
746
+ outline: 2px solid var(--primo);
747
+ outline-offset: 1px;
748
+ }
749
+ .camada, .miniatura, .clipe { -webkit-user-drag: element; }
750
+ .camada:active { cursor: grabbing; }
751
+
752
+ /* ações que aparecem ao passar o mouse na camada */
753
+ .camada { position: relative; }
754
+ .camada-acoes {
755
+ display: none; gap: 1px; margin-left: auto; flex: 0 0 auto;
756
+ }
757
+ .camada:hover .camada-acoes, .camada.ativa .camada-acoes { display: flex; }
758
+ .camada-acoes button {
759
+ background: none; border: 0; padding: 3px;
760
+ border-radius: 5px; cursor: pointer; color: var(--fraco2);
761
+ display: grid; place-items: center;
762
+ }
763
+ .camada-acoes button:hover { background: var(--sup3, rgba(255,255,255,.08)); color: var(--texto); }
764
+ .camada-acoes button:disabled { opacity: .25; cursor: default; }
765
+ .camada-acoes button:disabled:hover { background: none; color: var(--fraco2); }
766
+ .camada-acoes svg { width: 12px; height: 12px; opacity: 1; }
767
+
768
+ /* a transição marcada no próprio clipe da linha do tempo */
769
+ .clipe .tr-marca {
770
+ position: absolute; left: 4px; top: 4px;
771
+ background: rgba(6, 6, 8, .72);
772
+ color: #cbd5e1;
773
+ font-size: 9.5px; letter-spacing: .02em;
774
+ padding: 2px 5px; border-radius: 4px;
775
+ pointer-events: none;
776
+ max-width: calc(100% - 8px);
777
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
778
+ }
779
+
780
+ /* o "+" no fim da tira */
781
+ .clipe-mais {
782
+ flex: 0 0 auto;
783
+ width: 40px; height: 62px;
784
+ border: 1px dashed var(--linha2);
785
+ border-radius: 8px;
786
+ background: none; color: var(--fraco2);
787
+ cursor: pointer;
788
+ display: grid; place-items: center;
789
+ }
790
+ .clipe-mais:hover { border-color: var(--primo); color: var(--primo); }
791
+ .clipe-mais svg { width: 15px; height: 15px; }
792
+
793
+ /* galeria de modelos */
794
+ .folha .caixa.larga { width: min(1040px, 100%); }
795
+ .folha .sub { color: var(--fraco); font-size: 12.5px; margin: -8px 0 18px; }
796
+ .modelos {
797
+ display: grid;
798
+ grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
799
+ gap: 14px;
800
+ }
801
+ .modelo {
802
+ background: none; border: 0; padding: 0;
803
+ cursor: pointer; text-align: left;
804
+ display: grid; gap: 7px;
805
+ }
806
+ .modelo .caixa-mini {
807
+ position: relative; width: 100%; aspect-ratio: 16/9;
808
+ overflow: hidden; border-radius: 9px;
809
+ border: 1px solid var(--linha);
810
+ background: var(--sup2);
811
+ transition: border-color .12s, transform .12s;
812
+ }
813
+ .modelo .caixa-mini .cena { position: absolute; top: 0; left: 0; transform-origin: top left; }
814
+ .modelo .caixa-mini.vazia { display: grid; place-items: center; border-style: dashed; }
815
+ .modelo .caixa-mini.vazia svg { width: 22px; height: 22px; color: var(--fraco2); }
816
+ .modelo:hover .caixa-mini { border-color: var(--primo); transform: translateY(-2px); }
817
+ .modelo span { font-size: 12.5px; color: var(--fraco); }
818
+ .modelo:hover span { color: var(--texto); }
819
+
820
+ /* seletor de cor compacto: amostras numa faixa, hex ao lado */
821
+ .cor-faixa { display: grid; grid-template-columns: 1fr 62px; gap: 6px; align-items: center; }
822
+ .cor-faixa .cores { flex-wrap: nowrap; overflow-x: auto; padding-bottom: 2px; }
823
+ .cor-faixa .cores::-webkit-scrollbar { height: 0; }
824
+ .cor-faixa .cores button { width: 19px; height: 19px; flex: 0 0 auto; }
825
+ .cor-faixa .cores button.ativa { outline: 2px solid var(--texto); outline-offset: 1px; }
826
+ .cor-faixa .sem-cor {
827
+ background: repeating-linear-gradient(45deg, var(--sup2) 0 4px, var(--linha2) 4px 8px);
828
+ }
829
+ .campo.hex { font-size: 11px; padding-left: 6px; padding-right: 6px; }
830
+
831
+ /* chaves de animação: a régua mostra QUANDO cada uma acontece */
832
+ .regua-chaves { position: relative; height: 26px; margin: 2px 0 2px; }
833
+ .trilho-chaves {
834
+ position: absolute; left: 5px; right: 5px; top: 11px; height: 3px;
835
+ background: var(--sup3); border-radius: 2px;
836
+ }
837
+ .trilho-chaves .pino {
838
+ position: absolute; top: 50%; width: 11px; height: 11px;
839
+ margin: -5.5px 0 0 -5.5px; padding: 0;
840
+ border-radius: 50%; border: 2px solid var(--fundo);
841
+ background: var(--fraco); cursor: pointer;
842
+ }
843
+ .trilho-chaves .pino:hover { background: var(--texto); }
844
+ .trilho-chaves .pino.ativo { background: var(--primo); transform: scale(1.25); }
845
+ .sub-regua { font-size: 10.5px; color: var(--fraco2); margin-bottom: 10px; }
846
+ .opcoes button.ativo { border-color: var(--primo); color: var(--primo); background: var(--primo-suave); }
847
+ .presets { display: grid; gap: 5px; margin-top: 9px; }
848
+ .presets button {
849
+ height: 30px; padding: 0 10px; text-align: left;
850
+ border: 1px solid var(--linha); border-radius: 8px;
851
+ background: var(--sup2); color: var(--fraco);
852
+ font: 500 12px/1 var(--sans); cursor: pointer;
853
+ }
854
+ .presets button:hover { background: var(--sup3); color: var(--texto); border-color: var(--primo); }