@softize/opus 10.0.0 → 11.1.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/bin/cli.mjs +1 -1
  3. package/bin/lib/db-check-runner.mjs +5 -5
  4. package/bin/lib/db-migrate-runner.mjs +3 -3
  5. package/bin/lib/db-scaffold-runner.mjs +4 -4
  6. package/bin/lib/db.mjs +1 -1
  7. package/bin/lib/gen-manifest.mjs +0 -3
  8. package/bin/lib/gen-runner.mjs +2 -7
  9. package/bin/lib/init.mjs +6 -0
  10. package/bin/lib/materialize.mjs +3 -1
  11. package/docs/data-layer.md +2 -2
  12. package/docs/protocol.md +2 -2
  13. package/package.json +1 -1
  14. package/registry/instructions/opus.md +3 -0
  15. package/registry/skills/build-opus-ui/SKILL.md +2 -0
  16. package/registry/skills/create-opus-action/SKILL.md +2 -0
  17. package/registry/skills/implement-opus-change/SKILL.md +2 -0
  18. package/registry/skills/upgrade-opus/SKILL.md +2 -0
  19. package/registry/skills/write-product-communication/SKILL.md +28 -0
  20. package/registry/skills/write-product-communication/agents/openai.yaml +4 -0
  21. package/src/core/domain.ts +3 -6
  22. package/src/data/readonly-pool.ts +2 -2
  23. package/src/schema/entity.ts +1 -1
  24. package/src/ui/components/patterns/shell-nav.tsx +5 -9
  25. package/src/ui/components/patterns/sidebar.tsx +40 -15
  26. package/src/ui/docs/DocBrowser.tsx +25 -10
  27. package/src/ui/docs/content/actions.md +7 -6
  28. package/src/ui/docs/content/auth.md +3 -2
  29. package/src/ui/docs/content/cli.md +2 -1
  30. package/src/ui/docs/content/confirm.md +2 -2
  31. package/src/ui/docs/content/data.md +1 -1
  32. package/src/ui/docs/content/getting-started.md +15 -23
  33. package/src/ui/docs/content/microcopy.md +119 -72
  34. package/src/ui/docs/content/sidebar.md +21 -1
  35. package/src/ui/docs/content/upgrading.md +1 -1
  36. package/src/ui/docs/registry.tsx +1 -7
  37. package/src/ui/meta.ts +2 -23
  38. package/src/ui/react.tsx +4 -19
  39. package/docs/shellnav.md +0 -131
  40. package/src/ui/components/patterns/app-shell.tsx +0 -227
  41. package/src/ui/components/patterns/section-shell.tsx +0 -246
  42. package/src/ui/docs/content/app-shell.md +0 -155
  43. package/src/ui/docs/content/resizable.md +0 -86
  44. package/src/ui/docs/content/section-shell.md +0 -121
