@softize/opus 8.9.1 → 9.0.1

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.
Files changed (34) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/bin/cli.mjs +3 -3
  3. package/bin/lib/check.mjs +236 -2
  4. package/bin/lib/create.mjs +2 -2
  5. package/bin/lib/materialize.mjs +0 -1
  6. package/bin/lib/postinstall.mjs +1 -1
  7. package/docs/consumer-upgrade-propagation.md +56 -0
  8. package/docs/elevation-scale.md +55 -0
  9. package/docs/ownership-vs-shadcn-lock.md +4 -3
  10. package/package.json +1 -1
  11. package/registry/instructions/opus.md +2 -0
  12. package/src/ui/components/primitives/alert-dialog.tsx +1 -1
  13. package/src/ui/components/primitives/button-group.tsx +26 -1
  14. package/src/ui/components/primitives/button.tsx +10 -2
  15. package/src/ui/components/primitives/card.tsx +1 -1
  16. package/src/ui/components/primitives/chat.tsx +6 -2
  17. package/src/ui/components/primitives/composer.tsx +3 -2
  18. package/src/ui/components/primitives/control.ts +2 -0
  19. package/src/ui/components/primitives/dialog.tsx +1 -1
  20. package/src/ui/components/primitives/drawer.tsx +1 -1
  21. package/src/ui/components/primitives/input-group.tsx +4 -2
  22. package/src/ui/components/primitives/menu.tsx +2 -2
  23. package/src/ui/components/primitives/popover.tsx +1 -1
  24. package/src/ui/components/primitives/select.tsx +27 -9
  25. package/src/ui/components/primitives/tabs.tsx +6 -5
  26. package/src/ui/components/primitives/toggle-group.tsx +13 -2
  27. package/src/ui/components/primitives/toggle.tsx +8 -1
  28. package/src/ui/docs/content/card.md +2 -2
  29. package/src/ui/docs/content/composer.md +1 -1
  30. package/src/ui/docs/content/tokens.md +18 -18
  31. package/src/ui/meta.ts +5 -5
  32. package/src/ui/react.tsx +1 -0
  33. package/src/ui/theme.css +16 -27
  34. package/registry/github/opus.yml +0 -22
package/CHANGELOG.md CHANGED
@@ -11,6 +11,53 @@ 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.1 — 2026-08-12
15
+
16
+ **A garantia de materialização volta a ser local.** `opus setup` deixa de criar o
17
+ workflow `.github/workflows/opus.yml`; o hook composto de pre-push e `opus check`
18
+ continuam sendo os gates reproduzíveis do pacote. Ao atualizar, o setup remove o workflow
19
+ anterior quando ele ainda está íntegro e gerenciado pelo Opus, preservando qualquer edição
20
+ local para revisão explícita.
21
+
22
+ ## 9.0.0 — 2026-08-12
23
+
24
+ **Chrome denso passa a compartilhar a mesma régua visual.** Três primitives deixam de
25
+ depender de coincidências do call site:
26
+
27
+ - `TabsList` volta a aceitar uma altura fornecida por `className`; no variant `line`, o
28
+ indicador ancora na borda da própria lista, inclusive quando ela preenche uma faixa mais
29
+ alta que o size padrão.
30
+ - `Select variant="ghost"` abraça ícone, rótulo e chevron como um botão de toolbar. O
31
+ rótulo ainda pode encolher e truncar, mas não cresce para empurrar o chevron à borda;
32
+ agora ele também respeita a régua `default` (36px) / `sm` (32px).
33
+ - `Button`, `Select`, `Toggle`, `ToggleGroup`, `InputGroup` e `ButtonGroup` aceitam
34
+ `shape="pill"`. Forma fica separada de intenção (`variant`) e altura (`size`), e grupos
35
+ preservam o raio somente nas extremidades externas — inclusive quando contêm `Select`.
36
+ - o botão de envio do `Composer` usa a geometria pill do próprio `Button`.
37
+ - `Select variant="outline"` oferece a borda de um controle sem o `min-width` de campo:
38
+ ícone, rótulo e chevron ficam juntos em uma única linha em toolbars e composers; rótulos
39
+ longos truncam em vez de empurrar o chevron para uma segunda linha.
40
+ - superfícies elevadas voltam a usar `border border-border`; `ring-edge` e seu token são
41
+ removidos, deixando `ring-*` para foco e estados transitórios.
42
+ - a elevação volta ao vocabulário de intensidade do Tailwind (`shadow-sm`, `shadow-md`,
43
+ `shadow-lg` etc.), com a composição clara e difusa controlada pelo tema do Opus.
44
+ `Card`/`Composer`, menus/popovers e modais usam respectivamente `sm`, `md` e `lg` como
45
+ defaults; os aliases por papel e as variáveis intermediárias `--elevation-*` saem.
46
+ - `shadow-lg` ganha spread negativo e menos opacidade: mantém alcance, mas deixa de
47
+ engrossar visualmente a borda no ponto de contato com a superfície.
48
+ - toda a escala passa a declarar a receita diretamente em `--shadow-*`, preservando a
49
+ API nativa de modifiers do Tailwind, como `shadow-xl/30`.
50
+
51
+ **Migração:** os tokens públicos de elevação por papel foram removidos porque forma e
52
+ intensidade agora são ortogonais. Troque `shadow-card/popover/dialog` por
53
+ `shadow-sm/md/lg`; troque `ring-1 ring-edge` por `border border-border` e `border-edge`
54
+ por `border-border`. Overrides de `--elevation-*` devem migrar para o `--shadow-*`
55
+ correspondente. `opus check` acusa esses usos, que de outra forma perderiam o efeito
56
+ visual sem erro de compilação.
57
+
58
+ Também remova overrides locais de altura, posição do indicador e largura mínima que
59
+ existiam apenas para compensar os defeitos corrigidos nesta versão.
60
+
14
61
  ## 8.9.1 — 2026-08-04
