@softize/opus 21.0.1 → 22.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 (62) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/bin/cli.mjs +9 -1
  3. package/bin/lib/check.mjs +37 -29
  4. package/docs/adr/0004-page-content-state-is-composed.md +3 -2
  5. package/docs/adr/0005-structural-surfaces-share-an-explicit-anatomy.md +3 -0
  6. package/docs/adr/0006-semantic-context-precedes-visual-variant.md +23 -14
  7. package/docs/adr/0011-page-shell-coordinates-persistent-page-chrome.md +5 -0
  8. package/docs/adr/0013-presentation-is-a-portable-action-oriented-artifact.md +7 -4
  9. package/docs/adr/0016-list-collection-header-belongs-to-content.md +13 -8
  10. package/docs/adr/0023-surfaces-share-one-anatomy-and-two-layouts.md +27 -26
  11. package/docs/adr/0025-content-frame-treatment-does-not-change-semantics.md +1 -0
  12. package/docs/adr/0029-page-header-is-the-single-page-introduction.md +71 -0
  13. package/docs/adr/0030-framed-content-can-use-sectioned-anatomy.md +89 -0
  14. package/docs/adr/0031-detail-dividers-own-the-tabular-label-treatment.md +61 -0
  15. package/docs/adr/0032-tab-baseline-respects-the-container-inset.md +59 -0
  16. package/docs/adr/0033-drawer-uses-an-inset-structural-frame.md +72 -0
  17. package/docs/adr/0034-tabs-can-own-a-frame-without-owning-a-surface.md +60 -0
  18. package/docs/adr/0035-button-group-separates-frame-relationship-and-distribution.md +63 -0
  19. package/docs/adr/0036-tabs-declares-trigger-distribution.md +58 -0
  20. package/docs/adr/0037-design-data-lives-outside-the-contract.md +117 -0
  21. package/docs/radius-scale.md +5 -2
  22. package/package.json +5 -1
  23. package/registry/skills/build-opus-ui/SKILL.md +9 -6
  24. package/registry/skills/build-opus-ui/references/evaluations.md +5 -2
  25. package/registry/skills/build-opus-ui/references/ui-patterns.md +14 -16
  26. package/registry/skills/create-opus-action/references/contract-and-binding.md +5 -3
  27. package/registry/skills/create-opus-action/scripts/scaffold.mjs +0 -4
  28. package/registry/skills/test-opus-action/SKILL.md +2 -2
  29. package/registry/skills/test-opus-action/references/harness.md +2 -2
  30. package/registry/templates/app/src/App.tsx +1 -1
  31. package/registry/templates/app/src/domains/tasks/actions/list.test.ts +2 -19
  32. package/registry/templates/app/src/domains/tasks/actions/list.ts +3 -5
  33. package/registry/templates/app/vite.config.ts +3 -2
  34. package/src/core/domain.ts +2 -2
  35. package/src/core/types.ts +6 -20
  36. package/src/design/index.ts +179 -0
  37. package/src/testing/fake.ts +2 -2
  38. package/src/testing/index.ts +7 -3
  39. package/src/ui/components/patterns/content-header.tsx +51 -13
  40. package/src/ui/components/patterns/list.tsx +51 -33
  41. package/src/ui/components/patterns/page.tsx +139 -251
  42. package/src/ui/components/patterns/presentation.tsx +2 -6
  43. package/src/ui/components/primitives/button-group.tsx +80 -8
  44. package/src/ui/components/primitives/button.tsx +85 -60
  45. package/src/ui/components/primitives/detail.tsx +63 -57
  46. package/src/ui/components/primitives/dialog.tsx +8 -11
  47. package/src/ui/components/primitives/drawer.tsx +16 -12
  48. package/src/ui/components/primitives/tabs.tsx +123 -54
  49. package/src/ui/docs/content/actions.md +49 -43
  50. package/src/ui/docs/content/button.md +55 -29
  51. package/src/ui/docs/content/content.md +61 -36
  52. package/src/ui/docs/content/detail.md +28 -19
  53. package/src/ui/docs/content/drawer.md +53 -47
  54. package/src/ui/docs/content/input.md +51 -35
  55. package/src/ui/docs/content/page.md +84 -95
  56. package/src/ui/docs/content/pane.md +83 -63
  57. package/src/ui/docs/content/presentation.md +20 -20
  58. package/src/ui/docs/content/tabs.md +107 -36
  59. package/src/ui/docs/content/testing.md +9 -25
  60. package/src/ui/meta.ts +5 -5
  61. package/src/ui/react.tsx +0 -1
  62. package/src/vite/design.ts +133 -23
