@softize/opus 8.9.0 → 9.0.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.
package/CHANGELOG.md CHANGED
@@ -11,6 +11,52 @@ Depois de qualquer bump, rode os gates (`typecheck` · `test` · `opus check` ·
11
11
  > tinha ficado sem registro nenhum, o que é exatamente o caso que este arquivo existe
12
12
  > pra cobrir.
13
13
 
14
+ ## 9.0.0 — 2026-08-12
15
+
16
+ **Chrome denso passa a compartilhar a mesma régua visual.** Três primitives deixam de
17
+ depender de coincidências do call site:
18
+
19
+ - `TabsList` volta a aceitar uma altura fornecida por `className`; no variant `line`, o
20
+ indicador ancora na borda da própria lista, inclusive quando ela preenche uma faixa mais
21
+ alta que o size padrão.
22
+ - `Select variant="ghost"` abraça ícone, rótulo e chevron como um botão de toolbar. O
23
+ rótulo ainda pode encolher e truncar, mas não cresce para empurrar o chevron à borda;
24
+ agora ele também respeita a régua `default` (36px) / `sm` (32px).
25
+ - `Button`, `Select`, `Toggle`, `ToggleGroup`, `InputGroup` e `ButtonGroup` aceitam
26
+ `shape="pill"`. Forma fica separada de intenção (`variant`) e altura (`size`), e grupos
27
+ preservam o raio somente nas extremidades externas — inclusive quando contêm `Select`.
28
+ - o botão de envio do `Composer` usa a geometria pill do próprio `Button`.
29
+ - `Select variant="outline"` oferece a borda de um controle sem o `min-width` de campo:
30
+ ícone, rótulo e chevron ficam juntos em uma única linha em toolbars e composers; rótulos
31
+ longos truncam em vez de empurrar o chevron para uma segunda linha.
32
+ - superfícies elevadas voltam a usar `border border-border`; `ring-edge` e seu token são
33
+ removidos, deixando `ring-*` para foco e estados transitórios.
34
+ - a elevação volta ao vocabulário de intensidade do Tailwind (`shadow-sm`, `shadow-md`,
35
+ `shadow-lg` etc.), com a composição clara e difusa controlada pelo tema do Opus.
36
+ `Card`/`Composer`, menus/popovers e modais usam respectivamente `sm`, `md` e `lg` como
37
+ defaults; os aliases por papel e as variáveis intermediárias `--elevation-*` saem.
38
+ - `shadow-lg` ganha spread negativo e menos opacidade: mantém alcance, mas deixa de
39
+ engrossar visualmente a borda no ponto de contato com a superfície.
40
+ - toda a escala passa a declarar a receita diretamente em `--shadow-*`, preservando a
41
+ API nativa de modifiers do Tailwind, como `shadow-xl/30`.
42
+
43
+ **Migração:** os tokens públicos de elevação por papel foram removidos porque forma e
44
+ intensidade agora são ortogonais. Troque `shadow-card/popover/dialog` por
45
+ `shadow-sm/md/lg`; troque `ring-1 ring-edge` por `border border-border` e `border-edge`
46
+ por `border-border`. Overrides de `--elevation-*` devem migrar para o `--shadow-*`
47
+ correspondente. `opus check` acusa esses usos, que de outra forma perderiam o efeito
48
+ visual sem erro de compilação.
49
+
50
+ Também remova overrides locais de altura, posição do indicador e largura mínima que
51
+ existiam apenas para compensar os defeitos corrigidos nesta versão.
52
+
53
+ ## 8.9.1 — 2026-08-04
54
+
55
+ **A migração deixa de esconder o hook Opus no Git.** `opus setup` agora remove também a
56
+ exclusão local legada e específica de `.claude/hooks/opus-check-on-stop.mjs`, preservando
57
+ as preferências realmente locais. Isso fecha o caso em que o check passava na máquina que
58
+ já tinha o hook, mas o arquivo não entrava no commit para um clone novo.
59
+
14
60
  ## 8.9.0 — 2026-08-04
15
61
 
16
62
  **O Opus passa a materializar e versionar suas próprias skills e garantias.** `opus setup`
package/bin/cli.mjs CHANGED
@@ -176,7 +176,7 @@ async function cmdCheck(dir) {
176
176
  }
177
177
  const { files, actions, contracts, findings, hasMarker } = await scanDir(root)
178
178
 
179
- if (actions === 0) {
179
+ if (actions === 0 && findings.length === 0) {
180
180
  const verdict = emptyScanVerdict({ hasMarker, contracts })
181
181
  // Projeto MARCADO (opus.json) sem actions passa vacuamente — server/lib/app sem
182
182
  // domínio ainda. Sem marcador, "0 actions" segue sintoma (diretório errado ou
@@ -205,7 +205,7 @@ async function cmdCheck(dir) {
205
205
  console.log(` ${String(f.line).padStart(4)}: [${f.rule}] ${f.action} — ${f.message}`)
206
206
  }
207
207
  }
208
- log('error', `\n✗ opus check: ${findings.length} violação(ões) em ${byFile.size}/${actions} action(s).`)
208
+ log('error', `\n✗ opus check: ${findings.length} violação(ões) em ${byFile.size} arquivo(s).`)
209
209
  process.exit(1)
210
210
  }
211
211
 
package/bin/lib/check.mjs CHANGED
@@ -44,6 +44,22 @@ const SEG = '[a-z][a-z0-9]*(-[a-z0-9]+)*'
44
44
  const ACTION_NAME_RE = new RegExp(`^${SEG}(\\.${SEG})+$`)
45
45
  const KINDS = new Set(['simple', 'form', 'list', 'view'])
46
46
 
47
+ // Tokens públicos removidos cuja ausência no CSS não quebra typecheck/build: sem uma
48
+ // migração executável, o consumidor perde borda/sombra em silêncio. O check fica no Opus
49
+ // (dono do contrato visual), não no Maestro. Uma ocorrência por token/arquivo basta.
50
+ const REMOVED_UI_TOKENS = new Map([
51
+ ['ring-edge', 'use `border border-border` para a aresta da superfície'],
52
+ ['border-edge', 'use `border-border`'],
53
+ ['shadow-card', 'use `shadow-sm`'],
54
+ ['shadow-popover', 'use `shadow-md`'],
55
+ ['shadow-dialog', 'use `shadow-lg`'],
56
+ ['--edge-value', 'remova o override; a aresta agora usa `--border`'],
57
+ ['--color-edge', 'remova o token; a aresta agora usa `--border`'],
58
+ ['--elevation-card', 'use/ajuste `--shadow-sm`'],
59
+ ['--elevation-popover', 'use/ajuste `--shadow-md`'],
60
+ ['--elevation-dialog', 'use/ajuste `--shadow-lg`'],
61
+ ])
62
+
47
63
  /** Os três nomes que denotam action/contrato — o arquivo sem nenhum deles é pulado. */
48
64
  export const ACTION_MARKERS = ['defineAction', 'defineContract', 'bindAction']
49
65
 
@@ -239,7 +255,193 @@ export function checkProject(sources) {
239
255
  return { actions, contracts, findings }
240
256
  }
241
257
 
258
+ /** Migrações de UI que falhariam apenas visualmente. Linhas que são só comentário não
259
+ * contam; documentação vive fora do sourceRoot e também não entra no walk. */
260
+ function stripComments(text) {
261
+ let output = ''
262
+ let state = 'code'
263
+ let escaped = false
264
+ for (let index = 0; index < text.length; index += 1) {
265
+ const char = text[index]
266
+ const next = text[index + 1]
267
+ if (state === 'line-comment') {
268
+ if (char === '\n') { state = 'code'; output += char } else output += ' '
269
+ continue
270
+ }
271
+ if (state === 'block-comment') {
272
+ if (char === '*' && next === '/') { output += ' '; index += 1; state = 'code' }
273
+ else output += char === '\n' ? '\n' : ' '
274
+ continue
275
+ }
276
+ if (state === 'html-comment') {
277
+ if (char === '-' && text.slice(index, index + 3) === '-->') {
278
+ output += ' '; index += 2; state = 'code'
279
+ } else output += char === '\n' ? '\n' : ' '
280
+ continue
281
+ }
282
+ if (state === 'code' && text.slice(index, index + 4) === '<!--') {
283
+ output += ' '; index += 3; state = 'html-comment'; continue
284
+ }
285
+ if (state === 'code' && char === '/' && next === '/') {
286
+ output += ' '; index += 1; state = 'line-comment'; continue
287
+ }
288
+ if (state === 'code' && char === '/' && next === '*') {
289
+ output += ' '; index += 1; state = 'block-comment'; continue
290
+ }
291
+ if (state === 'code' && (char === "'" || char === '"' || char === '`')) {
292
+ state = char; output += char; escaped = false; continue
293
+ }
294
+ if (state !== 'code') {
295
+ output += char
296
+ if (escaped) escaped = false
297
+ else if (char === '\\') escaped = true
298
+ else if (char === state) state = 'code'
299
+ continue
300
+ }
301
+ output += char
302
+ }
303
+ return output
304
+ }
305
+
306
+ function quotedValues(text) {
307
+ const values = []
308
+ let line = 1
309
+ const startsRegex = (index) => {
310
+ const before = text.slice(0, index).trimEnd()
311
+ if (before === '') return true
312
+ const previous = before.at(-1)
313
+ return /[=(:,![{;?&|+*%^~<>-]/.test(previous) ||
314
+ /\b(?:return|case|throw|typeof|instanceof|in|of|yield|await)\s*$/.test(before)
315
+ }
316
+ for (let index = 0; index < text.length; index += 1) {
317
+ const quote = text[index]
318
+ if (quote === '\n') { line += 1; continue }
319
+ if (quote === '/' && startsRegex(index)) {
320
+ let escaped = false
321
+ let inCharacterClass = false
322
+ for (index += 1; index < text.length; index += 1) {
323
+ const char = text[index]
324
+ if (char === '\n') line += 1
325
+ if (escaped) { escaped = false; continue }
326
+ if (char === '\\') { escaped = true; continue }
327
+ if (char === '[') { inCharacterClass = true; continue }
328
+ if (char === ']') { inCharacterClass = false; continue }
329
+ if (char === '/' && !inCharacterClass) break
330
+ }
331
+ while (/[a-z]/i.test(text[index + 1] ?? '')) index += 1
332
+ continue
333
+ }
334
+ if (quote !== "'" && quote !== '"' && quote !== '`') continue
335
+ let value = ''
336
+ let escaped = false
337
+ const startLine = line
338
+ const startIndex = index
339
+ for (index += 1; index < text.length; index += 1) {
340
+ const char = text[index]
341
+ if (char === '\n') line += 1
342
+ if (escaped) { value += char; escaped = false; continue }
343
+ if (char === '\\') { value += char; escaped = true; continue }
344
+ if (char === quote) break
345
+ value += char
346
+ }
347
+ values.push({ value, startLine, startIndex, endIndex: index })
348
+ }
349
+ return values
350
+ }
351
+
352
+ const SIMPLE_TAILWIND_CLASSES = new Set(['flex', 'grid', 'block', 'inline', 'hidden', 'border', 'shadow', 'ring'])
353
+
354
+ function looksLikeUtilityValue(value, token, matchesToken) {
355
+ if (!matchesToken(value, token)) return false
356
+ const parts = value.trim().split(/\s+/).filter(Boolean)
357
+ return parts.length > 1 && parts.every((part) =>
358
+ matchesToken(part, token) || SIMPLE_TAILWIND_CLASSES.has(part) || /[-:[\]/!@.%]/.test(part),
359
+ )
360
+ }
361
+
362
+ function isInsideUtilityHelper(beforeValue) {
363
+ const matches = [...beforeValue.matchAll(/\b(?:cn|cva|clsx|twMerge)\s*\(/g)]
364
+ for (const match of matches.reverse()) {
365
+ const call = beforeValue.slice(match.index)
366
+ let depth = 0
367
+ let quote = null
368
+ let escaped = false
369
+ for (const char of call) {
370
+ if (quote !== null) {
371
+ if (escaped) escaped = false
372
+ else if (char === '\\') escaped = true
373
+ else if (char === quote) quote = null
374
+ continue
375
+ }
376
+ if (char === "'" || char === '"' || char === '`') { quote = char; continue }
377
+ if (char === '(') depth += 1
378
+ else if (char === ')') depth -= 1
379
+ }
380
+ if (depth > 0) return true
381
+ }
382
+ return false
383
+ }
384
+
385
+ export function checkUiMigrations(file, text) {
386
+ const findings = []
387
+ const seen = new Set()
388
+ const matchesToken = (line, token) => {
389
+ const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
390
+ return new RegExp(`(^|[^A-Za-z0-9_-])${escaped}(?=$|[^A-Za-z0-9_-])`).test(line)
391
+ }
392
+ const extension = path.extname(file)
393
+ const source = stripComments(text)
394
+ const addFinding = (token, replacement, line) => {
395
+ seen.add(token)
396
+ findings.push({
397
+ rule: 'removed-ui-token',
398
+ level: 'error',
399
+ action: token,
400
+ line,
401
+ file,
402
+ message: `token removido na 9.0.0 — ${replacement}.`,
403
+ })
404
+ }
405
+ for (const [index, raw] of source.split(/\r?\n/).entries()) {
406
+ const line = raw.trim()
407
+ if (line === '') continue
408
+ for (const [token, replacement] of REMOVED_UI_TOKENS) {
409
+ if (seen.has(token)) continue
410
+ const inStylesheet = extension === '.css' && matchesToken(line, token)
411
+ const inSvelteDirective = extension === '.svelte' && new RegExp(`\\bclass:${token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?=[=\\s]|$)`).test(line)
412
+ if (inStylesheet || inSvelteDirective) addFinding(token, replacement, index + 1)
413
+ }
414
+ }
415
+ for (const { value, startLine, startIndex, endIndex } of quotedValues(source)) {
416
+ const beforeValue = source.slice(Math.max(0, startIndex - 500), startIndex)
417
+ const classAttribute = /\bclass(?:Name)?\s*=\s*\{?\s*$/.test(beforeValue)
418
+ const classExpression = /\bclass(?:Name)?\s*=\s*\{[^{}]*$/.test(beforeValue)
419
+ const utilityHelper = isInsideUtilityHelper(beforeValue)
420
+ const propertyKey = /^\s*:/.test(source.slice(endIndex + 1, endIndex + 12))
421
+ const classBinding = /\b(?:class(?:Name|Names)?|classes|\w+(?:Class|Classes))\s*=\s*[^;\n]*$/.test(beforeValue)
422
+ const objectProperty = /\b(?!aria(?:Label|Description)\b|label\b|title\b|description\b|text\b|message\b|note\b|content\b)[A-Za-z_$][\w$-]*\s*:\s*$/.test(beforeValue)
423
+ const classListCall = /\bclassList\.(?:add|remove|toggle|replace|contains)\s*\([^)]*$/.test(beforeValue)
424
+ const stylePropertyCall = /\b(?:style\.)?setProperty\s*\([^)]*$/.test(beforeValue)
425
+ for (const [token, replacement] of REMOVED_UI_TOKENS) {
426
+ const usedAsUtility = matchesToken(value, token) && (
427
+ classAttribute || classExpression || utilityHelper || classBinding || objectProperty ||
428
+ (classListCall && !token.startsWith('--')) ||
429
+ (stylePropertyCall && token.startsWith('--')) ||
430
+ (propertyKey && token.startsWith('--')) ||
431
+ looksLikeUtilityValue(value, token, matchesToken)
432
+ )
433
+ if (seen.has(token) || !usedAsUtility) continue
434
+ const tokenIndex = value.indexOf(token)
435
+ const tokenLine = startLine + value.slice(0, tokenIndex).split('\n').length - 1
436
+ addFinding(token, replacement, tokenLine)
437
+ }
438
+ }
439
+ return findings.sort((left, right) => left.line - right.line)
440
+ }
441
+
242
442
  const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', '.git', '.next', 'coverage'])
443
+ const UI_SKIP_DIRS = new Set([...SKIP_DIRS, '__tests__', '__fixtures__', 'fixtures', 'test', 'tests'])
444
+ const UI_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.css', '.html', '.svelte', '.vue'])
243
445
 
244
446
  export async function walkTsFiles(dir, acc = []) {
245
447
  let entries
@@ -264,6 +466,25 @@ export async function walkTsFiles(dir, acc = []) {
264
466
  return acc
265
467
  }
266
468
 
469
+ async function walkUiFiles(dir, acc = []) {
470
+ let entries
471
+ try {
472
+ entries = await fs.readdir(dir, { withFileTypes: true })
473
+ } catch {
474
+ return acc
475
+ }
476
+ for (const e of entries) {
477
+ if (e.name.startsWith('.')) continue
478
+ const full = path.join(dir, e.name)
479
+ if (e.isDirectory()) {
480
+ if (!UI_SKIP_DIRS.has(e.name)) await walkUiFiles(full, acc)
481
+ } else if (UI_EXTENSIONS.has(path.extname(e.name)) && !/\.(?:test|spec)\.[^.]+$/.test(e.name)) {
482
+ acc.push(full)
483
+ }
484
+ }
485
+ return acc
486
+ }
487
+
267
488
  async function exists(p) {
268
489
  try {
269
490
  await fs.access(p)
@@ -283,9 +504,22 @@ export async function scanDir(rootDir) {
283
504
  if (!ACTION_MARKERS.some((m) => text.includes(m))) continue
284
505
  sources.push({ file: path.relative(rootDir, file), text })
285
506
  }
286
- const { actions, contracts, findings } = checkProject(sources)
507
+ const checked = checkProject(sources)
508
+ const uiFindings = []
509
+ // UI pode morar em src/, app/, pages/ ou na raiz. O walk cobre o projeto inteiro e
510
+ // exclui dependências, builds, testes e fixtures para não interpretar exemplos como uso.
511
+ for (const file of await walkUiFiles(rootDir)) {
512
+ const text = await fs.readFile(file, 'utf-8')
513
+ uiFindings.push(...checkUiMigrations(path.relative(rootDir, file), text))
514
+ }
287
515
  const hasMarker = await exists(path.join(rootDir, 'opus.json'))
288
- return { files: files.length, actions, contracts, findings, hasMarker }
516
+ return {
517
+ files: files.length,
518
+ actions: checked.actions,
519
+ contracts: checked.contracts,
520
+ findings: [...checked.findings, ...uiFindings],
521
+ hasMarker,
522
+ }
289
523
  }
290
524
 
291
525
  /**
@@ -242,7 +242,13 @@ function configureGit(root, mode, errors, changes) {
242
242
  const absolute = resolve(root, excludePath)
243
243
  if (existsSync(absolute)) {
244
244
  const current = readFileSync(absolute, 'utf8')
245
- const forbidden = new Set(['.claude/', '.claude/skills/', '.agents/', '.agents/skills/'])
245
+ const forbidden = new Set([
246
+ '.claude/',
247
+ '.claude/skills/',
248
+ '.claude/hooks/opus-check-on-stop.mjs',
249
+ '.agents/',
250
+ '.agents/skills/',
251
+ ])
246
252
  const lines = current.split(/\r?\n/)
247
253
  const blocked = lines.filter((line) => forbidden.has(line.trim()))
248
254
  if (blocked.length > 0 && mode === 'check') errors.push(`git info/exclude oculta artefatos de agentes: ${blocked.join(', ')}.`)
@@ -0,0 +1,56 @@
1
+ # Adoção de upgrades pelos consumidores
2
+
3
+ ## Contexto
4
+
5
+ Publicar uma versão do `@softize/opus` não significa que um projeto decidiu adotá-la.
6
+ Da mesma forma, uma worktree pode continuar coerente com seu próprio lockfile mesmo depois
7
+ que a branch principal do projeto adotou uma versão mais nova. Nesse intervalo, `opus check`
8
+ consegue provar a consistência local, mas não consegue concluir sozinho qual versão o projeto
9
+ pretende usar.
10
+
11
+ Há três sinais diferentes e eles não devem ser confundidos:
12
+
13
+ 1. **disponível:** versão publicada no registry;
14
+ 2. **adotada:** versão comitada na branch principal do consumidor;
15
+ 3. **aplicada:** versão presente na worktree e nos artefatos materializados.
16
+
17
+ ## Decisão
18
+
19
+ - A release do Opus publica a versão e seu changelog; não altera nem dispara automações nos
20
+ repositórios consumidores.
21
+ - A branch principal de cada consumidor é a autoridade sobre a versão adotada. Uma versão
22
+ apenas publicada não bloqueia trabalho nem CI.
23
+ - Quando um consumidor decide atualizar, a skill `upgrade-opus` lê o changelog, atualiza
24
+ manifests e lockfile, executa `opus setup` e conduz as migrações no próprio contexto.
25
+ - O Maestro compara a versão aplicada na sessão com a versão adotada pela branch principal e
26
+ apresenta o drift. A correção continua sendo puxar/reconciliar a branch; o Maestro não
27
+ reimplementa o upgrade do pacote.
28
+ - `opus check` mantém detectores de migração para APIs removidas cujo uso possa falhar em
29
+ silêncio. Erros de compilação continuam pertencendo ao typecheck.
30
+
31
+ ## Consequências
32
+
33
+ - Publicação e adoção permanecem desacopladas; cada consumidor atualiza no seu próprio fluxo
34
+ e passa pelos seus gates.
35
+ - Worktrees com trabalho em andamento nunca são sobrescritas por uma release.
36
+ - O diagnóstico funciona sem consultar o registry durante cada commit ou review; isso evita
37
+ transformar disponibilidade em política e mantém os gates reproduzíveis.
38
+ - O conhecimento durável fica no changelog, nos detectores de migração e nas instruções
39
+ materializadas, sem depender de uma infraestrutura externa de automação.
40
+
41
+ ## Alternativas descartadas
42
+
43
+ - **Fazer `opus check` consultar `latest`:** não é determinístico e bloquearia projetos que
44
+ ainda não decidiram adotar a versão publicada.
45
+ - **Atualizar todas as worktrees na publicação:** atravessa mudanças pendentes e pode criar
46
+ conflitos fora da sessão que iniciou a release.
47
+ - **Disparar GitHub Actions nos consumidores:** acopla a publicação à infraestrutura do
48
+ GitHub e concede ao release autoridade para criar branches e PRs em outros projetos.
49
+ - **Deixar apenas Dependabot/Renovate:** atualizar o semver não materializa `base.json`,
50
+ `opus.json`, skills, hooks e instruções que fazem parte do upgrade do Opus.
51
+
52
+ ## Verificação
53
+
54
+ - o status do Maestro deve distinguir versão instalada, aplicada e adotada;
55
+ - fixtures com tokens removidos devem reprovar no detector de migração, enquanto texto de
56
+ documentação e dependências geradas são ignorados.
@@ -0,0 +1,55 @@
1
+ # Escala de elevação
2
+
3
+ > **Status: decidido (ago/2026).** A intensidade da sombra usa a escala do Tailwind;
4
+ > o papel do componente não faz parte do nome do token.
5
+
6
+ ## Contexto
7
+
8
+ A composição original de `shadow-*` do Tailwind era escura demais para a interface do
9
+ Opus. A primeira correção preservou os nomes da escala e clareou seus valores. Depois ela
10
+ foi substituída por tokens de papel (`shadow-card`, `shadow-popover`, `shadow-dialog`).
11
+
12
+ Os tokens de papel acoplaram intensidade e tipo de componente. Na composição de páginas,
13
+ uma superfície que precisava apenas de mais presença acabava recebendo um nome incorreto
14
+ — por exemplo, um card usando `shadow-popover`. O nome deixava de explicar o efeito.
15
+
16
+ ## Decisão
17
+
18
+ - `shadow-2xs` até `shadow-2xl` continuam significando intensidade crescente, como no
19
+ Tailwind.
20
+ - O Opus declara a composição diretamente em `--shadow-*`, mantendo a progressão do
21
+ Tailwind com camadas mais claras, difusas e spread negativo. Sem variável intermediária,
22
+ modifiers nativos como `shadow-xl/30` continuam funcionando.
23
+ - Componentes escolhem a intensidade pelo contexto: `sm` para repouso, `md` para
24
+ flutuantes e `lg` para modais ou destaques fortes. Essa associação é default, não uma
25
+ restrição semântica.
26
+ - A aresta volta a ser `border border-border`; `ring-*` fica reservado para foco e outros
27
+ estados transitórios.
28
+ - Os antigos `--elevation-*`, `shadow-card/popover/dialog` e `ring-edge` são removidos.
29
+
30
+ ## Consequências
31
+
32
+ - Qualquer componente pode subir ou descer um degrau sem adotar o nome de outro papel.
33
+ - A escala global afeta todo uso legítimo de `shadow-*`, inclusive consumidores externos;
34
+ isso é intencional e fica declarado no tema.
35
+ - Forma e cor podem continuar semânticas (`rounded-card`, `bg-popover`), pois não
36
+ representam uma intensidade intercambiável.
37
+
38
+ ## Alternativas descartadas
39
+
40
+ - **Manter tokens por papel:** preserva defaults rígidos, mas repete a ambiguidade que
41
+ motivou esta decisão.
42
+ - **Usar a escala stock do Tailwind:** mantém o vocabulário, mas recupera sombras mais
43
+ escuras que a identidade do Opus.
44
+ - **Criar outra escala (`shadow-opus-sm`):** evita sobrescrever tokens, ao custo de dois
45
+ vocabulários equivalentes para a mesma propriedade.
46
+ - **Manter `ring-edge` como aresta:** não altera a geometria, mas duplica o vocabulário de
47
+ borda e usa outra camada de `box-shadow` para resolver um problema da receita da sombra.
48
+
49
+ ## Verificação
50
+
51
+ - A documentação de tokens demonstra a escala, não papéis.
52
+ - Primitivos elevados usam `border` e não dependem de `ring-edge`.
53
+ - A compilação de `shadow-xl/30` contém o modifier de opacidade aplicado às cores.
54
+ - Typecheck, testes de UI e build dos consumidores verificam que as classes continuam
55
+ materializadas pelo Tailwind.
@@ -21,9 +21,10 @@
21
21
 
22
22
  Sobrescrever um default do Tailwind/shadcn é legítimo quando **declarado** num dos dois
23
23
  registros — o header de DIVERGÊNCIAS do `theme.css` (valores de token) ou o delta do
24
- `registry.lock.json` (componentes ejetados). Nunca sobrescrever a **mecânica** (utilitário
25
- mantém o significado documentado; preferir namespace próprio, como a elevação
26
- `shadow-card/popover/dialog`). O que não está declarado é drift.
24
+ `registry.lock.json` (componentes ejetados). Nunca sobrescrever a **mecânica**: o utilitário
25
+ mantém o significado documentado. A escala de elevação, por exemplo, mantém `sm` → `2xl`
26
+ como intensidade crescente e muda somente a composição visual. O que não está declarado
27
+ é drift.
27
28
 
28
29
  ---
29
30
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "8.9.0",
3
+ "version": "9.0.0",
4
4
  "description": "End-to-end action protocol for TypeScript. Single package with subpath exports (core + adapters).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -186,7 +186,7 @@
186
186
  "access": "public"
187
187
  },
188
188
  "bin": {
189
- "opus": "./bin/cli.mjs"
189
+ "opus": "bin/cli.mjs"
190
190
  },
191
191
  "dependencies": {
192
192
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -11,5 +11,7 @@ artefatos de agentes fica em `base.json`; cada app Opus mantém seu marcador `op
11
11
  `defineContract` com `bindAction` quando cliente e servidor consomem a mesma action.
12
12
  - Não duplicar schemas, tipos de transporte, validação ou fetch que o contrato já fornece.
13
13
  - Rodar `opus check` e os gates do projeto depois de alterar actions, bindings ou versão do SDK.
14
+ - Quando a default branch tiver adotado uma versão diferente da worktree, reconciliar a
15
+ branch antes de commit/review e usar `upgrade-opus` para ler changelog e aplicar migrações.
14
16
  - Consultar as skills Opus materializadas conforme o workflow; não atribuir ao SDK decisões
15
17
  universais de domínio ou arquitetura.
@@ -52,7 +52,7 @@ function AlertDialogContent({
52
52
  <AlertDialogPrimitive.Content
53
53
  data-slot="alert-dialog-content"
54
54
  className={cn(
55
- "group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-xs translate-x-[-50%] translate-y-[-50%] gap-4 rounded-dialog ring-1 ring-edge bg-background p-6 shadow-dialog duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
55
+ "group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-xs translate-x-[-50%] translate-y-[-50%] gap-4 rounded-dialog border border-border bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
56
56
  className
57
57
  )}
58
58
  {...props}
@@ -14,9 +14,26 @@ const buttonGroupVariants = cva(
14
14
  vertical:
15
15
  "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
16
16
  },
17
+ shape: {
18
+ default: '',
19
+ pill: 'rounded-full',
20
+ },
17
21
  },
22
+ compoundVariants: [
23
+ {
24
+ orientation: 'horizontal',
25
+ shape: 'pill',
26
+ className: '[&>*:first-child]:rounded-l-full [&>*:last-child]:rounded-r-full [&>[data-slot=select]:first-child_[data-slot=select-control]]:rounded-l-full [&>[data-slot=select]:last-child_[data-slot=select-control]]:rounded-r-full [&>[data-slot=select-wrapper]:first-child_[data-slot=select]]:rounded-l-full [&>[data-slot=select-wrapper]:last-child_[data-slot=select]]:rounded-r-full',
27
+ },
28
+ {
29
+ orientation: 'vertical',
30
+ shape: 'pill',
31
+ className: '[&>*:first-child]:rounded-t-full [&>*:last-child]:rounded-b-full [&>[data-slot=select]:first-child_[data-slot=select-control]]:rounded-t-full [&>[data-slot=select]:last-child_[data-slot=select-control]]:rounded-b-full [&>[data-slot=select-wrapper]:first-child_[data-slot=select]]:rounded-t-full [&>[data-slot=select-wrapper]:last-child_[data-slot=select]]:rounded-b-full',
32
+ },
33
+ ],
18
34
  defaultVariants: {
19
35
  orientation: "horizontal",
36
+ shape: "default",
20
37
  },
21
38
  }
22
39
  )
@@ -24,6 +41,7 @@ const buttonGroupVariants = cva(
24
41
  function ButtonGroup({
25
42
  className,
26
43
  orientation,
44
+ shape,
27
45
  ...props
28
46
  }: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
29
47
  return (
@@ -31,7 +49,14 @@ function ButtonGroup({
31
49
  role="group"
32
50
  data-slot="button-group"
33
51
  data-orientation={orientation}
34
- className={cn(buttonGroupVariants({ orientation }), className)}
52
+ data-shape={shape}
53
+ className={cn(
54
+ buttonGroupVariants({ orientation, shape }),
55
+ orientation === 'vertical'
56
+ ? '[&>[data-slot=select]:not(:first-child)_[data-slot=select-control]]:rounded-t-none [&>[data-slot=select]:not(:first-child)_[data-slot=select-control]]:border-t-0 [&>[data-slot=select]:not(:last-child)_[data-slot=select-control]]:rounded-b-none [&>[data-slot=select-wrapper]:not(:first-child)_[data-slot=select]]:rounded-t-none [&>[data-slot=select-wrapper]:not(:first-child)_[data-slot=select]]:border-t-0 [&>[data-slot=select-wrapper]:not(:last-child)_[data-slot=select]]:rounded-b-none'
57
+ : '[&>[data-slot=select]:not(:first-child)_[data-slot=select-control]]:rounded-l-none [&>[data-slot=select]:not(:first-child)_[data-slot=select-control]]:border-l-0 [&>[data-slot=select]:not(:last-child)_[data-slot=select-control]]:rounded-r-none [&>[data-slot=select-wrapper]:not(:first-child)_[data-slot=select]]:rounded-l-none [&>[data-slot=select-wrapper]:not(:first-child)_[data-slot=select]]:border-l-0 [&>[data-slot=select-wrapper]:not(:last-child)_[data-slot=select]]:rounded-r-none',
58
+ className,
59
+ )}
35
60
  {...props}
36
61
  />
37
62
  )
@@ -2,6 +2,7 @@ import * as React from 'react'
2
2
  import { Slot } from '@radix-ui/react-slot'
3
3
  import { cva, type VariantProps } from 'class-variance-authority'
4
4
  import { cn } from '../../lib/cn.ts'
5
+ import type { ControlShape } from './control.ts'
5
6
  import { Spinner } from './spinner.tsx'
6
7
 
7
8
  /**
@@ -39,8 +40,12 @@ export const buttonVariants = cva(
39
40
  // do `icon-xs` do InputGroupButton; sem ele, o exemplo virava `className="size-6"`.
40
41
  'icon-xs': 'size-6',
41
42
  },
43
+ shape: {
44
+ default: '',
45
+ pill: 'rounded-full',
46
+ },
42
47
  },
43
- defaultVariants: { variant: 'default', size: 'default' },
48
+ defaultVariants: { variant: 'default', size: 'default', shape: 'default' },
44
49
  },
45
50
  )
46
51
 
@@ -56,12 +61,15 @@ export interface ButtonProps
56
61
  /** Ícone à esquerda do texto (o componente, ex.: `icon={Plus}`). No `busy` é TROCADO
57
62
  * pelo Spinner — não soma (evita ícone + spinner juntos). Ignorado com asChild. */
58
63
  icon?: React.ElementType
64
+ /** Geometria do controle; `pill` não altera cor nem hierarquia da variante. */
65
+ shape?: ControlShape
59
66
  }
60
67
 
61
68
  export function Button({
62
69
  className,
63
70
  variant,
64
71
  size,
72
+ shape,
65
73
  asChild = false,
66
74
  busy = false,
67
75
  icon: Icon,
@@ -76,7 +84,7 @@ export function Button({
76
84
  return (
77
85
  <Comp
78
86
  data-slot="button"
79
- className={cn(buttonVariants({ variant, size }), className)}
87
+ className={cn(buttonVariants({ variant, size, shape }), className)}
80
88
  disabled={disabled || busy}
81
89
  {...props}
82
90
  >
@@ -24,7 +24,7 @@ export function Card({
24
24
  }): React.ReactElement {
25
25
  const Comp = asChild ? Slot : 'div'
26
26
  return (
27
- <Comp data-slot="card" className={cn('rounded-card bg-card text-card-foreground ring-1 ring-edge shadow-card', className)} {...props} />
27
+ <Comp data-slot="card" className={cn('rounded-card border border-border bg-card text-card-foreground shadow-sm', className)} {...props} />
28
28
  )
29
29
  }
30
30
 
@@ -97,7 +97,7 @@ function UserTurn({ content }: { content: string }): React.ReactElement {
97
97
  }, [content, expanded])
98
98
 
99
99
  return (
100
- <div data-slot="chat-user" className="break-words rounded-xl bg-card px-3 py-2 text-sm leading-snug ring-1 ring-edge shadow-card">
100
+ <div data-slot="chat-user" className="break-words rounded-xl border border-border bg-card px-3 py-2 text-sm leading-snug shadow-sm">
101
101
  <div
102
102
  ref={ref}
103
103
  data-slot="chat-user-body"
@@ -155,6 +155,9 @@ export interface ChatProps {
155
155
  /** Seletores discretos na barra do composer (o `actions` do `<Composer>`): agente,
156
156
  * app, escopo… O lugar deles é a barra — `notice` é pra aviso, não pra controle. */
157
157
  composerActions?: React.ReactNode
158
+ /** Ajuste do contêiner externo do composer. O Chat mantém `p-3` por padrão; shells
159
+ * integrados podem alinhar esse inset aos painéis vizinhos sem alcançar o DOM interno. */
160
+ composerClassName?: string
158
161
  /** Texto do estado vazio (centrado, some quando a conversa começa). Não entra no
159
162
  * transcript — é apresentação, não fala do assistente. */
160
163
  greeting?: string
@@ -212,6 +215,7 @@ export function Chat({
212
215
  activity: activityProp,
213
216
  notice,
214
217
  composerActions,
218
+ composerClassName,
215
219
  greeting,
216
220
  initialMessages,
217
221
  kickoff,
@@ -391,7 +395,7 @@ export function Chat({
391
395
  {/* Composer da casa (pílula elevada, enviar dentro). Extraído no <Composer> — o Chat
392
396
  liga texto/envio e repassa `composerActions` pros seletores da conversa (agente,
393
397
  app…); o <Composer> sozinho segue sendo o caminho de quem não tem chat. */}
394
- <div className="shrink-0 p-3">
398
+ <div data-slot="chat-composer" className={cn('shrink-0 p-3', composerClassName)}>
395
399
  {notice}
396
400
  <Composer
397
401
  value={input}