15
62
 
16
63
  **A migração deixa de esconder o hook Opus no Git.** `opus setup` agora remove também a
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
 
@@ -454,7 +454,7 @@ Depois: git init && pnpm install && pnpm test — e cadastre repo/app no admin.
454
454
  Setup idempotente de um projeto Opus:
455
455
  opus.json marcador per-app com a versão do SDK
456
456
  base.json versão aplicada e eventuais skills excluídas
457
- agentes skills Claude/Codex, hook, instruções, pre-push e CI comitados
457
+ agentes skills Claude/Codex, hook, instruções e pre-push comitados
458
458
 
459
459
  Fundação de UI (só apps web — preset Tailwind + tema + flags do Opus):
460
460
  tailwind.config criado se faltar; senão avisa pra estender o preset
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
  /**
@@ -18,7 +18,7 @@
18
18
  * • tokens `{{APP_NAME}}` e `{{OPUS_VERSION}}` são substituídos em todo arquivo.
19
19
  *
20
20
  * Depois da cópia roda o `initProject` (setup): opus.json e artefatos Opus rastreáveis,
21
- * `.claude/memory/` e o CI de fábrica (repo-level: acha a raiz pelo `.git`).
21
+ * `.claude/memory/` e os gates locais (repo-level: acha a raiz pelo `.git`).
22
22
  */
23
23
 
24
24
  import { promises as fs } from 'node:fs'
@@ -202,7 +202,7 @@ export async function createProject(registryDir, targetDir, appName) {
202
202
  const warnings = mode === 'app' ? await workspaceWarnings(wsRoot, path.resolve(targetDir)) : []
203
203
 
204
204
  // Setup por cima: opus.json per-app e materialização repo-level. Em monorepo, a raiz
205
- // é descoberta pelo Git e recebe skills, hooks, instruções e CI específicos do Opus.
205
+ // é descoberta pelo Git e recebe skills, hooks, instruções e pre-push específicos do Opus.
206
206
  const setup = await initProject(registryDir, targetDir)
207
207
  return { version, mode, files, warnings, setup }
208
208
  }
@@ -109,7 +109,6 @@ function expectedFiles(exclude) {
109
109
  for (const [source, destination] of [
110
110
  ['registry/hooks/opus-check-on-stop.mjs', '.claude/hooks/opus-check-on-stop.mjs'],
111
111
  ['registry/git/pre-push.d/opus', '.githooks/pre-push.d/opus'],
112
- ['registry/github/opus.yml', '.github/workflows/opus.yml'],
113
112
  ]) {
114
113
  const content = readFileSync(join(PACKAGE_ROOT, source), 'utf8')
115
114
  expected.set(destination, { source, content, output: addMarker(destination, source, content) })
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * postinstall do @softize/opus — tenta rodar o setup no consumidor. É conveniência
4
- * best-effort; a garantia vem de `opus setup` explícito + `opus check` no CI.
4
+ * best-effort; a garantia vem de `opus setup` explícito + `opus check` nos gates locais.
5
5
  *
6
6
  * Blindagem (best-effort, NUNCA falha o install):
7
7
  * • OPUS_SKIP_INIT=1 → pula
@@ -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.1",
3
+ "version": "9.0.1",
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",
@@ -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