package/CHANGELOG.md CHANGED
@@ -6,11 +6,54 @@ Este arquivo viaja no pacote: num projeto, leia `node_modules/@softize/opus/CHAN
6
6
  Depois de qualquer bump, rode os gates (`typecheck` · `test` · `opus check` ·
7
7
  `manifest:check`) — eles apontam o que a mudança cobra do seu código.
8
8
 
9
+ ## 11.1.0 — 2026-08-21
10
+
11
+ O Opus passa a distribuir a skill `write-product-communication` para orientar documentação,
12
+ interface, mensagens de erro, CLI, onboarding e textos explicativos a partir da situação do
13
+ leitor. A página antes dedicada apenas a microcopy agora reúne a referência canônica de
14
+ comunicação, incluindo escrita normativa e tratamento de erros.
15
+
16
+ O `opus setup` expande o conteúdo dessa referência ao materializar a skill em `.agents` e
17
+ `.claude`. As instruções permanentes e as skills de UI, actions, mudanças e upgrades passam a
18
+ acionar a orientação nos fluxos em que produzem texto. Testes de criação e setup confirmam que
19
+ projetos novos recebem o conteúdo completo, sem depender de um link externo.
20
+
21
+ A home, as páginas iniciais e a mensagem de sucesso do `opus check` começam a migração da voz
22
+ existente: apresentam contexto e efeito antes das regras, evitam slogans e informam a próxima
23
+ etapa em linguagem natural.
24
+
9
25
  > As entradas 2.30.1–2.31.5 foram **reconstruídas do git** (o release publicava sem
10
26
  > passar por aqui). Uma delas — a 2.31.5, que mudou o rem base — é visual GLOBAL e
11
27
  > tinha ficado sem registro nenhum, o que é exatamente o caso que este arquivo existe
12
28
  > pra cobrir.
13
29
 
30
+ ## 11.0.0 — 2026-08-21
31
+
32
+ **Breaking — deprecateds removidos na fronteira pública.** O major anterior manteve por
33
+ engano APIs cuja própria documentação prometia remoção na próxima versão; esta versão
34
+ fecha a migração em vez de carregar duas taxonomias indefinidamente.
35
+
36
+ - `AppShell`, `AppShellBar`, `AppShellTrigger`, `useAppShell`, `useSidebarSlot` e
37
+ `SectionShell` (incluindo seus tipos `Section*`) saem. Componha chrome e seções com
38
+ `Split`, `Pane`, `Sidebar`, `PaneHeader`, `PaneContent`, `PaneFooter` e `SidebarNav`.
39
+ - `ResizablePanelGroup`, `ResizablePanel` e `ResizableHandle` deixam de ser exports
40
+ públicos. Use `<Split resizable>` e declare tamanho/limites nos `<Pane>`.
41
+ - `SidebarHeader`, `SidebarContent` e `SidebarFooter` saem; eram aliases de
42
+ `PaneHeader`, `PaneContent` e `PaneFooter`.
43
+ - `DomainConfig.models` sai. Declare entidades em `DomainConfig.entities`; os comandos
44
+ `opus db` e o gerador agora leem somente esse campo. O manifest deixa de emitir o array
45
+ redundante `models`; os nomes continuam disponíveis em `entities[].name`.
46
+
47
+ `SidebarNav` passa a aceitar `subgroups`, cobrindo navegação contextual e a árvore de três
48
+ níveis da documentação sem um shell especializado. `ShellNav` agora deriva o estado
49
+ recolhido diretamente da `Sidebar`. O próprio `DocBrowser` foi migrado para a composição
50
+ canônica e serve como consumidor de referência.
51
+
52
+ **Migração:** substitua shells prontos pela composição `Split > Pane > Sidebar`; troque os
53
+ aliases `Sidebar*` pelos slots `Pane*`; envolva painéis ajustáveis em `<Split resizable>`;
54
+ renomeie `domain.models` para `domain.entities` e, ao consumir manifest, derive nomes de
55
+ `domain.entities.map(entity => entity.name)`.
56
+
14
57
  ## 10.0.0 — 2026-08-20
15
58
 
16
59
  **Breaking — forma usa a escala do Tailwind e superfícies sempre carregam seu foreground.**
package/bin/cli.mjs CHANGED
@@ -190,7 +190,7 @@ async function cmdCheck(dir) {
190
190
  }
191
191
  const viaContrato = contracts > 0 ? ` (${contracts} contrato(s))` : ''
192
192
  if (findings.length === 0) {
193
- log('success', `✓ opus check: ${actions} action(s)${viaContrato}, 0 violação — padrão ok.`)
193
+ log('success', `✓ opus check: ${actions} action(s)${viaContrato}; nenhuma violação encontrada.`)
194
194
  return
195
195
  }
196
196
 
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * Contrato do `opus.config.ts` pros comandos `db`:
10
10
  * - `entities?: EntityConfig[]` — entidades explícitas (opcional)
11
- * - `domains?: DomainConfig[]` — entidades coletadas de `domain.models`
11
+ * - `domains?: DomainConfig[]` — entidades coletadas de `domain.entities`
12
12
  * - `database: () => Kysely | Promise<Kysely>` — factory LAZY do banco.
13
13
  * É factory (não instância) de propósito: o `gen` importa o mesmo config
14
14
  * sem nunca chamar `database()`, então não abre conexão.
@@ -29,9 +29,9 @@ function emit(obj) {
29
29
 
30
30
  function collectEntities(domain, out) {
31
31
  if (domain === null || typeof domain !== 'object') return
32
- const models = domain.models
33
- if (models !== undefined && models !== null && typeof models === 'object') {
34
- for (const value of Object.values(models)) {
32
+ const entities = domain.entities
33
+ if (entities !== undefined && entities !== null && typeof entities === 'object') {
34
+ for (const value of Object.values(entities)) {
35
35
  if (isEntityConfig(value)) out.push(value)
36
36
  }
37
37
  }
@@ -59,7 +59,7 @@ async function main() {
59
59
  }
60
60
 
61
61
  if (entities.length === 0) {
62
- emit({ ok: false, error: 'nenhuma entidade encontrada (config.entities ou domain.models)' })
62
+ emit({ ok: false, error: 'nenhuma entidade encontrada (config.entities ou domain.entities)' })
63
63
  return
64
64
  }
65
65
 
@@ -28,9 +28,9 @@ function emit(obj) {
28
28
 
29
29
  function collectEntities(domain, out) {
30
30
  if (domain === null || typeof domain !== 'object') return
31
- const models = domain.models
32
- if (models !== undefined && models !== null && typeof models === 'object') {
33
- for (const value of Object.values(models)) if (isEntityConfig(value)) out.push(value)
31
+ const entities = domain.entities
32
+ if (entities !== undefined && entities !== null && typeof entities === 'object') {
33
+ for (const value of Object.values(entities)) if (isEntityConfig(value)) out.push(value)
34
34
  }
35
35
  if (Array.isArray(domain.subdomains)) for (const sub of domain.subdomains) collectEntities(sub, out)
36
36
  }
@@ -23,9 +23,9 @@ function emit(obj) {
23
23
 
24
24
  function collectEntities(domain, out) {
25
25
  if (domain === null || typeof domain !== 'object') return
26
- const models = domain.models
27
- if (models !== undefined && models !== null && typeof models === 'object') {
28
- for (const value of Object.values(models)) if (isEntityConfig(value)) out.push(value)
26
+ const entities = domain.entities
27
+ if (entities !== undefined && entities !== null && typeof entities === 'object') {
28
+ for (const value of Object.values(entities)) if (isEntityConfig(value)) out.push(value)
29
29
  }
30
30
  if (Array.isArray(domain.subdomains)) for (const sub of domain.subdomains) collectEntities(sub, out)
31
31
  }
@@ -48,7 +48,7 @@ async function main() {
48
48
  if (Array.isArray(config.entities)) for (const e of config.entities) if (isEntityConfig(e)) entities.push(e)
49
49
  if (Array.isArray(config.domains)) for (const d of config.domains) collectEntities(d, entities)
50
50
  if (entities.length === 0) {
51
- emit({ ok: false, error: 'nenhuma entidade encontrada (config.entities ou domain.models)' })
51
+ emit({ ok: false, error: 'nenhuma entidade encontrada (config.entities ou domain.entities)' })
52
52
  return
53
53
  }
54
54
  if (typeof config.database !== 'function') {
package/bin/lib/db.mjs CHANGED
@@ -249,7 +249,7 @@ Flags:
249
249
 
250
250
  O opus.config.ts precisa expor, pros comandos db:
251
251
  database: () => Kysely factory LAZY do banco (gen não a chama)
252
- entities | domains entidades (explícitas ou via domain.models)
252
+ entities | domains entidades (explícitas ou via domain.entities)
253
253
  schema?: string script SQL idempotente (default: ./db/schema.sql)
254
254
  migrations?: string pasta dos rascunhos do scaffold (default: ./migrations)
255
255
 
@@ -25,10 +25,7 @@ function shapeDomain(d) {
25
25
  dicts: d.dicts,
26
26
  repository: d.hasRepository,
27
27
  service: d.hasService,
28
- // `entities` = a fonte estrutural+negócio (campos + docs). `models` = nomes
29
- // (legado, removido no PR de rename). Ambos por ora pra não quebrar a lente.
30
28
  entities: d.entities ?? [],
31
- models: d.modelNames,
32
29
  actions: d.actions.map(shapeAction),
33
30
  reactions: d.reactions,
34
31
  schedules: d.schedules,
@@ -30,7 +30,7 @@
30
30
  * SerializedDomain:
31
31
  * {
32
32
  * name, dicts, actions, reactions, schedules, subdomains,
33
- * hasRepository, hasService, hasModels
33
+ * hasRepository, hasService
34
34
  * }
35
35
  *
36
36
  * SerializedAction (todos os ActionDef preservam suas strings):
@@ -143,17 +143,12 @@ async function main() {
143
143
  // =============================================================================
144
144
 
145
145
  function serializeDomain(domain, ctx) {
146
- // `entities` é o nome preferido; `models` é o legado (alias). Lê os dois.
147
- const entitySrc = domain.entities ?? domain.models
148
- const entityList = serializeEntities(entitySrc)
146
+ const entityList = serializeEntities(domain.entities)
149
147
  return {
150
148
  name: domain.name,
151
149
  description: nullable(domain.description),
152
150
  hasRepository: domain.repository !== undefined,
153
151
  hasService: domain.service !== undefined,
154
- // Compat: nomes ainda expostos; `entities` traz a estrutura+doc completa.
155
- hasModels: entityList.length > 0,
156
- modelNames: entityList.map((e) => e.name),
157
152
  entities: entityList,
158
153
  dicts: serializeDicts(domain.dicts),
159
154
  actions: Array.from(iterateActions(domain.actions), (a) =>
package/bin/lib/init.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  /** opus setup: aplica o marcador per-app e os artefatos Opus rastreáveis no repo. */
2
2
 
3
3
  import { promises as fs } from "node:fs";
4
+ import { tmpdir } from "node:os";
4
5
  import path from "node:path";
5
6
 
6
7
  import { materializeOpus, PACKAGE_VERSION } from "./materialize.mjs";
@@ -27,7 +28,12 @@ async function readJson(p) {
27
28
  /** Raiz do repo (sobe até achar `.git`); sem repo → null (o chamador decide o fallback). */
28
29
  export async function repoRootOf(dir) {
29
30
  let cur = path.resolve(dir);
31
+ const temporaryRoot = path.resolve(tmpdir());
30
32
  for (;;) {
33
+ // O diretório temporário global pode receber marcadores transitórios de ferramentas
34
+ // e sandboxes. Um projeto dentro dele ainda pode ter sua própria raiz Git, mas não deve
35
+ // herdar `/tmp/.git` como se todos os fixtures pertencessem ao mesmo repositório.
36
+ if (cur === temporaryRoot) return null;
31
37
  if (await exists(path.join(cur, ".git"))) return cur;
32
38
  const up = path.dirname(cur);
33
39
  if (up === cur) return null;
@@ -5,6 +5,7 @@ import { dirname, extname, join, relative, resolve } from 'node:path'
5
5
  import { fileURLToPath } from 'node:url'
6
6
 
7
7
  import { validateSkill } from './validate-skill.mjs'
8
+ import { expandDocIncludes } from './docs-include.mjs'
8
9
 
9
10
  export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
10
11
  export const PACKAGE_NAME = '@softize/opus'
@@ -97,7 +98,8 @@ function expectedFiles(exclude) {
97
98
  for (const file of filesUnder(join(PACKAGE_ROOT, sourceRoot))) {
98
99
  const source = `${sourceRoot}/${file.relative}`
99
100
  const destination = `${destinationRoot}/${file.relative}`
100
- const content = readFileSync(file.path, 'utf8')
101
+ const sourceContent = readFileSync(file.path, 'utf8')
102
+ const content = file.relative === 'SKILL.md' ? expandDocIncludes(sourceContent) : sourceContent
101
103
  expected.set(destination, { source, content, output: addMarker(destination, source, content) })
102
104
  }
103
105
  }
@@ -90,7 +90,7 @@ infra do `gen`, não a do `check`). Contrato do config **pros comandos `db`**:
90
90
 
91
91
  ```ts
