@softize/opus 12.0.1 → 12.2.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
@@ -7,6 +7,29 @@ Depois de qualquer bump, rode os gates (`typecheck` · `test` · `opus check` ·
7
7
  `opus copy --check` · `base copy check` · `manifest:check`) — eles apontam o que a
8
8
  mudança cobra do seu código.
9
9
 
10
+ ## 12.2.0 — 2026-08-23
11
+
12
+ A escala de arredondamento ganha uma base mais presente sem transformar todo componente em uma
13
+ superfície principal. `--radius` passa de `0.625rem` para `0.75rem`; os degraus de
14
+ `rounded-xs` a `rounded-2xl` derivam dessa base e permanecem crescentes. Cards e modais continuam
15
+ em `rounded-xl`, enquanto controles compactos e molduras estruturais preservam seus degraus.
16
+
17
+ A mudança é visual e não exige migração de código. Aplicações que já sobrescrevem `--radius`
18
+ mantêm sua própria identidade; as demais recebem automaticamente a nova escala. A documentação e
19
+ a skill de UI explicam aninhamento, grupos conectados e a separação entre tamanho e forma.
20
+
21
+ ## 12.1.0 — 2026-08-22
22
+
23
+ Interfaces que combinam copy fixa com nomes, contagens ou opções vindas da aplicação não
24
+ precisam mais excluir o arquivo inteiro do inventário. `base.json.copy.dynamic` classifica um
25
+ diagnóstico exato como `runtime-data` ou `message-template`, com motivo e referência
26
+ auditáveis. Dados de runtime ficam fora da política editorial; o texto-modelo de mensagens
27
+ interpoladas entra no inventário com seu papel semântico, sem alterar o conteúdo renderizado.
28
+
29
+ Cada declaração precisa corresponder a um único caminho, linha e campo. Quando o código muda
30
+ e o seletor fica órfão ou ambíguo, `opus copy` reprova e orienta a revisão. O inventário expõe
31
+ essas classificações em `dynamicSurfaces`, e o resumo do CLI informa sua quantidade.
32
+
10
33
  ## 12.0.1 — 2026-08-22
11
34
 
12
35
  `Split` volta a atender layouts que precisam preservar uma medida física e a preferência da
