@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.
- package/CHANGELOG.md +47 -0
- package/bin/cli.mjs +3 -3
- package/bin/lib/check.mjs +236 -2
- package/bin/lib/create.mjs +2 -2
- package/bin/lib/materialize.mjs +0 -1
- package/bin/lib/postinstall.mjs +1 -1
- package/docs/consumer-upgrade-propagation.md +56 -0
- package/docs/elevation-scale.md +55 -0
- package/docs/ownership-vs-shadcn-lock.md +4 -3
- package/package.json +1 -1
- package/registry/instructions/opus.md +2 -0
- package/src/ui/components/primitives/alert-dialog.tsx +1 -1
- package/src/ui/components/primitives/button-group.tsx +26 -1
- package/src/ui/components/primitives/button.tsx +10 -2
- package/src/ui/components/primitives/card.tsx +1 -1
- package/src/ui/components/primitives/chat.tsx +6 -2
- package/src/ui/components/primitives/composer.tsx +3 -2
- package/src/ui/components/primitives/control.ts +2 -0
- package/src/ui/components/primitives/dialog.tsx +1 -1
- package/src/ui/components/primitives/drawer.tsx +1 -1
- package/src/ui/components/primitives/input-group.tsx +4 -2
- package/src/ui/components/primitives/menu.tsx +2 -2
- package/src/ui/components/primitives/popover.tsx +1 -1
- package/src/ui/components/primitives/select.tsx +27 -9
- package/src/ui/components/primitives/tabs.tsx +6 -5
- package/src/ui/components/primitives/toggle-group.tsx +13 -2
- package/src/ui/components/primitives/toggle.tsx +8 -1
- package/src/ui/docs/content/card.md +2 -2
- package/src/ui/docs/content/composer.md +1 -1
- package/src/ui/docs/content/tokens.md +18 -18
- package/src/ui/meta.ts +5 -5
- package/src/ui/react.tsx +1 -0
- package/src/ui/theme.css +16 -27
- 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}
|
|
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
|
|
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
|
|
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 {
|
|
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
|
/**
|
package/bin/lib/create.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
}
|
package/bin/lib/materialize.mjs
CHANGED
|
@@ -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) })
|
package/bin/lib/postinstall.mjs
CHANGED
|
@@ -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`
|
|
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
|
|
25
|
-
mantém o significado documentado
|
|
26
|
-
|
|
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
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|