@softize/opus 16.0.0 → 16.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 CHANGED
@@ -7,6 +7,15 @@ 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
+ ## 16.1.0 — 2026-09-11
11
+
12
+ `PresentationDevtoolsProvider`, `usePresentationRegistration` e `PresentationDevtools` permitem
13
+ que o shell reúna as Presentations ativas em um único launcher de desenvolvimento. Cada recurso
14
+ registra sua definição e o estado vivo da invocação; a pessoa escolhe qual snapshot inspecionar no
15
+ menu. O JSON continua mascarando dados sensíveis e só é materializado depois da escolha, evitando
16
+ serialização desnecessária durante a renderização. O consumidor deve montar o provider, os registros
17
+ e o launcher apenas em ambiente de desenvolvimento.
18
+
10
19
  ## 16.0.0 — 2026-09-11
11
20
 
12
21
  Opus 16 introduz `Presentation` como artefato público declarativo, disponível também pelo subpath
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "16.0.0",
3
+ "version": "16.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",
@@ -1,4 +1,15 @@
1
- import { useState, type ReactElement, type ReactNode } from "react";
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useId,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ type ReactElement,
11
+ type ReactNode,
12
+ } from "react";
2
13
  import { Braces, X } from "lucide-react";
3
14
  import type {
4
15
  PresentationDiagnostic,
@@ -20,6 +31,13 @@ import {
20
31
  DialogHeader,
21
32
  DialogTitle,
22
33
  } from "../primitives/dialog.tsx";
34
+ import {
35
+ Menu,
36
+ MenuContent,
37
+ MenuItem,
38
+ MenuLabel,
39
+ MenuTrigger,
40
+ } from "../primitives/menu.tsx";
23
41
  import {
24
42
  Drawer,
25
43
  DrawerBody,
@@ -248,6 +266,211 @@ export interface PresentationInspectorProps {
248
266
  className?: string;
249
267
  }
250
268
 
269
+ export interface PresentationRegistration
270
+ extends Pick<
271
+ PresentationInspectorProps,
272
+ "invocation" | "resolved" | "diagnostics"
273
+ > {
274
+ definition: PresentationDefinition;
275
+ /** Retira temporariamente a Presentation do inventário ativo sem desmontar o consumidor. */
276
+ enabled?: boolean;
277
+ }
278
+
279
+ interface ActivePresentationRegistration
280
+ extends Omit<PresentationRegistration, "enabled"> {
281
+ key: string;
282
+ }
283
+
284
+ interface PresentationRegistrationContextValue {
285
+ upsert: (key: string, registration: ActivePresentationRegistration) => void;
286
+ remove: (key: string) => void;
287
+ }
288
+
289
+ interface PresentationDevtoolsContextValue {
290
+ keys: readonly string[];
291
+ read: (key: string) => ActivePresentationRegistration | undefined;
292
+ }
293
+
294
+ const PresentationRegistrationContext =
295
+ createContext<PresentationRegistrationContextValue | null>(null);
296
+ const PresentationDevtoolsContext =
297
+ createContext<PresentationDevtoolsContextValue | null>(null);
298
+
299
+ export interface PresentationDevtoolsProviderProps {
300
+ children: ReactNode;
301
+ }
302
+
303
+ /** Mantém o inventário das Presentations montadas para uma única ferramenta do shell. */
304
+ export function PresentationDevtoolsProvider({
305
+ children,
306
+ }: PresentationDevtoolsProviderProps): ReactElement {
307
+ const registrations = useRef(
308
+ new Map<string, ActivePresentationRegistration>(),
309
+ );
310
+ const [keys, setKeys] = useState<readonly string[]>([]);
311
+ const upsert = useCallback(
312
+ (key: string, registration: ActivePresentationRegistration): void => {
313
+ const exists = registrations.current.has(key);
314
+ registrations.current.set(key, registration);
315
+ if (!exists) setKeys((current) => [...current, key]);
316
+ },
317
+ [],
318
+ );
319
+ const remove = useCallback((key: string): void => {
320
+ if (!registrations.current.delete(key)) return;
321
+ setKeys((current) => current.filter((candidate) => candidate !== key));
322
+ }, []);
323
+ const read = useCallback(
324
+ (key: string) => registrations.current.get(key),
325
+ [],
326
+ );
327
+ const registration = useMemo(() => ({ upsert, remove }), [upsert, remove]);
328
+ const devtools = useMemo(() => ({ keys, read }), [keys, read]);
329
+
330
+ return (
331
+ <PresentationRegistrationContext.Provider value={registration}>
332
+ <PresentationDevtoolsContext.Provider value={devtools}>
333
+ {children}
334
+ </PresentationDevtoolsContext.Provider>
335
+ </PresentationRegistrationContext.Provider>
336
+ );
337
+ }
338
+
339
+ /** Anuncia um snapshot ao provider mais próximo; fora dele, permanece inofensivo. */
340
+ export function usePresentationRegistration({
341
+ definition,
342
+ invocation,
343
+ resolved,
344
+ diagnostics,
345
+ enabled = true,
346
+ }: PresentationRegistration): void {
347
+ const context = useContext(PresentationRegistrationContext);
348
+ const upsert = context?.upsert;
349
+ const remove = context?.remove;
350
+ const key = useId();
351
+
352
+ // Sem dependências de propósito: os dados podem mudar mantendo a mesma Presentation.
353
+ // `upsert` substitui o snapshot em memória sem provocar um ciclo de render.
354
+ useEffect(() => {
355
+ if (upsert === undefined || !enabled) {
356
+ remove?.(key);
357
+ return;
358
+ }
359
+ upsert(key, { key, definition, invocation, resolved, diagnostics });
360
+ });
361
+
362
+ useEffect(
363
+ () => () => {
364
+ remove?.(key);
365
+ },
366
+ [key, remove],
367
+ );
368
+ }
369
+
370
+ interface SelectedPresentation {
371
+ key: string;
372
+ title: string;
373
+ json: string;
374
+ }
375
+
376
+ export interface PresentationDevtoolsProps {
377
+ /** Nome acessível e tooltip do launcher. */
378
+ triggerLabel?: string;
379
+ className?: string;
380
+ }
381
+
382
+ /** Launcher único do shell para escolher e inspecionar qualquer Presentation ativa. */
383
+ export function PresentationDevtools({
384
+ triggerLabel = "Inspecionar Presentations ativas",
385
+ className,
386
+ }: PresentationDevtoolsProps): ReactElement | null {
387
+ const registry = useContext(PresentationDevtoolsContext);
388
+ const [selected, setSelected] = useState<SelectedPresentation | null>(null);
389
+ const [menuOpen, setMenuOpen] = useState(false);
390
+ const registrations =
391
+ registry?.keys.flatMap((key) => {
392
+ const registration = registry.read(key);
393
+ return registration === undefined ? [] : [registration];
394
+ }) ?? [];
395
+
396
+ useEffect(() => {
397
+ if (selected !== null && !registry?.keys.includes(selected.key)) {
398
+ setSelected(null);
399
+ }
400
+ }, [registry?.keys, selected]);
401
+
402
+ if (registrations.length === 0) return null;
403
+
404
+ function inspect(key: string): void {
405
+ const registration = registry?.read(key);
406
+ if (registration === undefined) return;
407
+ const json =
408
+ JSON.stringify(createPresentationInspectionSnapshot(registration), null, 2) ??
409
+ "null";
410
+ setSelected({ key, title: registration.definition.title, json });
411
+ }
412
+
413
+ return (
414
+ <>
415
+ <Menu open={menuOpen} onOpenChange={setMenuOpen}>
416
+ <MenuTrigger asChild>
417
+ <Button
418
+ size="icon"
419
+ variant="outline"
420
+ data-slot="presentation-devtools"
421
+ data-appearance="fab"
422
+ className={cn("rounded-full shadow-md", className)}
423
+ aria-label={triggerLabel}
424
+ title={triggerLabel}
425
+ >
426
+ <Braces aria-hidden />
427
+ </Button>
428
+ </MenuTrigger>
429
+ <MenuContent side="left" align="center" className="min-w-64">
430
+ <MenuLabel>Presentations ativas</MenuLabel>
431
+ {[...registrations].reverse().map((registration) => (
432
+ <MenuItem key={registration.key} onSelect={() => inspect(registration.key)}>
433
+ <span className="min-w-0 flex-1 truncate">
434
+ {registration.definition.title}
435
+ </span>
436
+ <span className="text-xs text-muted-foreground">
437
+ {registration.invocation === undefined ||
438
+ registration.invocation === null ||
439
+ typeof registration.invocation !== "object" ||
440
+ !("surface" in registration.invocation)
441
+ ? "Presentation"
442
+ : String(registration.invocation.surface)}
443
+ </span>
444
+ </MenuItem>
445
+ ))}
446
+ </MenuContent>
447
+ </Menu>
448
+ {selected === null ? null : (
449
+ <Dialog open onOpenChange={(open) => !open && setSelected(null)}>
450
+ <DialogContent className="sm:max-w-3xl" aria-describedby={undefined}>
451
+ <DialogHeader>
452
+ <DialogTitle>{selected.title}</DialogTitle>
453
+ </DialogHeader>
454
+ <DialogBody>
455
+ <pre className="max-h-[65vh] overflow-auto rounded-lg bg-muted p-4 text-xs leading-relaxed">
456
+ {selected.json}
457
+ </pre>
458
+ </DialogBody>
459
+ <DialogFooter>
460
+ <Copyable
461
+ value={selected.json}
462
+ className={buttonVariants({ variant: "outline" })}
463
+ >
464
+ Copiar JSON
465
+ </Copyable>
466
+ </DialogFooter>
467
+ </DialogContent>
468
+ </Dialog>
469
+ )}
470
+ </>
471
+ );
472
+ }
473
+
251
474
  /** Ferramenta de desenvolvimento para conferir e copiar o artefato consumido pelo renderer. */
252
475
  export function PresentationInspector({
253
476
  definition,
@@ -129,6 +129,13 @@ Use `floating` quando o inspetor precisar ficar disponível sem ocupar o cabeça
129
129
  aplica tamanho, forma, borda e elevação de FAB; `className` define a âncora na superfície consumidora.
130
130
  Reserve espaço para outros controles flutuantes do shell em vez de sobrepô-los.
131
131
 
132
+ Quando várias Presentations puderem estar montadas ao mesmo tempo, envolva o shell com
133
+ `PresentationDevtoolsProvider`, anuncie cada recurso com `usePresentationRegistration` e renderize
134
+ um único `PresentationDevtools`. O launcher abre a lista ativa e só cria o snapshot mascarado depois
135
+ que uma Presentation é escolhida. Monte os três exclusivamente em desenvolvimento: o inventário
136
+ inclui definição, invocação, bindings resolvidos e diagnósticos que não pertencem à experiência de
137
+ produção.
138
+
132
139
  ## Propriedades de Presentation
133
140
 
134
141
  | Propriedade | Tipo | Padrão | Descrição |
@@ -156,3 +163,26 @@ Reserve espaço para outros controles flutuantes do shell em vez de sobrepô-los
156
163
  | `title` | `string` | `JSON da Presentation` | Título do dialog de inspeção. |
157
164
  | `floating` | `boolean` | `false` | Apresenta o gatilho com aparência de FAB. |
158
165
  | `className` | `string` | | Classes adicionais do gatilho. |
166
+
167
+ ## Propriedades de PresentationDevtoolsProvider
168
+
169
+ | Propriedade | Tipo | Descrição |
170
+ | ----------- | ----------- | ------------------------------------------ |
171
+ | `children` | `ReactNode` | Árvore que registra Presentations ativas. |
172
+
173
+ ## Parâmetros de usePresentationRegistration
174
+
175
+ | Propriedade | Tipo | Padrão | Descrição |
176
+ | ------------- | ---------------------------- | ------ | ------------------------------------------------------ |
177
+ | `definition` | `PresentationDefinition` | | Definição estática da Presentation. |
178
+ | `invocation` | `unknown` | | Estado vivo serializável, quando existir. |
179
+ | `resolved` | `unknown` | | Bindings resolvidos pelo renderer. |
180
+ | `diagnostics` | `PresentationDiagnostic[]` | | Diagnósticos da definição ou execução. |
181
+ | `enabled` | `boolean` | `true` | Mantém ou retira o recurso do inventário ativo. |
182
+
183
+ ## Propriedades de PresentationDevtools
184
+
185
+ | Propriedade | Tipo | Padrão | Descrição |
186
+ | -------------- | -------- | -------------------------------- | ---------------------------------------------- |
187
+ | `triggerLabel` | `string` | `Inspecionar Presentations ativas` | Nome acessível e tooltip do launcher. |
188
+ | `className` | `string` | | Classes adicionais; o shell define a posição. |
package/src/ui/meta.ts CHANGED
@@ -406,7 +406,7 @@ export const componentMeta = {
406
406
  name: "presentation",
407
407
  ancestry: "opus",
408
408
  whenToUse:
409
- "Descreva um recurso que precisa manter a mesma anatomia ao aparecer como Page, Dialog ou Drawer. Presentation organiza navegação, título, ações de cabeçalho, body e ações de rodapé sem redefinir o conteúdo para cada superfície. A definição persistente liga esses slots a actions Opus; a invocação carrega o estado JSON da execução. PresentationInspector reúne definição, invocação, bindings resolvidos e diagnósticos com dados sensíveis mascarados.",
409
+ "Descreva um recurso que precisa manter a mesma anatomia ao aparecer como Page, Dialog ou Drawer. Presentation organiza navegação, título, ações de cabeçalho, body e ações de rodapé sem redefinir o conteúdo para cada superfície. A definição persistente liga esses slots a actions Opus; a invocação carrega o estado JSON da execução. PresentationInspector inspeciona um recurso; PresentationDevtools reúne as Presentations ativas do shell em desenvolvimento. Ambos materializam snapshots mascarados somente sob demanda.",
410
410
  },
411
411
  router: {
412
412
  name: "router",
package/src/ui/react.tsx CHANGED
@@ -420,11 +420,17 @@ export type { DataStateProps } from "./components/patterns/data-state.tsx";
420
420
 
421
421
  export {
422
422
  Presentation,
423
+ PresentationDevtools,
424
+ PresentationDevtoolsProvider,
423
425
  PresentationInspector,
426
+ usePresentationRegistration,
424
427
  } from "./components/patterns/presentation.tsx";
425
428
  export type {
426
429
  PresentationProps,
430
+ PresentationDevtoolsProps,
431
+ PresentationDevtoolsProviderProps,
427
432
  PresentationInspectorProps,
433
+ PresentationRegistration,
428
434
  } from "./components/patterns/presentation.tsx";
429
435
 
430
436
  // Esqueleto de página do back-office (main + container + header título/descrição/ação).