@@ -6,7 +6,7 @@
6
6
  * Antes, o dev server proxeava `/api` pra um backend real — o que vaza escrita
7
7
  * pra prod e exige infra de pé pra desenhar tela. Com o plugin, `vite --mode
8
8
  * design` monta o runtime (driver `nodeServer`) no próprio dev server: os
9
- * `mockHandler` dos contratos respondem (`OPUS_MODE=design`), qualquer proxy
9
+ * actions respondem pelos dados de design (ADR 0037), qualquer proxy
10
10
  * configurado é DESLIGADO (prefixo ex-proxy sem cobertura responde 503 em
11
11
  * envelope, nunca vaza), e o Maestro sonda `GET /__opus/design` pra saber que
12
12
  * o preview está montado.
@@ -44,6 +44,7 @@ import {
44
44
  isDomainConfig,
45
45
  isReaction,
46
46
  isSchedule,
47
+ type ActionContext,
47
48
  type ActionDef,
48
49
  type AuthAdapter,
49
50
  type ErrorInput,
@@ -53,6 +54,7 @@ import {
53
54
  type User,
54
55
  } from '../core/index.ts'
55
56
  import { readPackageVersion } from '../core/package-version.ts'
57
+ import { designResponse, isDesignScenario, type DesignScenario } from '../design/index.ts'
56
58
  import { mittEvents } from '../events/drivers/mitt.ts'
57
59
  import { nodeServer, type NodeServerHandle } from '../server/drivers/node.ts'
58
60
  import { serializeResult } from '../server/index.ts'
@@ -84,6 +86,14 @@ export interface OpusDesignOptions {
84
86
  */
85
87
  entry?: string | string[]
86
88
 
89
+ /**
90
+ * Cenários de design (ADR 0037): módulos que exportam `defineDesignScenario(contrato, resposta)`.
91
+ * Aceita caminhos e globs, como `entry`. O cenário vence o `mockHandler` e a resposta padrão
92
+ * gerada pelo `fake`; cenário de action inexistente impede o boot, com a lista no erro.
93
+ * Sem default: o projeto decide onde os cenários ficam e se são versionados.
94
+ */
95
+ scenarios?: string | string[]
96
+
87
97
  /** Prefixo do path. Default '/api'. */
88
98
  apiPrefix?: string
89
99
 
@@ -145,10 +155,14 @@ const PROBE_PATH = '/__opus/design'
145
155
  // deixá-los chegar no handler (moram fora do apiPrefix).
146
156
  const RUNTIME_ENDPOINT_PATHS = new Set(['/health', '/ready', '/openapi.json'])
147
157
 
158
+ /** De onde vem a resposta de cada action no modo design, na ordem de precedência. */
159
+ type DesignSource = 'scenario' | 'mock' | 'handler' | 'generated'
160
+
148
161
  interface DesignState {
149
162
  handle: NodeServerHandle
150
163
  runtime: Runtime
151
164
  actions: ActionDef[]
165
+ sources: Map<string, DesignSource>
152
166
  }
153
167
 
154
168
  export function opusDesign(options: OpusDesignOptions = {}): Plugin {
@@ -156,6 +170,12 @@ export function opusDesign(options: OpusDesignOptions = {}): Plugin {
156
170
  const entries = Array.isArray(options.entry)
157
171
  ? options.entry
158
172
  : [options.entry ?? './opus.config.ts']
173
+ const scenarioEntries =
174
+ options.scenarios === undefined
175
+ ? []
176
+ : Array.isArray(options.scenarios)
177
+ ? options.scenarios
178
+ : [options.scenarios]
159
179
  const devBackend = options.devBackend === true
160
180
  // Mais-específico primeiro: prefixo mais longo vence.
161
181
  const stubEntries = Object.entries(options.stubs ?? {}).sort(
@@ -194,8 +214,8 @@ export function opusDesign(options: OpusDesignOptions = {}): Plugin {
194
214
  exProxyPrefixes = Object.keys(config.server?.proxy ?? {})
195
215
  // 2) Mata o proxy: fecha o vazamento preview→prod pra QUALQUER prefixo.
196
216
  if (config.server?.proxy !== undefined) config.server.proxy = undefined
197
- // 3) O execute() roteia handler×mockHandler lendo OPUS_MODE (isOpusDesignMode);
198
- // com o runtime local, o preview fica autossuficiente.
217
+ // 3) O execute() roteia pro mockHandler lendo OPUS_MODE (isOpusDesignMode); o boot
218
+ // liga em cada action a resposta de design, e o preview fica autossuficiente.
199
219
  process.env.OPUS_MODE = 'design'
200
220
  },
201
221
 
@@ -215,7 +235,14 @@ export function opusDesign(options: OpusDesignOptions = {}): Plugin {
215
235
  registrables.push(...(await loadEntry(server, entry)))
216
236
  }
217
237
  }
218
- const { actions, dropped } = collectActions(registrables)
238
+ const collected = collectActions(registrables)
239
+ const { dropped } = collected
240
+ const scenarios = isDesign
241
+ ? await loadScenarios(server, scenarioEntries, collected.actions)
242
+ : new Map<string, DesignScenario>()
243
+ const { actions, sources } = isDesign
244
+ ? prepareDesignActions(collected.actions, scenarios)
245
+ : { actions: collected.actions, sources: new Map<string, DesignSource>() }
219
246
 
220
247
  const handle = nodeServer({ apiPrefix })
221
248
  const runtime = createRuntime({
@@ -223,8 +250,8 @@ export function opusDesign(options: OpusDesignOptions = {}): Plugin {
223
250
  auth: designAuth({ user: options.user, tenantId: options.tenantId }),
224
251
  eventBus: mittEvents(),
225
252
  storage: memStorage(),
226
- // SEM `data` de propósito: design não toca ctx.db — handler real sem
227
- // mock falhando é o sinal certo de que falta mockar.
253
+ // SEM `data` de propósito: design não toca ctx.db. Uma action ligada a handler real
254
+ // que dependa do banco falha aqui; o caminho é um cenário (ADR 0037).
228
255
  ...options.runtime,
229
256
  config: { env: 'development', dev: true, ...options.runtime?.config },
230
257
  })
@@ -236,19 +263,9 @@ export function opusDesign(options: OpusDesignOptions = {}): Plugin {
236
263
  `[opus-design] ${dropped} reaction(s)/schedule(s) ignorada(s) — o runtime de design é request/response.`,
237
264
  )
238
265
  }
239
- if (isDesign) {
240
- for (const action of actions) {
241
- if (action.mockHandler === undefined) {
242
- logger.warn(
243
- `[opus-design] Action "${action.name}" sem mockHandler — em design cai no handler real e pode falhar sem adapters.`,
244
- )
245
- }
246
- }
247
- }
248
-
249
266
  await runtime.start()
250
267
  handle.adapter.mountEndpoints({ health: true, ready: true, openapi: true })
251
- return { handle, runtime, actions }
268
+ return { handle, runtime, actions, sources }
252
269
  }
253
270
 
254
271
  // Promise-mutex: requests em voo aguardam o MESMO boot; rebuild (stale)
@@ -285,8 +302,9 @@ export function opusDesign(options: OpusDesignOptions = {}): Plugin {
285
302
  // add/unlink: QUALQUER arquivo sob o root (fora node_modules/.git) —
286
303
  // arquivo novo nunca importado não está no graph, e é exatamente ele
287
304
  // que um entry glob precisa enxergar. Over-invalidation é barata.
305
+ const watchedEntries = [...entries, ...scenarioEntries]
288
306
  const entryPaths = new Set(
289
- entries
307
+ watchedEntries
290
308
  .filter((entry) => !isGlobPattern(entry))
291
309
  .map((entry) => path.resolve(server.config.root, entry)),
292
310
  )
@@ -294,7 +312,7 @@ export function opusDesign(options: OpusDesignOptions = {}): Plugin {
294
312
  // pode apontar pra FORA dele (ex.: '../../services/*/contract.ts', caso GB):
295
313
  // mandamos o chokidar observar o PREFIXO ESTÁTICO de cada glob — o mesmo que
296
314
  // o expandGlob anda, nunca o mundo — pra change/unlink lá também invalidar.
297
- const globSpecs = entries
315
+ const globSpecs = watchedEntries
298
316
  .filter(isGlobPattern)
299
317
  .map((pattern) => globSpec(server.config.root, pattern))
300
318
  for (const { prefixDir } of globSpecs) server.watcher.add(prefixDir)
@@ -341,12 +359,21 @@ export function opusDesign(options: OpusDesignOptions = {}): Plugin {
341
359
  }
342
360
  try {
343
361
  const state = await ensureBoot()
344
- const unmocked = state.actions.filter((a) => a.mockHandler === undefined)
362
+ const named = (source: DesignSource): string[] =>
363
+ state.actions.filter((a) => state.sources.get(a.name) === source).map((a) => a.name)
364
+ const scenarios = named('scenario')
365
+ const mocks = named('mock')
345
366
  payload.runtime = {
346
367
  prefix: apiPrefix,
347
368
  actions: state.actions.length,
348
- mocked: state.actions.length - unmocked.length,
349
- unmocked: unmocked.map((a) => a.name),
369
+ // `mocked`/`unmocked` mantêm o formato anterior da sonda: resposta escrita à mão
370
+ // (cenário ou mockHandler) contra o resto, que responde por handler real ou `fake`.
371
+ mocked: scenarios.length + mocks.length,
372
+ unmocked: state.actions
373
+ .filter((a) => !['scenario', 'mock'].includes(state.sources.get(a.name) ?? ''))
374
+ .map((a) => a.name),
375
+ scenarios,
376
+ generated: named('generated'),
350
377
  }
351
378
  } catch (err) {
352
379
  payload.error = String(err)
@@ -605,6 +632,89 @@ function registrablesOfModule(mod: Record<string, unknown>): unknown[] {
605
632
  return [...out]
606
633
  }
607
634
 
635
+ // =============================================================================
636
+ // Dados de design (ADR 0037)
637
+ // =============================================================================
638
+
639
+ /**
640
+ * Carrega os cenários dos módulos casados por `scenarios`. Cenário de action inexistente ou dois
641
+ * cenários para a mesma action impedem o boot: o preview mostra o erro em vez de silenciar.
642
+ */
643
+ async function loadScenarios(
644
+ server: ViteDevServer,
645
+ patterns: string[],
646
+ actions: ActionDef[],
647
+ ): Promise<Map<string, DesignScenario>> {
648
+ const scenarios = new Map<string, DesignScenario>()
649
+ const known = new Set(actions.map((action) => action.name))
650
+ const unknown: string[] = []
651
+ const duplicated: string[] = []
652
+ for (const pattern of patterns) {
653
+ const files = isGlobPattern(pattern) ? expandGlob(server.config.root, pattern) : [normalizeEntryUrl(pattern)]
654
+ for (const file of files) {
655
+ const mod = (await server.ssrLoadModule(file)) as Record<string, unknown>
656
+ for (const scenario of scenariosOfModule(mod)) {
657
+ if (!known.has(scenario.action)) unknown.push(scenario.action)
658
+ else if (scenarios.has(scenario.action) && scenarios.get(scenario.action) !== scenario) {
659
+ duplicated.push(scenario.action)
660
+ } else scenarios.set(scenario.action, scenario)
661
+ }
662
+ }
663
+ }
664
+ if (unknown.length > 0 || duplicated.length > 0) {
665
+ const names = (list: string[]): string => [...new Set(list)].join(', ')
666
+ const problems = [
667
+ unknown.length > 0 ? `sem action registrada: ${names(unknown)}` : '',
668
+ duplicated.length > 0 ? `mais de um cenário para: ${names(duplicated)}` : '',
669
+ ].filter(Boolean)
670
+ throw error({
671
+ code: 'design.invalid_scenario',
672
+ category: 'internal',
673
+ message: `Cenários de design inválidos (${problems.join('; ')}).`,
674
+ })
675
+ }
676
+ return scenarios
677
+ }
678
+
679
+ function scenariosOfModule(mod: Record<string, unknown>): DesignScenario[] {
680
+ const out = new Set<DesignScenario>()
681
+ const add = (value: unknown): void => {
682
+ if (Array.isArray(value)) value.forEach(add)
683
+ else if (isDesignScenario(value)) out.add(value)
684
+ }
685
+ Object.values(mod).forEach(add)
686
+ return [...out]
687
+ }
688
+
689
+ /**
690
+ * Liga a cada action a resposta de design, nesta precedência: cenário, `mockHandler` declarado,
691
+ * handler real (`bindAction`) e, por fim, a resposta padrão gerada pelo `fake`.
692
+ */
693
+ function prepareDesignActions(
694
+ actions: ActionDef[],
695
+ scenarios: Map<string, DesignScenario>,
696
+ ): { actions: ActionDef[]; sources: Map<string, DesignSource> } {
697
+ const sources = new Map<string, DesignSource>()
698
+ const prepared = actions.map((action): ActionDef => {
699
+ const scenario = scenarios.get(action.name)
700
+ if (scenario !== undefined) {
701
+ sources.set(action.name, 'scenario')
702
+ return { ...action, mockHandler: (ctx: ActionContext, input: unknown) => scenario.respond(input, ctx) } as ActionDef
703
+ }
704
+ if (action.mockHandler !== undefined) {
705
+ sources.set(action.name, 'mock')
706
+ return action
707
+ }
708
+ if (typeof (action as { handler?: unknown }).handler === 'function') {
709
+ sources.set(action.name, 'handler')
710
+ return action
711
+ }
712
+ sources.set(action.name, 'generated')
713
+ return { ...action, mockHandler: async (_ctx: ActionContext, input: unknown) => designResponse(action, input) } as ActionDef
714
+ })
715
+ return { actions: prepared, sources }
716
+ }
717
+
608
718
  /** Achata os registráveis em actions; reactions/schedules ficam de fora (contados). */
609
719
  function collectActions(items: unknown[]): { actions: ActionDef[]; dropped: number } {
610
720
  const actions: ActionDef[] = []
@@ -621,7 +731,7 @@ function collectActions(items: unknown[]): { actions: ActionDef[]; dropped: numb
621
731
  dropped += 1
622
732
  continue
623
733
  }
624
- // ActionDef ou contrato (sem handler): em design o mockHandler responde.
734
+ // ActionDef ou contrato (sem handler): em design, a resposta de design responde.
625
735
  actions.push(item as ActionDef)
626
736
  }
627
737
  return { actions, dropped }