@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.
- package/CHANGELOG.md +71 -0
- package/bin/cli.mjs +9 -1
- package/bin/lib/check.mjs +37 -29
- package/docs/adr/0004-page-content-state-is-composed.md +3 -2
- package/docs/adr/0005-structural-surfaces-share-an-explicit-anatomy.md +3 -0
- package/docs/adr/0006-semantic-context-precedes-visual-variant.md +23 -14
- package/docs/adr/0011-page-shell-coordinates-persistent-page-chrome.md +5 -0
- package/docs/adr/0013-presentation-is-a-portable-action-oriented-artifact.md +7 -4
- package/docs/adr/0016-list-collection-header-belongs-to-content.md +13 -8
- package/docs/adr/0023-surfaces-share-one-anatomy-and-two-layouts.md +27 -26
- package/docs/adr/0025-content-frame-treatment-does-not-change-semantics.md +1 -0
- package/docs/adr/0029-page-header-is-the-single-page-introduction.md +71 -0
- package/docs/adr/0030-framed-content-can-use-sectioned-anatomy.md +89 -0
- package/docs/adr/0031-detail-dividers-own-the-tabular-label-treatment.md +61 -0
- package/docs/adr/0032-tab-baseline-respects-the-container-inset.md +59 -0
- package/docs/adr/0033-drawer-uses-an-inset-structural-frame.md +72 -0
- package/docs/adr/0034-tabs-can-own-a-frame-without-owning-a-surface.md +60 -0
- package/docs/adr/0035-button-group-separates-frame-relationship-and-distribution.md +63 -0
- package/docs/adr/0036-tabs-declares-trigger-distribution.md +58 -0
- package/docs/adr/0037-design-data-lives-outside-the-contract.md +117 -0
- package/docs/radius-scale.md +5 -2
- package/package.json +5 -1
- package/registry/skills/build-opus-ui/SKILL.md +9 -6
- package/registry/skills/build-opus-ui/references/evaluations.md +5 -2
- package/registry/skills/build-opus-ui/references/ui-patterns.md +14 -16
- package/registry/skills/create-opus-action/references/contract-and-binding.md +5 -3
- package/registry/skills/create-opus-action/scripts/scaffold.mjs +0 -4
- package/registry/skills/test-opus-action/SKILL.md +2 -2
- package/registry/skills/test-opus-action/references/harness.md +2 -2
- package/registry/templates/app/src/App.tsx +1 -1
- package/registry/templates/app/src/domains/tasks/actions/list.test.ts +2 -19
- package/registry/templates/app/src/domains/tasks/actions/list.ts +3 -5
- package/registry/templates/app/vite.config.ts +3 -2
- package/src/core/domain.ts +2 -2
- package/src/core/types.ts +6 -20
- package/src/design/index.ts +179 -0
- package/src/testing/fake.ts +2 -2
- package/src/testing/index.ts +7 -3
- package/src/ui/components/patterns/content-header.tsx +51 -13
- package/src/ui/components/patterns/list.tsx +51 -33
- package/src/ui/components/patterns/page.tsx +139 -251
- package/src/ui/components/patterns/presentation.tsx +2 -6
- package/src/ui/components/primitives/button-group.tsx +80 -8
- package/src/ui/components/primitives/button.tsx +85 -60
- package/src/ui/components/primitives/detail.tsx +63 -57
- package/src/ui/components/primitives/dialog.tsx +8 -11
- package/src/ui/components/primitives/drawer.tsx +16 -12
- package/src/ui/components/primitives/tabs.tsx +123 -54
- package/src/ui/docs/content/actions.md +49 -43
- package/src/ui/docs/content/button.md +55 -29
- package/src/ui/docs/content/content.md +61 -36
- package/src/ui/docs/content/detail.md +28 -19
- package/src/ui/docs/content/drawer.md +53 -47
- package/src/ui/docs/content/input.md +51 -35
- package/src/ui/docs/content/page.md +84 -95
- package/src/ui/docs/content/pane.md +83 -63
- package/src/ui/docs/content/presentation.md +20 -20
- package/src/ui/docs/content/tabs.md +107 -36
- package/src/ui/docs/content/testing.md +9 -25
- package/src/ui/meta.ts +5 -5
- package/src/ui/react.tsx +0 -1
- package/src/vite/design.ts +133 -23
package/src/vite/design.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
198
|
-
//
|
|
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
|
|
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
|
|
227
|
-
//
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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:
|
|
349
|
-
|
|
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
|
|
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 }
|