92
92
  export default {
93
- domains: [...], // entidades coletadas de domain.models
93
+ domains: [...], // entidades coletadas de domain.entities
94
94
  entities: [Deal, Company], // ou explícitas (opcional)
95
95
  schema: './src/db/schema.sql', // o script idempotente (default: ./db/schema.sql)
96
96
  database: () => new Kysely({ ... }), // factory LAZY do banco
@@ -139,7 +139,7 @@ import { Kysely, PostgresDialect, CamelCasePlugin } from 'kysely'
139
139
  import { Deal } from './src/domains/deals/deal.entity.ts'
140
140
 
141
141
  export default {
142
- entities: [Deal], // ou domains: [...] (entidades via domain.models)
142
+ entities: [Deal], // ou domains: [...] (entidades via domain.entities)
143
143
  naming: 'snake', // colunas snake no banco
144
144
  migrations: './migrations',
145
145
  database: () => new Kysely({
package/docs/protocol.md CHANGED
@@ -2053,14 +2053,14 @@ defineAction({
2053
2053
 
2054
2054
  ### Entidades dentro de um domínio
2055
2055
 
2056
- `DomainConfig.models` recebe as entidades do recorte (substitui o `unknown` placeholder atual). O domínio agrupa; a entidade declara.
2056
+ `DomainConfig.entities` recebe as entidades do recorte. O domínio agrupa; a entidade declara.
2057
2057
 
2058
2058
  ```ts
2059
2059
  import { defineDomain } from '@softize/opus'
2060
2060
 
2061
2061
  export const crm = defineDomain({
2062
2062
  name: 'crm',
2063
- models: { Deal, Company, Contact },
2063
+ entities: { Deal, Company, Contact },
2064
2064
  actions: { /* deal.create, deal.search, ... */ },
2065
2065
  })
2066
2066
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "10.0.0",
3
+ "version": "11.1.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",
@@ -15,3 +15,6 @@ artefatos de agentes fica em `base.json`; cada app Opus mantém seu marcador `op
15
15
  branch antes de commit/review e usar `upgrade-opus` para ler changelog e aplicar migrações.
16
16
  - Consultar as skills Opus materializadas conforme o workflow; não atribuir ao SDK decisões
17
17
  universais de domínio ou arquitetura.
18
+ - Usar `write-product-communication` ao criar ou alterar documentação, textos de interface,
19
+ mensagens de erro, CLI, onboarding ou explicações. Partir da situação do leitor, apresentar
20
+ contexto antes da regra e evitar slogans, absolutos e linguagem de manifesto.
@@ -26,6 +26,8 @@ existentes, mantendo navegação observável e componentes reutilizáveis sem la
26
26
  7. Usar diretamente a escala `rounded-*`; não criar radius por nome de componente quando
27
27
  `rounded-sm` a `rounded-xl` já expressam a forma.
28
28
  8. Testar estados de loading, vazio, erro, sucesso, permissão e interação relevante.
29
+ 9. Usar `write-product-communication` para labels, ajuda, estados vazios, confirmações e erros;
30
+ revisar o fluxo completo, não apenas cada string isolada.
29
31
 
30
32
  ## Verificação
31
33
 
@@ -21,6 +21,8 @@ registrados no runtime do projeto.
21
21
  1. Rodar `node scripts/scaffold.mjs <resource> <verb> <kind>` a partir desta skill.
22
22
  2. Colocar o contrato em módulo importável pelos consumidores e o binding no lado servidor.
23
23
  3. Substituir os exemplos do scaffold por schemas, descrições e comportamento do domínio.
24
+ Usar `write-product-communication` nas descrições, mensagens e textos projetados para UI
25
+ ou documentação.
24
26
  4. Declarar `requires` somente com `authorize` efetivo; `requires` sozinho não protege a action.
25
27
  5. Exportar e registrar o binding no domínio/runtime existente.
26
28
  6. Acionar `test-opus-action` para cobrir contrato, sucesso, erros e efeitos relevantes.
@@ -25,6 +25,8 @@ dependências server-only, bindings registrados, testes e gates verdes.
25
25
  5. Usar `create-opus-action`, `test-opus-action` ou `build-opus-ui` quando a mudança entrar
26
26
  nesses workflows especializados.
27
27
  6. Atualizar manifest, docs geradas e exemplos somente pelos comandos do repo.
28
+ 7. Usar `write-product-communication` sempre que a mudança alcançar documentação, UI, CLI,
29
+ mensagens de erro ou outro texto destinado a uma pessoa.
28
30
 
29
31
  ## Verificação
30
32
 
@@ -19,6 +19,8 @@ materializados e projeções geradas consistentes no mesmo diff.
19
19
  4. Aplicar migrações no código por domínio, sem compatibilidade temporária silenciosa.
20
20
  5. Rodar `opus check`, typecheck, testes, build e geradores declarados pelo projeto.
21
21
  6. Conferir `base.json`, lockfile, skills, hooks, instruções e outputs gerados no diff.
22
+ 7. Usar `write-product-communication` ao redigir changelog, guia de migração e mensagens
23
+ adicionadas ou alteradas durante o upgrade.
22
24
 
23
25
  ## Verificação
24
26
 
@@ -0,0 +1,28 @@
1
+ ---
2
+ name: write-product-communication
3
+ description: Escreve e revisa comunicação de produto clara, humana e orientada ao leitor. Usar ao criar ou alterar documentação, mensagens de erro, textos de interface, CLI, onboarding, ajuda, explicações, comentários ou qualquer conteúdo que uma pessoa precise compreender.
4
+ ---
5
+
6
+ # Escrever comunicação de produto
7
+
8
+ ## Resultado
9
+
10
+ Entregar uma comunicação que parte da situação do leitor, oferece o contexto necessário e
11
+ permite que a pessoa compreenda ou prossiga sem decodificar a linguagem interna da equipe.
12
+
13
+ ## Procedimento
14
+
15
+ 1. Identificar quem lerá o texto, o que essa pessoa tenta fazer e o que precisa compreender ou
16
+ decidir em seguida.
17
+ 2. Ler a comunicação no fluxo completo em que aparecerá, incluindo estados anteriores e
18
+ posteriores.
19
+ 3. Escrever a partir do problema e do efeito percebido; introduzir mecanismos e termos técnicos
20
+ somente quando acrescentarem precisão.
21
+ 4. Distinguir princípios, regras, recomendações, comportamentos, verificações e exceções.
22
+ 5. Revisar o texto fora do contexto da implementação e remover pressupostos, slogans,
23
+ advertências desnecessárias e detalhes internos.
24
+ 6. Confirmar que erros explicam o ocorrido, o impacto e uma próxima ação real quando ela existir.
25
+
26
+ ## Referência canônica
27
+
28
+ <!-- opus-doc: microcopy.md -->
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "Comunicação de produto"
3
+ short_description: "Escreva textos claros, humanos e úteis"
4
+ default_prompt: "Use $write-product-communication para revisar esta comunicação pelo ponto de vista do leitor."
@@ -2,7 +2,7 @@
2
2
  * tbdlib — `defineDomain` factory + flatten helper.
3
3
  *
4
4
  * Domain agrupa as peças que pertencem a um mesmo recorte funcional
5
- * (dicts, models, repository, service, actions, reactions, schedules,
5
+ * (dicts, entities, repository, service, actions, reactions, schedules,
6
6
  * subdomains). É puramente declarativo: nenhum efeito colateral acontece
7
7
  * em `defineDomain`. O runtime consome o objeto via `flattenDomain` quando
8
8
  * recebe um `DomainConfig` em `register(...)`.
@@ -55,12 +55,9 @@ export interface DomainConfig {
55
55
  dicts?: Record<string, unknown>
56
56
 
57
57
  /** Entidades do domínio (`defineEntity`). Map de nome → EntityConfig. É a fonte
58
- * estrutural+negócio que vai pro manifest. `models` é o nome legado (alias). */
58
+ * estrutural+negócio que vai pro manifest. */
59
59
  entities?: Record<string, unknown>
60
60
 
61
- /** @deprecated Use `entities`. Mantido por compat — o runtime/gen lê os dois. */
62
- models?: Record<string, unknown>
63
-
64
61
  /** Repository class do domínio. */
65
62
  repository?: unknown
66
63
 
@@ -152,7 +149,7 @@ export function isDomainConfig(value: unknown): value is DomainConfig {
152
149
  'schedules' in v ||
153
150
  'subdomains' in v ||
154
151
  'dicts' in v ||
155
- 'models' in v ||
152
+ 'entities' in v ||
156
153
  'repository' in v ||
157
154
  'service' in v
158
155
  )
@@ -5,8 +5,8 @@
5
5
  *
6
6
  * 1. **Pool com teto** — crie o `Pool` com `max` (e/ou use `maxConcurrent` aqui):
7
7
  * query de LLM não esgota as conexões do app.
8
- * 2. **Transação READ ONLY** — `BEGIN TRANSACTION READ ONLY`: escrita morre no
9
- * servidor, qualquer que seja o SQL.
8
+ * 2. **Transação READ ONLY** — `BEGIN TRANSACTION READ ONLY`: o servidor rejeita
9
+ * qualquer tentativa de escrita, independentemente do SQL recebido.
10
10
  * 3. **`SET LOCAL ROLE` por transação** — o alcance é do contexto: com grants
11
11
  * default-fechado, `sales_read` não enxerga `hr_employees.salary` nem por
12
12
  * SELECT criativo. (Criar os roles/grants é migração sua; aqui só se assume.)
@@ -310,7 +310,7 @@ export function entityTable(config: EntityConfig): string {
310
310
 
311
311
  /**
312
312
  * `true` se `value` parece um `EntityConfig`. Usado pra coletar entidades de
313
- * `domain.models` (que é `unknown`) sem confundir com action/reaction/model.
313
+ * `domain.entities` (que é `unknown`) sem confundir com action/reaction/model.
314
314
  * Heurística: tem `name` string + `fields` objeto e **não** tem `kind`
315
315
  * (descarta `ActionDef`).
316
316
  */
@@ -2,11 +2,8 @@
2
2
  * <ShellNav /> — o menu como primitivo PIVOTÁVEL. Um nav (grupos → itens, com heading e
3
3
  * âncora inferior) que serve os DOIS lares do chrome sem flag:
4
4
  *
5
- * - na sidebar do <AppShell> → recolhe pra ícone-só (com tooltip) quando a sidebar
6
- * recolhe, sozinho. Sabe que está NA sidebar (e se ela recolheu) pelo contexto de
7
- * slot do AppShell (`useSidebarSlot`) — o app não passa isso em duas mãos.
8
- * - dentro do conteúdo (o que o <SectionShell> faz) → sempre expandido (fora da sidebar,
9
- * `useSidebarSlot` é null).
5
+ * - dentro de <Sidebar> → recolhe pra ícone-só (com tooltip) junto com a coluna;
6
+ * - fora de <Sidebar> permanece expandido.
10
7
  *
11
8
  * "Tanto faz onde": o componente se adapta ao lugar, sem "modo" configurado.
12
9
  *
@@ -17,7 +14,7 @@
17
14
 
18
15
  import type { ReactNode } from 'react'
19
16
  import { cn } from '../../lib/cn.ts'
20
- import { useSidebarSlot } from './app-shell.tsx'
17
+ import { useSidebarCollapsed } from './sidebar.tsx'
21
18
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../primitives/tooltip.tsx'
22
19
 
23
20
  export interface ShellNavItem {
@@ -49,7 +46,7 @@ export interface ShellNavProps {
49
46
  footer?: ShellNavGroup[]
50
47
  /** Rótulo da landmark `<nav>`. */
51
48
  navLabel?: string
52
- /** Classes da raiz (a coluna). A LARGURA vem do lar (sidebar do AppShell / SectionShell). */
49
+ /** Classes da raiz (a coluna). A largura vem do pane/sidebar que a contém. */
53
50
  className?: string
54
51
  }
55
52
 
@@ -109,8 +106,7 @@ function renderGroup(group: ShellNavGroup, gi: number, activeId: string | undefi
109
106
  }
110
107
 
111
108
  export function ShellNav({ groups, activeId, onSelect, heading, footer, navLabel = 'Navegação', className }: ShellNavProps): React.ReactElement {
112
- // Recolhe na sidebar recolhida (o contexto de slot é null no conteúdo → sempre expandido).
113
- const railed = useSidebarSlot()?.collapsed ?? false
109
+ const railed = useSidebarCollapsed()
114
110
 
115
111
  const body = (
116
112
  <div data-slot="shell-nav" className={cn('flex h-full min-h-0 flex-col', className)}>
@@ -5,6 +5,11 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../pri
5
5
  interface SidebarContextValue { collapsed: boolean }
6
6
  const SidebarContext = createContext<SidebarContextValue | null>(null)
7
7
 
8
+ /** Estado da Sidebar mais próxima. Uso interno por navegações que se adaptam ao colapso. */
9
+ export function useSidebarCollapsed(): boolean {
10
+ return useContext(SidebarContext)?.collapsed ?? false
11
+ }
12
+
8
13
  export interface SidebarProps {
9
14
  collapsed?: boolean
10
15
  /** Desliga a borda quando o Split já desenha a divisória. */
@@ -39,27 +44,21 @@ export function PaneFooter({ className, children }: { className?: string; childr
39
44
  return <div data-slot="pane-footer" className={cn('shrink-0 border-t border-border p-2', className)}>{children}</div>
40
45
  }
41
46
 
42
- /** @deprecated Use PaneHeader. */
43
- export const SidebarHeader = PaneHeader
44
- /** @deprecated Use PaneContent. */
45
- export const SidebarContent = PaneContent
46
- /** @deprecated Use PaneFooter. */
47
- export const SidebarFooter = PaneFooter
48
-
49
47
  export interface SidebarItemProps {
50
48
  label: string
51
49
  icon?: ReactNode
52
50
  badge?: ReactNode
53
51
  active?: boolean
54
52
  disabled?: boolean
53
+ className?: string
55
54
  onClick: () => void
56
55
  }
57
56
 
58
57
  /** Item de navegação que se adapta ao colapso da Sidebar em que está. */
59
- export function SidebarItem({ label, icon, badge, active = false, disabled = false, onClick }: SidebarItemProps): React.ReactElement {
60
- const collapsed = useContext(SidebarContext)?.collapsed ?? false
58
+ export function SidebarItem({ label, icon, badge, active = false, disabled = false, className, onClick }: SidebarItemProps): React.ReactElement {
59
+ const collapsed = useSidebarCollapsed()
61
60
  const button = (
62
- <button type="button" disabled={disabled} aria-current={active ? 'page' : undefined} onClick={onClick} className={cn('flex items-center gap-2.5 rounded-md transition-colors', 'disabled:pointer-events-none disabled:opacity-40', collapsed ? 'mx-auto size-9 justify-center p-0' : 'w-full px-2.5 py-1.5 text-left text-sm', active ? 'bg-muted font-medium text-foreground' : 'text-foreground/80 hover:bg-muted/60')}>
61
+ <button type="button" disabled={disabled} aria-current={active ? 'page' : undefined} onClick={onClick} className={cn('flex items-center gap-2.5 rounded-md transition-colors', 'disabled:pointer-events-none disabled:opacity-40', collapsed ? 'mx-auto size-9 justify-center p-0' : 'w-full px-2.5 py-1.5 text-left text-sm', active ? 'bg-muted font-medium text-foreground' : 'text-foreground/80 hover:bg-muted/60', className)}>
63
62
  {icon !== undefined && <span className="flex shrink-0">{icon}</span>}
64
63
  {!collapsed && <span className="min-w-0 flex-1 truncate">{label}</span>}
65
64
  {!collapsed && badge !== undefined && <span className="shrink-0">{badge}</span>}
@@ -71,17 +70,43 @@ export function SidebarItem({ label, icon, badge, active = false, disabled = fal
71
70
 
72
71
  export interface SidebarNavGroup {
73
72
  label?: string
74
- items: Array<Omit<SidebarItemProps, 'active' | 'onClick'> & { id: string }>
73
+ items?: SidebarNavItem[]
74
+ subgroups?: SidebarNavSubgroup[]
75
+ }
76
+
77
+ export type SidebarNavItem = Omit<SidebarItemProps, 'active' | 'onClick'> & { id: string }
78
+
79
+ export interface SidebarNavSubgroup {
80
+ label?: string
81
+ items: SidebarNavItem[]
75
82
  }
76
83
 
84
+ const titled = (label?: string): boolean => (label ?? '').trim() !== ''
85
+ const subFilled = (subgroup: SidebarNavSubgroup): boolean => subgroup.items.length > 0
86
+ const groupFilled = (group: SidebarNavGroup): boolean =>
87
+ (group.items?.length ?? 0) > 0 || (group.subgroups?.some(subFilled) ?? false)
88
+
77
89
  /** Navegação controlada para Sidebar. Header/footer continuam slots explícitos do pai. */
78
90
  export function SidebarNav({ groups, activeId, onSelect, navLabel = 'Navegação', className }: { groups: SidebarNavGroup[]; activeId?: string; onSelect: (id: string) => void; navLabel?: string; className?: string }): React.ReactElement {
79
- const collapsed = useContext(SidebarContext)?.collapsed ?? false
91
+ const collapsed = useSidebarCollapsed()
92
+ const renderItem = (item: SidebarNavItem, nested = false): React.ReactElement => (
93
+ <SidebarItem
94
+ key={item.id}
95
+ {...item}
96
+ className={cn(nested && !collapsed && 'pl-6', item.className)}
97
+ active={item.id === activeId}
98
+ onClick={() => onSelect(item.id)}
99
+ />
100
+ )
80
101
  const body = (
81
102
  <nav aria-label={navLabel} data-slot="sidebar-nav" className={cn('space-y-4 p-2', className)}>
82
- {groups.map((group, gi) => <div key={gi} className="space-y-0.5">
83
- {!collapsed && group.label?.trim() && <div className="px-2.5 pb-1 text-xs font-medium text-muted-foreground/70">{group.label}</div>}
84
- {group.items.map(({ id, ...item }) => <SidebarItem key={id} {...item} active={id === activeId} onClick={() => onSelect(id)} />)}
103
+ {groups.map((group, gi) => !groupFilled(group) ? null : <div key={gi} className="space-y-0.5">
104
+ {!collapsed && titled(group.label) && <div className="px-2.5 pb-1 text-xs font-medium text-muted-foreground/70">{group.label}</div>}
105
+ {group.items?.map((item) => renderItem(item))}
106
+ {group.subgroups?.map((subgroup, si) => !subFilled(subgroup) ? null : <div key={si} className="space-y-0.5">
107
+ {!collapsed && titled(subgroup.label) && <div className="pb-0.5 pl-4 pr-2.5 pt-1.5 text-[11px] font-medium text-muted-foreground/50">{subgroup.label}</div>}
108
+ {subgroup.items.map((item) => renderItem(item, titled(subgroup.label)))}
109
+ </div>)}
85
110
  </div>)}
86
111
  </nav>
87
112
  )
@@ -14,7 +14,8 @@
14
14
  */
15
15
  import { useEffect } from 'react'
16
16
  import { DOC_SECTIONS, type DocSection, type DocEntry } from './registry'
17
- import { SectionShell, type SectionNavGroup } from '../components/patterns/section-shell.tsx'
17
+ import { Pane, Split } from '../components/patterns/split.tsx'
18
+ import { PaneContent, Sidebar, SidebarNav, type SidebarNavGroup } from '../components/patterns/sidebar.tsx'
18
19
  import { navigate, usePathname } from '../router.ts'
19
20
 
20
21
  function findPage(sections: DocSection[], slug: string): DocEntry | undefined {
@@ -70,21 +71,35 @@ export function DocBrowser({
70
71
  // Seção → grupo, e os grupos da seção → subgrupos. O tier do meio NÃO pode ser
71
72
  // achatado: `docSectionsFromFolder` o preenche a partir de sub-pasta (ou do
72
73
  // frontmatter `group:`), que é o caminho do `opusDocs({ source })`.
73
- const navGroups: SectionNavGroup[] = sections.map((section) => ({
74
+ const navGroups: SidebarNavGroup[] = sections.map((section) => ({
74
75
  label: section.label,
75
76
  subgroups: section.groups.map((g) => ({
76
77
  label: g.label,
77
- items: g.pages.map((p) => ({ id: p.slug, label: p.title, badge: p.badge })),
78
+ items: g.pages.map((p) => ({
79
+ id: p.slug,
80
+ label: p.title,
81
+ badge: p.badge === undefined ? undefined : <span className="rounded border border-border/60 px-1 font-mono text-[10px] leading-tight text-muted-foreground/60">{p.badge}</span>,
82
+ })),
78
83
  })),
79
84
  }))
80
85
 
81
86
  return (
82
- <SectionShell
83
- groups={navGroups}
84
- activeId={page?.slug}
85
- onSelect={(slug) => go(`${basePath}/${slug}`)}
86
- >
87
- <div className="mx-auto max-w-3xl px-8 py-8">{page?.render()}</div>
88
- </SectionShell>
87
+ <Split className="h-full">
88
+ <Pane inset="none" className="w-56">
89
+ <Sidebar className="w-full">
90
+ <PaneContent>
91
+ <SidebarNav
92
+ groups={navGroups}
93
+ activeId={page?.slug}
94
+ navLabel="Navegação da documentação"
95
+ onSelect={(slug) => go(`${basePath}/${slug}`)}
96
+ />
97
+ </PaneContent>
98
+ </Sidebar>
99
+ </Pane>
100
+ <Pane key={page?.slug} grow inset="none" className="overflow-y-auto">
101
+ <div className="mx-auto max-w-3xl px-8 py-8">{page?.render()}</div>
102
+ </Pane>
103
+ </Split>
89
104
  )
90
105
  }
@@ -4,9 +4,9 @@ title: Actions & contratos
4
4
 
5
5
  # Actions & contratos
6
6
 
7
- A action é a unidade do Opus. O contrato (entidade + input/output + metadados) mora no package
8
- compartilhado; a API amarra o handler; a web renderiza a partir do mesmo contrato. Um shape,
9
- três consumidores.
7
+ Uma action descreve uma operação que cliente e servidor precisam compreender da mesma forma.
8
+ Seu contrato reúne entidade, entrada, saída e metadados no package compartilhado. A API conecta
9
+ o handler e a web usa a mesma definição para executar ou renderizar a operação.
10
10
 
11
11
  ## Contrato primeiro
12
12
 
@@ -51,8 +51,9 @@ export const workspaceCreate = bindAction(workspaceCreateContract, {
51
51
 
52
52
  ## Ordem canônica dos campos
53
53
 
54
- > O `opus check` reprova fora desta ordem é a régua, 0 violação antes de entregar. A skill
55
- > `create-opus-action` gera contrato e binding no formato certo por construção.
54
+ > Esta ordem mantém contratos previsíveis para leitura e geração. O `opus check` informa os
55
+ > campos que precisam ser reposicionados, e a skill `create-opus-action` cria o contrato e o
56
+ > binding nessa estrutura.
56
57
 
57
58
  ```text
58
59
  name → kind → (label / summary / messages / tags…)
@@ -130,7 +131,7 @@ descartável — vira o contrato que o backend honra.
130
131
  import { useLookupAction } from '@softize/opus/client'
131
132
  import { ActionForm } from '@softize/opus/ui/react'
132
133
 
133
- // Lista paginada, tipada pelo contrato zero shape duplicado.
134
+ // O contrato mantém a lista paginada tipada sem exigir uma definição paralela.
134
135
  const { rows, fetchNextPage } = useLookupAction(workspaceListContract)
135
136
 
136
137
  // Form contract-driven: os campos (fields) moram NO contrato.