package/bin/cli.mjs CHANGED
@@ -254,7 +254,8 @@ async function cmdCopy(dir, flags) {
254
254
  const coverage = result.inventory.coverage
255
255
  const summary =
256
256
  `${coverage.analyzedFiles} arquivo(s) analisado(s), ` +
257
- `${coverage.coveredSources} fonte(s) com copy, ${coverage.extractedEntries} texto(s)`
257
+ `${coverage.coveredSources} fonte(s) com copy, ${coverage.extractedEntries} texto(s), ` +
258
+ `${result.inventory.dynamicSurfaces.length} superfície(s) dinâmica(s) declarada(s)`
258
259
  if (flags.check) log('success', `✓ opus copy: ${location} está atualizado (${summary}).`)
259
260
  else if (result.changed) log('success', `✓ opus copy: ${location} gerado atomicamente (${summary}).`)
260
261
  else log('success', `✓ opus copy: ${location} já estava atualizado (${summary}).`)
package/bin/lib/copy.mjs CHANGED
@@ -52,6 +52,13 @@ const IGNORED_FILE = /(?:\.d|\.spec|\.test|\.stories)\.[cm]?[jt]sx?$/u
52
52
  // O Opus garante somente o namespace e o shape do identificador. O catálogo de regras,
53
53
  // inclusive adições futuras, pertence à versão instalada da Base.
54
54
  const COPY_RULE_ID = /^COPY\d{3}$/u
55
+ const COPY_DYNAMIC_KINDS = new Set(['message-template', 'runtime-data'])
56
+ const COPY_ROLES = new Set([
57
+ 'badge', 'breadcrumb', 'button', 'description', 'dialog-body', 'empty-state', 'error',
58
+ 'heading', 'helper-text', 'kicker', 'label', 'menu-item', 'message', 'notification',
59
+ 'placeholder', 'success', 'tab', 'title', 'warning',
60
+ ])
61
+ const AUDIT_REFERENCE = /^(?:https?:\/\/\S+|[a-z][a-z0-9-]*:\S+|(?:[A-Za-z0-9._-]+\/)+[A-Za-z0-9._#/-]+|[A-Za-z0-9._-]+\.(?:md|json|ya?ml)(?:#[^\s]+)?)$/u
55
62
  const CONTRACT_MODULES = new Set(['@softize/opus', '@softize/opus/core'])
56
63
  const CONTRACT_FACTORIES = new Set(['defineAction', 'defineContract'])
57
64
  const ACTION_BINDING_KEYS = new Set(['authorize', 'background', 'emits', 'handler', 'idempotency', 'loads'])
@@ -839,10 +846,12 @@ function lineOf(sourceFile, node) {
839
846
  return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1
840
847
  }
841
848
 
842
- function diagnostic(file, sourceFile, node, field) {
849
+ function diagnostic(file, sourceFile, node, field, category = 'content') {
843
850
  return {
844
851
  source: file,
845
852
  line: lineOf(sourceFile, node),
853
+ field,
854
+ category,
846
855
  message:
847
856
  `não foi possível extrair \`${field}\` estaticamente; use texto literal, concatenação ` +
848
857
  'de literais, constante local ou I18nRef com `default` literal.',
@@ -929,13 +938,13 @@ export function extractCopyFromSource(file, sourceText) {
929
938
  const optionItems = (object, prefix) => {
930
939
  const kind = property(object, 'kind', sourceFile, checker)
931
940
  if (kind.opaque !== undefined) {
932
- diagnostics.push(diagnostic(file, sourceFile, kind.opaque, `${prefix}kind`))
941
+ diagnostics.push(diagnostic(file, sourceFile, kind.opaque, `${prefix}kind`, 'structure'))
933
942
  return
934
943
  }
935
944
  if (kind.candidate === undefined) return
936
945
  const kindText = staticText(propertyValue(kind.candidate), checker)?.text
937
946
  if (kindText === undefined) {
938
- diagnostics.push(diagnostic(file, sourceFile, propertyValue(kind.candidate), `${prefix}kind`))
947
+ diagnostics.push(diagnostic(file, sourceFile, propertyValue(kind.candidate), `${prefix}kind`, 'structure'))
939
948
  return
940
949
  }
941
950
  if (kindText !== 'static') return
@@ -1075,7 +1084,7 @@ export function extractCopyFromSource(file, sourceText) {
1075
1084
  const key = `${node.pos}:${node.end}:${field}`
1076
1085
  if (transformDiagnostics.has(key)) return
1077
1086
  transformDiagnostics.add(key)
1078
- diagnostics.push(diagnostic(file, sourceFile, node, field))
1087
+ diagnostics.push(diagnostic(file, sourceFile, node, field, 'transform'))
1079
1088
  }
1080
1089
 
1081
1090
  function tailwindTransform(token) {
@@ -1502,13 +1511,13 @@ export function extractCopyFromSource(file, sourceText) {
1502
1511
  if (component !== 'Select') return null
1503
1512
  const native = attribute(opening.attributes, 'native')
1504
1513
  if (native.opaque !== undefined) {
1505
- diagnostics.push(diagnostic(file, sourceFile, native.opaque, '<Select>.native'))
1514
+ diagnostics.push(diagnostic(file, sourceFile, native.opaque, '<Select>.native', 'structure'))
1506
1515
  return 'opaque'
1507
1516
  }
1508
1517
  if (native.candidate === undefined) return 'custom'
1509
1518
  const value = attributeBoolean(native)
1510
1519
  if (value === null) {
1511
- diagnostics.push(diagnostic(file, sourceFile, attributeValue(native) ?? opening, '<Select>.native'))
1520
+ diagnostics.push(diagnostic(file, sourceFile, attributeValue(native) ?? opening, '<Select>.native', 'structure'))
1512
1521
  return 'opaque'
1513
1522
  }
1514
1523
  return value ? 'native' : 'custom'
@@ -1547,7 +1556,15 @@ export function extractCopyFromSource(file, sourceText) {
1547
1556
  if (slot.opaque !== undefined) diagnostics.push(diagnostic(file, sourceFile, slot.opaque, `<${component}>.${name}`))
1548
1557
  else if (slot.candidate !== undefined) {
1549
1558
  const raw = attributeValue(slot)
1550
- if (raw === null || !noRenderedValue(raw)) diagnostics.push(diagnostic(file, sourceFile, raw ?? opening, `<${component}>.${name}`))
1559
+ if (raw === null || !noRenderedValue(raw)) {
1560
+ diagnostics.push(diagnostic(
1561
+ file,
1562
+ sourceFile,
1563
+ raw ?? opening,
1564
+ `<${component}>.${name}`,
1565
+ raw === null ? 'missing-content' : 'content',
1566
+ ))
1567
+ }
1551
1568
  }
1552
1569
  }
1553
1570
 
@@ -1594,8 +1611,16 @@ export function extractCopyFromSource(file, sourceText) {
1594
1611
  }
1595
1612
  }
1596
1613
  } else {
1597
- const value = attributeText(item)
1598
- if (value === null) diagnostics.push(diagnostic(file, sourceFile, raw ?? opening, `<${component}>.${name}`))
1614
+ const value = raw === null ? null : attributeText(item)
1615
+ if (value === null) {
1616
+ diagnostics.push(diagnostic(
1617
+ file,
1618
+ sourceFile,
1619
+ raw ?? opening,
1620
+ `<${component}>.${name}`,
1621
+ raw === null ? 'missing-content' : 'content',
1622
+ ))
1623
+ }
1599
1624
  else {
1600
1625
  const entry = { source: file, line: lineOf(sourceFile, value.node), role, text: value.text }
1601
1626
  if (transformed.uppercase) entry.transform = 'uppercase'
@@ -1613,7 +1638,7 @@ export function extractCopyFromSource(file, sourceText) {
1613
1638
  inheritedFor('className', 'style'),
1614
1639
  )
1615
1640
  if (transformed.uppercase) {
1616
- diagnostics.push(diagnostic(file, sourceFile, opening, '<ActionTrigger>.label herdado do contrato sob uppercase'))
1641
+ diagnostics.push(diagnostic(file, sourceFile, opening, '<ActionTrigger>.label herdado do contrato sob uppercase', 'transform'))
1617
1642
  }
1618
1643
  }
1619
1644
  }
@@ -1625,7 +1650,15 @@ export function extractCopyFromSource(file, sourceText) {
1625
1650
  const raw = attributeValue(item)
1626
1651
  const object = raw === null ? null : resolveBinding(raw, checker)
1627
1652
  if (object?.kind === ts.SyntaxKind.NullKeyword || (ts.isIdentifier(object) && object.text === 'undefined')) continue
1628
- if (!ts.isObjectLiteralExpression(object)) diagnostics.push(diagnostic(file, sourceFile, raw ?? opening, `<${component}>.${name}`))
1653
+ if (!ts.isObjectLiteralExpression(object)) {
1654
+ diagnostics.push(diagnostic(
1655
+ file,
1656
+ sourceFile,
1657
+ raw ?? opening,
1658
+ `<${component}>.${name}`,
1659
+ raw === null ? 'structure' : 'content',
1660
+ ))
1661
+ }
1629
1662
  else for (const [nestedName, role] of nestedRoles) addProperty(object, nestedName, role, `<${component}>.${name}.`)
1630
1663
  }
1631
1664
  }
@@ -1636,7 +1669,15 @@ export function extractCopyFromSource(file, sourceText) {
1636
1669
  else if (item.candidate !== undefined) {
1637
1670
  const raw = attributeValue(item)
1638
1671
  const listed = raw === null ? { values: [], opaque: [opening] } : objects(raw, checker)
1639
- for (const opaque of listed.opaque) diagnostics.push(diagnostic(file, sourceFile, opaque, `<${component}>.${name}`))
1672
+ for (const opaque of listed.opaque) {
1673
+ diagnostics.push(diagnostic(
1674
+ file,
1675
+ sourceFile,
1676
+ opaque,
1677
+ `<${component}>.${name}`,
1678
+ raw === null ? 'structure' : 'content',
1679
+ ))
1680
+ }
1640
1681
  for (const [index, option] of listed.values.entries()) {
1641
1682
  for (const [optionName, role] of optionRoles) {
1642
1683
  if (mode === 'native' && !['label', 'group'].includes(optionName)) continue
@@ -1668,8 +1709,17 @@ export function extractCopyFromSource(file, sourceText) {
1668
1709
  if (ariaLabelAttribute.opaque !== undefined) {
1669
1710
  diagnostics.push(diagnostic(file, sourceFile, ariaLabelAttribute.opaque, `<${component}>.aria-label`))
1670
1711
  } else if (ariaLabelAttribute.candidate !== undefined) {
1671
- const ariaLabel = attributeText(ariaLabelAttribute)
1672
- if (ariaLabel === null) diagnostics.push(diagnostic(file, sourceFile, attributeValue(ariaLabelAttribute) ?? opening, `<${component}>.aria-label`))
1712
+ const raw = attributeValue(ariaLabelAttribute)
1713
+ const ariaLabel = raw === null ? null : attributeText(ariaLabelAttribute)
1714
+ if (ariaLabel === null) {
1715
+ diagnostics.push(diagnostic(
1716
+ file,
1717
+ sourceFile,
1718
+ raw ?? opening,
1719
+ `<${component}>.aria-label`,
1720
+ raw === null ? 'missing-content' : 'content',
1721
+ ))
1722
+ }
1673
1723
  if (ariaLabel !== null) {
1674
1724
  hasStaticAriaLabel = true
1675
1725
  entries.push({ source: file, line: lineOf(sourceFile, ariaLabel.node), role: 'label', text: ariaLabel.text })
@@ -1692,7 +1742,7 @@ export function extractCopyFromSource(file, sourceText) {
1692
1742
  if (children.opaque !== undefined) return { kind: 'opaque', node: children.opaque }
1693
1743
  if (children.candidate === undefined) return { kind: 'decorative' }
1694
1744
  const raw = attributeValue(children)
1695
- return raw === null ? { kind: 'opaque', node: opening } : childExpression(raw, inheritedUppercase)
1745
+ return raw === null ? { kind: 'decorative' } : childExpression(raw, inheritedUppercase)
1696
1746
  }
1697
1747
  const inheritedUppercase = component === 'ActionFormDialog'
1698
1748
  ? false
@@ -1710,7 +1760,9 @@ export function extractCopyFromSource(file, sourceText) {
1710
1760
  }
1711
1761
  if (value.kind === 'opaque') diagnostics.push(diagnostic(file, sourceFile, value.node, `<${component}> children`))
1712
1762
  else if (value.kind === 'decorative') {
1713
- if (!hasStaticAriaLabel && !OPTIONAL_CHILD_COMPONENTS.has(component)) diagnostics.push(diagnostic(file, sourceFile, element, `<${component}> children`))
1763
+ if (!hasStaticAriaLabel && !OPTIONAL_CHILD_COMPONENTS.has(component)) {
1764
+ diagnostics.push(diagnostic(file, sourceFile, element, `<${component}> children`, 'missing-content'))
1765
+ }
1714
1766
  } else {
1715
1767
  const texts = value.kind === 'text' ? [value] : value.values
1716
1768
  for (const text of texts) {
@@ -1727,11 +1779,11 @@ export function extractCopyFromSource(file, sourceText) {
1727
1779
  if (ts.isCallExpression(node) && factory !== null && node.arguments.length > 0) {
1728
1780
  hasContract = true
1729
1781
  if (!stableFactoryResult(node, checker, new Set())) {
1730
- diagnostics.push(diagnostic(file, sourceFile, node, `${factory}(...) result`))
1782
+ diagnostics.push(diagnostic(file, sourceFile, node, `${factory}(...) result`, 'structure'))
1731
1783
  } else {
1732
1784
  const object = resolveBinding(node.arguments[0], checker)
1733
1785
  if (ts.isObjectLiteralExpression(object)) extractContract(object)
1734
- else diagnostics.push(diagnostic(file, sourceFile, node.arguments[0], `${factory}(...)`))
1786
+ else diagnostics.push(diagnostic(file, sourceFile, node.arguments[0], `${factory}(...)`, 'structure'))
1735
1787
  }
1736
1788
  }
1737
1789
  if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) extractJsx(node)
@@ -1834,6 +1886,7 @@ function copyConfiguration(root) {
1834
1886
  inventoryPath,
1835
1887
  exemptions: [],
1836
1888
  transforms: [],
1889
+ dynamic: [],
1837
1890
  exclude: [],
1838
1891
  }
1839
1892
  }
@@ -1854,6 +1907,7 @@ function copyConfiguration(root) {
1854
1907
  inventoryPath: target,
1855
1908
  exemptions: config?.exemptions ?? [],
1856
1909
  transforms: config?.transforms ?? [],
1910
+ dynamic: config?.dynamic ?? [],
1857
1911
  exclude: config?.exclude ?? [],
1858
1912
  }
1859
1913
  }
@@ -1914,7 +1968,118 @@ function findEntry(entries, selector) {
1914
1968
  )
1915
1969
  }
1916
1970
 
1971
+ function canonicalSource(value) {
1972
+ return (
1973
+ typeof value === 'string' &&
1974
+ value !== '' &&
1975
+ value.trim() === value &&
1976
+ value === value.normalize('NFC') &&
1977
+ !value.includes('\\') &&
1978
+ !path.posix.isAbsolute(value) &&
1979
+ !path.win32.isAbsolute(value) &&
1980
+ !/^[A-Za-z]:/u.test(value) &&
1981
+ value !== '.' &&
1982
+ value !== '..' &&
1983
+ path.posix.normalize(value) === value &&
1984
+ value.split('/').every((part) => part !== '' && part !== '.' && part !== '..')
1985
+ )
1986
+ }
1987
+
1988
+ function applyDynamicMetadata(config, entries, diagnostics) {
1989
+ const surfaces = []
1990
+ if (!Array.isArray(config.dynamic)) {
1991
+ diagnostics.push(selectorDiagnostic('copy.dynamic deve ser uma lista.'))
1992
+ return surfaces
1993
+ }
1994
+ const consumed = new Set()
1995
+ for (const [index, declaration] of config.dynamic.entries()) {
1996
+ const template = declaration?.kind === 'message-template'
1997
+ const allowedFields = new Set([
1998
+ 'source', 'line', 'field', 'kind', 'reason', 'reference',
1999
+ ...(template ? ['role', 'text'] : []),
2000
+ ])
2001
+ const normalizedReason = typeof declaration?.reason === 'string' ? declaration.reason.trim() : ''
2002
+ const normalizedReference = typeof declaration?.reference === 'string' ? declaration.reference.trim() : ''
2003
+ if (
2004
+ declaration === null ||
2005
+ typeof declaration !== 'object' ||
2006
+ Array.isArray(declaration) ||
2007
+ Object.keys(declaration).some((field) => !allowedFields.has(field)) ||
2008
+ !canonicalSource(declaration.source) ||
2009
+ !Number.isInteger(declaration.line) ||
2010
+ declaration.line < 1 ||
2011
+ typeof declaration.field !== 'string' ||
2012
+ declaration.field.trim() === '' ||
2013
+ !COPY_DYNAMIC_KINDS.has(declaration.kind) ||
2014
+ typeof declaration.reason !== 'string' ||
2015
+ normalizedReason.length < 12 ||
2016
+ normalizedReason.length > 500 ||
2017
+ /[\r\n]/u.test(declaration.reason) ||
2018
+ typeof declaration.reference !== 'string' ||
2019
+ normalizedReference.length < 8 ||
2020
+ normalizedReference.length > 500 ||
2021
+ !AUDIT_REFERENCE.test(normalizedReference) ||
2022
+ (template && (
2023
+ typeof declaration.text !== 'string' ||
2024
+ declaration.text.trim() === '' ||
2025
+ declaration.text !== declaration.text.trim() ||
2026
+ /[\r\n]/u.test(declaration.text) ||
2027
+ !COPY_ROLES.has(declaration.role)
2028
+ )) ||
2029
+ (!template && (declaration.text !== undefined || declaration.role !== undefined))
2030
+ ) {
2031
+ diagnostics.push(selectorDiagnostic(
2032
+ `copy.dynamic[${index}] exige seletor, kind, reason e reference válidos` +
2033
+ (template ? ', além de text e role para message-template.' : '.'),
2034
+ ))
2035
+ continue
2036
+ }
2037
+ const matches = diagnostics
2038
+ .map((item, diagnosticIndex) => ({ item, diagnosticIndex }))
2039
+ .filter(({ item, diagnosticIndex }) =>
2040
+ !consumed.has(diagnosticIndex) &&
2041
+ item.category === 'content' &&
2042
+ item.source === declaration.source &&
2043
+ item.line === declaration.line &&
2044
+ item.field === declaration.field,
2045
+ )
2046
+ if (matches.length !== 1) {
2047
+ diagnostics.push(selectorDiagnostic(
2048
+ `copy.dynamic[${index}] deve selecionar exatamente um diagnóstico de extração; encontrou ${matches.length}.`,
2049
+ ))
2050
+ continue
2051
+ }
2052
+ consumed.add(matches[0].diagnosticIndex)
2053
+ const surface = {
2054
+ source: declaration.source,
2055
+ line: declaration.line,
2056
+ field: declaration.field,
2057
+ kind: declaration.kind,
2058
+ reason: normalizedReason,
2059
+ reference: normalizedReference,
2060
+ }
2061
+ if (template) {
2062
+ surface.role = declaration.role
2063
+ surface.text = declaration.text
2064
+ entries.push({
2065
+ source: declaration.source,
2066
+ line: declaration.line,
2067
+ role: declaration.role,
2068
+ text: declaration.text,
2069
+ dynamic: { kind: declaration.kind, field: declaration.field, reference: normalizedReference },
2070
+ })
2071
+ }
2072
+ surfaces.push(surface)
2073
+ }
2074
+ if (consumed.size > 0) {
2075
+ const remaining = diagnostics.filter((_, index) => !consumed.has(index))
2076
+ diagnostics.splice(0, diagnostics.length, ...remaining)
2077
+ }
2078
+ return surfaces
2079
+ }
2080
+
1917
2081
  function applyProjectMetadata(config, entries, diagnostics) {
2082
+ const dynamicSurfaces = applyDynamicMetadata(config, entries, diagnostics)
1918
2083
  if (!Array.isArray(config.exemptions)) {
1919
2084
  diagnostics.push(selectorDiagnostic('copy.exemptions deve ser uma lista.'))
1920
2085
  } else {
@@ -1993,6 +2158,7 @@ function applyProjectMetadata(config, entries, diagnostics) {
1993
2158
  entries[entryIndex].transform = transform.transform
1994
2159
  }
1995
2160
  }
2161
+ return dynamicSurfaces
1996
2162
  }
1997
2163
 
1998
2164
  export function extractCopyInventory(root) {
@@ -2050,7 +2216,7 @@ export function extractCopyInventory(root) {
2050
2216
  'nenhum arquivo JavaScript/TypeScript elegível foi analisado; o inventário v2 exige ao menos uma fonte rastreável.',
2051
2217
  ))
2052
2218
  }
2053
- applyProjectMetadata(config, entries, diagnostics)
2219
+ const dynamicSurfaces = applyProjectMetadata(config, entries, diagnostics)
2054
2220
 
2055
2221
  const coveredSources = new Set(entries.map((entry) => entry.source)).size
2056
2222
 
@@ -2066,6 +2232,7 @@ export function extractCopyInventory(root) {
2066
2232
  },
2067
2233
  sources,
2068
2234
  entries,
2235
+ dynamicSurfaces,
2069
2236
  },
2070
2237
  diagnostics,
2071
2238
  }
@@ -131,6 +131,23 @@ autoral da interface. Arrays, objetos ou discriminadores dinâmicos falham visiv
131
131
  inequívoca; entidades JSX são decodificadas antes do inventário. Fragmentos cuja concatenação
132
132
  exigiria adivinhar espaços continuam reprovando.
133
133
 
134
+ Quando o diagnóstico corresponde a dados reais da aplicação ou a uma mensagem interpolada,
135
+ `copy.dynamic` permite classificar somente aquela superfície sem excluir o arquivo. Cada item
136
+ seleciona exatamente um diagnóstico por `source`, `line` e `field`, registra `reason` e
137
+ `reference` auditáveis e usa um destes contratos:
138
+
139
+ - `runtime-data` reconhece nomes, contagens, status ou opções fornecidos em runtime. Nenhuma
140
+ entrada editorial é fabricada;
141
+ - `message-template` exige `role` e `text`. O texto-modelo entra no inventário e passa pela
142
+ política da Base, mas não substitui nem altera o conteúdo renderizado.
143
+
144
+ As declarações aparecem em `dynamicSurfaces` no inventário. Se o source mudar e o seletor não
145
+ encontrar exatamente um diagnóstico, `opus copy` falha. Assim, a classificação permanece
146
+ estreita e revisável, sem transformar metadata em uma exclusão silenciosa. O mecanismo aceita
147
+ somente diagnósticos de conteúdo; incerteza estrutural, `className`, `style`, transformação ou
148
+ modo de renderização continua bloqueante. Superfície vazia também não pode ser reclassificada
149
+ como dado de runtime.
150
+
134
151
  Um `opus copy --check` verde **não significa que toda a copy do produto foi coberta**. Ficam
135
152
  fora, deliberadamente:
136
153
 
@@ -138,7 +155,7 @@ fora, deliberadamente:
138
155
  seu papel semântico;
139
156
  - texto vindo de API, banco, catálogo i18n importado ou variável de runtime em superfícies
140
157
  que o Opus não classifica. Quando esse conteúdo ocupa uma prop ou filho mapeado, a extração
141
- falha e pede um fallback estático;
158
+ falha e pede copy estática ou uma declaração estreita em `copy.dynamic`;
142
159
  - props ou componentes de terceiros sem contrato Opus;
143
160
  - copy escondida em CSS ou pseudo-elemento. Para uma transformação conhecida que o AST não
144
161
  enxerga globalmente, use `copy.transforms`; em componente Opus mapeado, `className` opaco
@@ -0,0 +1,66 @@
1
+ # Escala de arredondamento
2
+
3
+ > **Status: decidido (ago/2026).** A forma usa uma escala contínua do Tailwind; o nome do
4
+ > componente não cria um segundo conjunto de tokens.
5
+
6
+ ## Contexto
7
+
8
+ Cards e modais precisam ter presença suficiente para organizar a página sem parecerem caixas
9
+ rígidas. Controles, itens internos e superfícies compactas precisam continuar menores para que a
10
+ interface preserve hierarquia. A base anterior de `0.625rem` deixava o primeiro grupo adequado,
11
+ mas mantinha os demais degraus visualmente mais retos do que a identidade desejada.
12
+
13
+ Alterar cada componente isoladamente resolveria exemplos visíveis, mas deixaria consumidores e
14
+ novos componentes sem uma referência comum. Reintroduzir tokens como `--radius-card` também
15
+ voltaria a acoplar forma e tipo de componente, problema removido na versão 10.0.0.
16
+
17
+ ## Decisão
18
+
19
+ - `--radius` passa a `0.75rem`. Aplicações podem sobrescrever essa base para ajustar a identidade
20
+ inteira sem forkar componentes.
21
+ - `rounded-xs` até `rounded-2xl` formam uma progressão derivada da mesma base. `rounded-full`
22
+ continua reservado a círculos e variantes explicitamente pill.
23
+ - Componentes escolhem o degrau pela escala visual. Em geral, detalhes e itens internos usam
24
+ `xs` ou `sm`; controles e superfícies compactas usam `md`; molduras estruturais usam `lg`; e
25
+ superfícies principais ou modais usam `xl`. Essa associação orienta o default, mas não cria um
26
+ token semântico por componente.
27
+ - Superfícies aninhadas evitam moldura dupla e normalmente usam um degrau menor que o contêiner.
28
+ Elementos conectados removem os raios nas arestas internas.
29
+ - Card, Dialog e AlertDialog permanecem em `xl`. Molduras estruturais com cantos, como a tabela
30
+ standalone, permanecem em `lg`; Page, Split e Sidebar não ganham uma moldura por essa regra.
31
+ - Calendar e Command preservam a geometria embutível. Um consumidor standalone pode ajustar o
32
+ contêiner por `className` quando a composição pedir mais presença.
33
+ - Tamanho e forma continuam eixos separados nos controles. Variantes `sm`, `default` e `lg` não
34
+ mudam o radius automaticamente; `shape="pill"` continua sendo a escolha explícita para pílulas.
35
+
36
+ ## Consequências
37
+
38
+ - A mudança da base afeta todos os consumidores que não sobrescrevem `--radius`. O efeito é
39
+ visual e intencional; nenhuma migração de código é necessária.
40
+ - A progressão derivada evita que `rounded-xl` ultrapasse `rounded-2xl` e faz a customização por
41
+ `--radius` alcançar também os extremos usados pelo Opus. `rounded-3xl` continua nativo do
42
+ Tailwind; aplicações que usam esse degrau precisam revisar a fronteira ao alterar a base.
43
+ - Novos componentes podem escolher uma utility da escala sem inventar vocabulário próprio.
44
+ - Drawer e outras superfícies presas à viewport não mudam neste entregável. Arredondar somente os
45
+ cantos expostos exige validar clipping, scroll e conteúdo full-bleed em uma mudança própria.
46
+
47
+ ## Alternativas descartadas
48
+
49
+ - **Aplicar `rounded-xl` a toda superfície:** aproxima os componentes do Card, mas elimina a
50
+ diferença entre controles compactos, molduras estruturais e superfícies principais.
51
+ - **Editar cada componente sem mudar a base:** oferece controle pontual, mas espalha a decisão e
52
+ não beneficia consumidores que usam diretamente a escala do Tailwind.
53
+ - **Restaurar tokens por papel:** fixa defaults, mas volta a criar uma taxonomia paralela para a
54
+ mesma propriedade.
55
+ - **Escalar radius junto com o tamanho de cada controle:** parece proporcional isoladamente, mas
56
+ quebra grupos conectados e mistura o eixo de forma com o eixo de densidade.
57
+
58
+ ## Verificação
59
+
60
+ - Um teste de contrato lê o tema canônico e confirma a base, as fórmulas e a ordem crescente de
61
+ `rounded-xs` até `rounded-2xl`.
62
+ - A documentação de tokens demonstra a escala completa e explica como uma aplicação altera a
63
+ identidade pelo token base.
64
+ - A skill `build-opus-ui` orienta o uso da escala, o aninhamento e as junções sem prescrever tokens
65
+ por componente.
66
+ - Os testes e o build da UI confirmam que as utilities continuam materializadas pelo Tailwind.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "12.0.1",
3
+ "version": "12.2.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",
@@ -24,15 +24,23 @@ existentes, mantendo navegação observável e componentes reutilizáveis sem la
24
24
  `bg-card text-card-foreground` e `bg-popover text-popover-foreground`. Não depender da
25
25
  igualdade atual com `--foreground`, porque o app pode sobrescrever cada par.
26
26
  7. Usar diretamente a escala `rounded-*`; não criar radius por nome de componente quando
27
- `rounded-sm` a `rounded-xl` já expressam a forma.
27
+ `rounded-xs` a `rounded-2xl` já expressam a forma. Escolher o degrau pela escala visual:
28
+ detalhes e itens internos tendem a `xs`/`sm`, controles e flutuantes compactos a `md`,
29
+ molduras estruturais a `lg`, e superfícies principais ou modais a `xl`. Essa associação
30
+ orienta o default, não cria uma restrição semântica. Em aninhamento, evitar moldura dupla e
31
+ reduzir o raio interno; em grupos conectados, remover os raios das arestas internas. Tamanho
32
+ e forma permanecem eixos separados; usar `shape="pill"` quando a pílula for intencional.
28
33
  8. Testar estados de loading, vazio, erro, sucesso, permissão e interação relevante.
29
34
 
30
35
  ## Verificação
31
36
 
32
37
  Rodar `opus check`, testes focados, typecheck e build da superfície. Se a interface alterou
33
38
  copy em contrato ou componente Opus mapeado, regenerar com `opus copy` e executar
34
- `base copy check`. Copy dinâmica numa superfície mapeada precisa de fallback estático ou de
35
- uma decisão explícita de arquitetura; não contorne o diagnóstico com `aria-label` paralelo.
39
+ `base copy check`. Quando uma superfície mapeada recebe dados da aplicação, usar
40
+ `copy.dynamic` com seletor exato, motivo e referência: `runtime-data` para valores sem autoria
41
+ editorial; `message-template` com `role` e texto-modelo para mensagens interpoladas. Não excluir
42
+ o arquivo inteiro nem contornar o diagnóstico com `aria-label` paralelo. Revisar cada declaração
43
+ dinâmica como fronteira de cobertura, mesmo com os gates verdes.
36
44
  Inspecionar visualmente a rota real e validar navegação por URL quando aplicável.
37
45
 
38
46
  ## Limites
@@ -3,5 +3,9 @@
3
3
  - Dispara: “Monte a tela de edição usando a form action do Opus.”
4
4
  - Não dispara: “Ajuste o CSS de um e-mail estático.”
5
5
  - Execução: implementar uma lista com modal roteável e provar loading, erro, vazio e back.
6
+ - Execução de forma: compor uma tabela dentro de Card sem moldura duplicada, manter a moldura
7
+ estrutural standalone em `rounded-lg` e os controles internos em `rounded-md`.
6
8
  - Reprova: criar uma casca `bg-card` que herda o texto global ou introduzir `rounded-widget`
7
9
  quando a escala `rounded-*` atende ao papel.
10
+ - Reprova: trocar automaticamente o radius de um controle quando apenas seu tamanho muda ou
11
+ manter cantos arredondados nas arestas internas de um grupo conectado.
@@ -8,4 +8,10 @@
8
8
  - Superfície semântica e foreground são um par local (`bg-card text-card-foreground`,
9
9
  `bg-popover text-popover-foreground`); herança do foreground global não substitui o par.
10
10
  - Forma usa a escala `rounded-*`; nome de componente não cria uma segunda escala de radius.
11
+ Detalhes e itens internos tendem a `xs`/`sm`, controles e flutuantes compactos a `md`,
12
+ molduras estruturais a `lg`, e superfícies principais ou modais a `xl`. Essa associação é
13
+ um default visual, não uma restrição semântica.
14
+ - Superfície aninhada evita moldura dupla e normalmente usa um degrau abaixo do contêiner.
15
+ Grupos conectados removem os raios internos. O tamanho do controle não muda sua forma;
16
+ `shape="pill"` declara a pílula quando ela for intencional.
11
17
  - Catálogo e API efetivos vêm dos exports da versão instalada, não de memória ou exemplo antigo.
@@ -60,6 +60,43 @@ visual que o protocolo não representa com segurança.
60
60
  `ActionTrigger.itemLabel` é conteúdo de domínio em runtime (o nome do registro alvo), não
61
61
  é copy editorial estável; `label` e `confirm.*` são as superfícies editoriais cobertas.
62
62
 
63
+ Quando uma prop mapeada recebe dados da aplicação, declare a superfície em
64
+ `base.json.copy.dynamic` em vez de excluir o arquivo inteiro. O seletor usa o caminho, a
65
+ linha e o campo exibido pelo diagnóstico. `runtime-data` registra valores como nomes,
66
+ contagens e opções vindas de API sem transformá-los em copy editorial. `message-template`
67
+ acrescenta ao inventário um texto-modelo com papel semântico, para que a parte autoral de uma
68
+ mensagem interpolada continue passando pela política da Base. As declarações não alteram o
69
+ que o React renderiza; um seletor que deixa de corresponder a um único diagnóstico reprova o
70
+ gate e precisa ser revisto. Diagnósticos estruturais ou de transformação visual não podem ser
71
+ dispensados por `copy.dynamic`; ausência de conteúdo também continua bloqueante.
72
+
73
+ ```json
74
+ {
75
+ "copy": {
76
+ "dynamic": [
77
+ {
78
+ "source": "src/sessions.tsx",
79
+ "line": 42,
80
+ "field": "<Select>.options",
81
+ "kind": "runtime-data",
82
+ "reason": "As opções são nomes de sessões retornados pela aplicação.",
83
+ "reference": "domain:sessions"
84
+ },
85
+ {
86
+ "source": "src/sessions.tsx",
87
+ "line": 58,
88
+ "field": "<DialogDescription> children",
89
+ "kind": "message-template",
90
+ "role": "dialog-body",
91
+ "text": "Excluir {nome}?",
92
+ "reason": "O nome identifica a sessão selecionada na confirmação.",
93
+ "reference": "domain:sessions"
94
+ }
95
+ ]
96
+ }
97
+ }
98
+ ```
99
+
63
100
  O mapeamento completo, o limite de cobertura e os comandos do gate estão em
64
101
  `docs/code-style.md` do pacote.
65
102
 
@@ -21,10 +21,13 @@ divergência declarada), pra valer pra casa toda.
21
21
  :root {
22
22
  --primary: hsl(262 83% 58%); /* A cor da marca do projeto. */
23
23
  --ring: hsl(262 83% 58%);
24
- --radius: 0.5rem; /* Cantos mais retos, se for a praia do projeto. */
24
+ --radius: 0.5rem; /* Uma base menor deixa toda a escala mais contida. */
25
25
  }
26
26
  ```
27
27
 
28
+ O Opus deriva de `rounded-xs` a `rounded-2xl` dessa base. Assim, a marca muda a presença dos
29
+ cantos sem substituir as utilities escolhidas pelos componentes nem criar tokens por papel.
30
+
28
31
  ## 2 · className em tudo
29
32
 
30
33
  > Todo componente termina em `cn(base, className)` com tailwind-merge: o utilitário do consumidor
@@ -109,19 +109,21 @@ render(
109
109
 
110
110
  ## Radius
111
111
 
112
- > Escala mapeada no `@theme inline` preservando o sentido pré-v4 dos utilitários `rounded-*`
113
- > (neutraliza o rename do Tailwind v4). Componentes e superfícies usam diretamente essa
114
- > escala: a forma não cria uma segunda taxonomia por componente. A identidade por app
115
- > sobrescreve `--radius` e escala tudo junto.
112
+ > A escala mapeada no `@theme inline` preserva o sentido pré-v4 dos utilitários `rounded-*`
113
+ > e mantém uma progressão contínua de `xs` a `2xl`. Componentes e superfícies escolhem um
114
+ > degrau pela escala visual; a forma não cria uma segunda taxonomia por componente. A
115
+ > identidade por app sobrescreve `--radius` e move esses degraus em conjunto.
116
116
 
117
117
  ```tsx preview
118
118
  render(
119
119
  <div className="flex flex-wrap gap-4">
120
120
  {[
121
+ ['rounded-xs', 'radius − 6px'],
121
122
  ['rounded-sm', 'radius − 4px'],
122
123
  ['rounded-md', 'radius − 2px'],
123
- ['rounded-lg', 'radius (0.625rem)'],
124
+ ['rounded-lg', 'radius (0.75rem)'],
124
125
  ['rounded-xl', 'radius + 4px'],
126
+ ['rounded-2xl', 'radius + 8px'],
125
127
  ].map(([cls, calc]) => (
126
128
  <div key={cls} className="flex items-center gap-3">
127
129
  <div className={'h-12 w-12 border-2 border-foreground/30 bg-muted ' + cls} />
@@ -144,7 +146,9 @@ render(
144
146
  valores acompanham o upstream, só o formato difere.
145
147
  2. Elevação no dark: `--card`/`--popover` ficam ACIMA de `--background` (10% vs 3.9%) — identidade
146
148
  da casa; dialog e card "sobem" da página de verdade.
147
- 3. Radius mapeado (sm/md/lg/xl) preservando o sentido pré-v4 dos utilitários `rounded-*`.
149
+ 3. Radius mapeado (`xs` a `2xl`) preservando o sentido pré-v4 dos utilitários `rounded-*`. Esse
150
+ trecho derivado permanece monotônico quando a base muda; aplicações que também usam `3xl`
151
+ precisam revisar a transição para esse degrau nativo ao customizar `--radius`.
148
152
 
149
153
  4. Escala `shadow-*` recomposta em duas camadas claras e difusas — preserva os degraus do
150
154
  Tailwind e troca somente o peso visual. Os aliases antigos por papel foram removidos;
@@ -153,6 +157,11 @@ render(
153
157
  5. Bordas mais leves no light: `--border`/`--input` em 92.5% (upstream 89.8%) — par do canvas
154
158
  quase-branco do body; superfícies elevadas usam essa mesma borda real.
155
159
 
160
+ 6. Radius base em `0.75rem` — superfícies principais ganham cantos mais presentes sem aproximar
161
+ controles compactos de modais. A decisão detalhada acompanha o pacote em
162
+ `docs/radius-scale.md`, com os defaults, o comportamento de superfícies aninhadas e as
163
+ alternativas descartadas.
164
+
156
165
  ## Identidade por app
157
166
 
158
167
  > O tema do Opus é a base; cada app sobrescreve as vars pra ter a própria identidade sem forkar componente.
package/src/ui/theme.css CHANGED
@@ -10,13 +10,15 @@
10
10
  * nossas; os VALORES acompanham o upstream, só o formato difere.
11
11
  * 2. Elevação dark: --card/--popover ACIMA de --background (10% vs 3.9%) — identidade
12
12
  * da casa (upstream v4 também elevou; calibramos pelo site deles em 2026-06).
13
- * 3. Radius mapeado (sm/md/lg/xl em @theme inline) preservando o sentido pré-v4 dos
14
- * utilitários `rounded-*` — neutraliza o rename do v4.
13
+ * 3. Radius mapeado (xs a 2xl em @theme inline) preservando o sentido pré-v4 dos
14
+ * utilitários `rounded-*` — neutraliza o rename do v4 e mantém a escala monotônica.
15
15
  * 4. Escala shadow-* recomposta diretamente em duas camadas claras, difusas e com spread
16
16
  * negativo — preserva inclusive modifiers como `shadow-lg/30` (ver docs/elevation-scale.md).
17
17
  * 5. Bordas mais leves no light: --border/--input em 92.5% (upstream 89.8%) — par do
18
18
  * canvas quase-branco; superfícies elevadas usam a mesma borda real dos demais papéis.
19
- * Tudo o mais (escala neutra, radius base 0.625rem, semânticas) segue o upstream.
19
+ * 6. Radius base em 0.75rem identidade mais arredondada; os componentes continuam usando
20
+ * diretamente a escala Tailwind, sem tokens por papel (ver docs/radius-scale.md).
21
+ * Tudo o mais (escala neutra, semânticas) segue o upstream.
20
22
  */
21
23
  @import 'tailwindcss';
22
24
  @import 'tw-animate-css';
@@ -50,7 +52,7 @@
50
52
  /* Ring de foco LEVE (pedido da casa): aro sutil, não um halo. Com ring-[3px] + /50,
51
53
  ~86% encosta na borda (89.8%) — um sussurro de foco. */
52
54
  --ring: hsl(0 0% 86%);
53
- --radius: 0.625rem;
55
+ --radius: 0.75rem;
54
56
  }
55
57
 
56
58
  .dark {
@@ -132,10 +134,12 @@
132
134
  --color-input: var(--input);
133
135
  --color-ring: var(--ring);
134
136
 
137
+ --radius-xs: calc(var(--radius) - 6px);
135
138
  --radius-sm: calc(var(--radius) - 4px);
136
139
  --radius-md: calc(var(--radius) - 2px);
137
140
  --radius-lg: var(--radius);
138
141
  --radius-xl: calc(var(--radius) + 4px);
142
+ --radius-2xl: calc(var(--radius) + 8px);
139
143
 
140
144
  /* Escala Tailwind, com receita direta para o compilador preservar modifiers `/opacity`.
141
145
  `light-dark()` mantém o peso adequado em cada tema sem uma variável intermediária. */