@softize/opus 8.6.6

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 (286) hide show
  1. package/CHANGELOG.md +1616 -0
  2. package/LICENSE +21 -0
  3. package/README.md +113 -0
  4. package/bin/cli.mjs +528 -0
  5. package/bin/lib/check.mjs +307 -0
  6. package/bin/lib/components.mjs +151 -0
  7. package/bin/lib/create.mjs +208 -0
  8. package/bin/lib/db-check-runner.mjs +86 -0
  9. package/bin/lib/db-migrate-runner.mjs +89 -0
  10. package/bin/lib/db-scaffold-runner.mjs +84 -0
  11. package/bin/lib/db.mjs +261 -0
  12. package/bin/lib/docs-include.mjs +48 -0
  13. package/bin/lib/gen-dicts.mjs +134 -0
  14. package/bin/lib/gen-docs.mjs +288 -0
  15. package/bin/lib/gen-manifest.mjs +102 -0
  16. package/bin/lib/gen-openapi.mjs +195 -0
  17. package/bin/lib/gen-runner.mjs +472 -0
  18. package/bin/lib/gen-stubs.mjs +463 -0
  19. package/bin/lib/gen.mjs +311 -0
  20. package/bin/lib/init.mjs +514 -0
  21. package/bin/lib/introspect.mjs +107 -0
  22. package/bin/lib/mcp.mjs +85 -0
  23. package/bin/lib/postinstall.mjs +56 -0
  24. package/docs/chat-event-protocol.md +85 -0
  25. package/docs/code-style.md +16 -0
  26. package/docs/data-layer.md +246 -0
  27. package/docs/ownership-vs-shadcn-lock.md +102 -0
  28. package/docs/protocol.md +2053 -0
  29. package/docs/releasing.md +110 -0
  30. package/docs/shellnav.md +131 -0
  31. package/package.json +338 -0
  32. package/registry/hooks/hooks.json +26 -0
  33. package/registry/hooks/link-memory-on-start.mjs +46 -0
  34. package/registry/hooks/opus-check-on-stop.mjs +114 -0
  35. package/registry/skills/create-action/SKILL.md +49 -0
  36. package/registry/skills/create-action/scaffold.mjs +122 -0
  37. package/registry/templates/app/_gitignore +3 -0
  38. package/registry/templates/app/_npmrc +1 -0
  39. package/registry/templates/app/_opus/_gitignore +5 -0
  40. package/registry/templates/app/_prettierrc.json +6 -0
  41. package/registry/templates/app/index.html +13 -0
  42. package/registry/templates/app/opus.config.ts +16 -0
  43. package/registry/templates/app/package.json +43 -0
  44. package/registry/templates/app/pnpm-workspace.yaml +11 -0
  45. package/registry/templates/app/public/favicon.svg +4 -0
  46. package/registry/templates/app/src/App.tsx +37 -0
  47. package/registry/templates/app/src/domains/tasks/actions/list.test.ts +34 -0
  48. package/registry/templates/app/src/domains/tasks/actions/list.ts +33 -0
  49. package/registry/templates/app/src/domains/tasks/index.ts +13 -0
  50. package/registry/templates/app/src/index.css +18 -0
  51. package/registry/templates/app/src/main.tsx +25 -0
  52. package/registry/templates/app/tsconfig.json +20 -0
  53. package/registry/templates/app/vite.config.ts +46 -0
  54. package/registry/templates/monorepo/_gitignore +3 -0
  55. package/registry/templates/monorepo/_npmrc +1 -0
  56. package/registry/templates/monorepo/package.json +9 -0
  57. package/registry/templates/monorepo/pnpm-workspace.yaml +14 -0
  58. package/src/ai/ask.ts +64 -0
  59. package/src/ai/drivers/anthropic.ts +309 -0
  60. package/src/ai/index.ts +17 -0
  61. package/src/audit/drivers/console.ts +117 -0
  62. package/src/audit/drivers/pg.ts +172 -0
  63. package/src/audit/index.ts +51 -0
  64. package/src/auth/drivers/better-auth.ts +103 -0
  65. package/src/auth/drivers/jwt.ts +188 -0
  66. package/src/auth/index.ts +9 -0
  67. package/src/client/drivers/fetch.ts +202 -0
  68. package/src/client/index.ts +22 -0
  69. package/src/core/actions.ts +110 -0
  70. package/src/core/audit.ts +239 -0
  71. package/src/core/contracts.ts +137 -0
  72. package/src/core/domain.ts +310 -0
  73. package/src/core/errors.ts +181 -0
  74. package/src/core/index.ts +174 -0
  75. package/src/core/logical-type.ts +31 -0
  76. package/src/core/reactions.ts +81 -0
  77. package/src/core/runtime.ts +1167 -0
  78. package/src/core/schedules.ts +41 -0
  79. package/src/core/types.ts +1356 -0
  80. package/src/data/drivers/kysely.ts +389 -0
  81. package/src/data/index.ts +10 -0
  82. package/src/data/readonly-pool.ts +160 -0
  83. package/src/dsl/eval.ts +136 -0
  84. package/src/dsl/index.ts +29 -0
  85. package/src/dsl/kysely.ts +230 -0
  86. package/src/dsl/loads.ts +123 -0
  87. package/src/dsl/parser.ts +423 -0
  88. package/src/dsl/types.ts +113 -0
  89. package/src/events/drivers/mitt.ts +70 -0
  90. package/src/events/index.ts +9 -0
  91. package/src/log/drivers/pino.ts +57 -0
  92. package/src/log/index.ts +9 -0
  93. package/src/mcp/index.ts +62 -0
  94. package/src/queue/drivers/bullmq.ts +190 -0
  95. package/src/queue/index.ts +9 -0
  96. package/src/scheduler/drivers/node-cron.ts +93 -0
  97. package/src/scheduler/every.ts +45 -0
  98. package/src/scheduler/index.ts +9 -0
  99. package/src/schema/drivers/zod.ts +765 -0
  100. package/src/schema/entity.ts +439 -0
  101. package/src/schema/format/locale.ts +144 -0
  102. package/src/schema/index.ts +65 -0
  103. package/src/schema/openapi.ts +302 -0
  104. package/src/schema/scaffold.ts +160 -0
  105. package/src/server/drivers/fastify.ts +224 -0
  106. package/src/server/drivers/node.ts +386 -0
  107. package/src/server/index.ts +142 -0
  108. package/src/storage/drivers/fs.ts +90 -0
  109. package/src/storage/drivers/s3.ts +117 -0
  110. package/src/storage/index.ts +27 -0
  111. package/src/testing/fake.ts +298 -0
  112. package/src/testing/index.ts +324 -0
  113. package/src/ui/components/patterns/action-form-card.tsx +48 -0
  114. package/src/ui/components/patterns/action-list-dialog.tsx +93 -0
  115. package/src/ui/components/patterns/app-shell.tsx +227 -0
  116. package/src/ui/components/patterns/confirm.tsx +226 -0
  117. package/src/ui/components/patterns/data-state.tsx +75 -0
  118. package/src/ui/components/patterns/form-dialog.tsx +64 -0
  119. package/src/ui/components/patterns/form.tsx +584 -0
  120. package/src/ui/components/patterns/list.tsx +1488 -0
  121. package/src/ui/components/patterns/page.tsx +46 -0
  122. package/src/ui/components/patterns/section-shell.tsx +246 -0
  123. package/src/ui/components/patterns/shell-nav.tsx +150 -0
  124. package/src/ui/components/patterns/sidebar.tsx +89 -0
  125. package/src/ui/components/patterns/split.tsx +93 -0
  126. package/src/ui/components/patterns/trigger.tsx +196 -0
  127. package/src/ui/components/patterns/view.tsx +84 -0
  128. package/src/ui/components/primitives/accordion.tsx +64 -0
  129. package/src/ui/components/primitives/alert-dialog.tsx +190 -0
  130. package/src/ui/components/primitives/alert.tsx +116 -0
  131. package/src/ui/components/primitives/aspect-ratio.tsx +9 -0
  132. package/src/ui/components/primitives/avatar.tsx +107 -0
  133. package/src/ui/components/primitives/badge.tsx +37 -0
  134. package/src/ui/components/primitives/breadcrumb.tsx +109 -0
  135. package/src/ui/components/primitives/button-group.tsx +83 -0
  136. package/src/ui/components/primitives/button.tsx +102 -0
  137. package/src/ui/components/primitives/calendar.tsx +218 -0
  138. package/src/ui/components/primitives/card.tsx +56 -0
  139. package/src/ui/components/primitives/carousel.tsx +239 -0
  140. package/src/ui/components/primitives/chat.tsx +407 -0
  141. package/src/ui/components/primitives/checkbox.tsx +30 -0
  142. package/src/ui/components/primitives/collapsible.tsx +31 -0
  143. package/src/ui/components/primitives/command.tsx +182 -0
  144. package/src/ui/components/primitives/composer.tsx +121 -0
  145. package/src/ui/components/primitives/copyable.tsx +50 -0
  146. package/src/ui/components/primitives/dialog.tsx +147 -0
  147. package/src/ui/components/primitives/drawer.tsx +141 -0
  148. package/src/ui/components/primitives/empty.tsx +104 -0
  149. package/src/ui/components/primitives/field.tsx +246 -0
  150. package/src/ui/components/primitives/icon-picker.tsx +180 -0
  151. package/src/ui/components/primitives/input-group.tsx +168 -0
  152. package/src/ui/components/primitives/input-otp.tsx +75 -0
  153. package/src/ui/components/primitives/input.tsx +72 -0
  154. package/src/ui/components/primitives/item.tsx +193 -0
  155. package/src/ui/components/primitives/kbd.tsx +28 -0
  156. package/src/ui/components/primitives/label.tsx +22 -0
  157. package/src/ui/components/primitives/markdown.tsx +35 -0
  158. package/src/ui/components/primitives/menu.tsx +255 -0
  159. package/src/ui/components/primitives/pagination.tsx +127 -0
  160. package/src/ui/components/primitives/popover.tsx +87 -0
  161. package/src/ui/components/primitives/progress.tsx +29 -0
  162. package/src/ui/components/primitives/radio-group.tsx +43 -0
  163. package/src/ui/components/primitives/resizable.tsx +51 -0
  164. package/src/ui/components/primitives/scroll-area.tsx +56 -0
  165. package/src/ui/components/primitives/select.tsx +479 -0
  166. package/src/ui/components/primitives/separator.tsx +26 -0
  167. package/src/ui/components/primitives/skeleton.tsx +13 -0
  168. package/src/ui/components/primitives/slider.tsx +61 -0
  169. package/src/ui/components/primitives/sonner.tsx +46 -0
  170. package/src/ui/components/primitives/spinner.tsx +29 -0
  171. package/src/ui/components/primitives/switch.tsx +33 -0
  172. package/src/ui/components/primitives/table.tsx +114 -0
  173. package/src/ui/components/primitives/tabs.tsx +104 -0
  174. package/src/ui/components/primitives/textarea.tsx +18 -0
  175. package/src/ui/components/primitives/toggle-group.tsx +81 -0
  176. package/src/ui/components/primitives/toggle.tsx +45 -0
  177. package/src/ui/components/primitives/tooltip.tsx +55 -0
  178. package/src/ui/components/primitives/truncate.tsx +49 -0
  179. package/src/ui/docs/DocBrowser.tsx +90 -0
  180. package/src/ui/docs/changelog.tsx +80 -0
  181. package/src/ui/docs/content/accordion.md +86 -0
  182. package/src/ui/docs/content/action-form-card.md +24 -0
  183. package/src/ui/docs/content/action-form-dialog.md +30 -0
  184. package/src/ui/docs/content/action-form.md +125 -0
  185. package/src/ui/docs/content/action-list-dialog.md +68 -0
  186. package/src/ui/docs/content/action-list.md +194 -0
  187. package/src/ui/docs/content/action-trigger.md +72 -0
  188. package/src/ui/docs/content/action-view.md +47 -0
  189. package/src/ui/docs/content/actions.md +138 -0
  190. package/src/ui/docs/content/ai.md +112 -0
  191. package/src/ui/docs/content/alert-dialog.md +73 -0
  192. package/src/ui/docs/content/alert.md +69 -0
  193. package/src/ui/docs/content/app-shell.md +155 -0
  194. package/src/ui/docs/content/aspect-ratio.md +66 -0
  195. package/src/ui/docs/content/audit.md +84 -0
  196. package/src/ui/docs/content/auth.md +70 -0
  197. package/src/ui/docs/content/avatar.md +94 -0
  198. package/src/ui/docs/content/badge.md +48 -0
  199. package/src/ui/docs/content/breadcrumb.md +87 -0
  200. package/src/ui/docs/content/button-group.md +71 -0
  201. package/src/ui/docs/content/button.md +60 -0
  202. package/src/ui/docs/content/calendar.md +62 -0
  203. package/src/ui/docs/content/card.md +49 -0
  204. package/src/ui/docs/content/carousel.md +85 -0
  205. package/src/ui/docs/content/chat.md +69 -0
  206. package/src/ui/docs/content/checkbox.md +75 -0
  207. package/src/ui/docs/content/cli.md +58 -0
  208. package/src/ui/docs/content/collapsible.md +64 -0
  209. package/src/ui/docs/content/command.md +56 -0
  210. package/src/ui/docs/content/composer.md +50 -0
  211. package/src/ui/docs/content/confirm.md +120 -0
  212. package/src/ui/docs/content/copyable.md +30 -0
  213. package/src/ui/docs/content/customization.md +110 -0
  214. package/src/ui/docs/content/cycle.md +34 -0
  215. package/src/ui/docs/content/data-state.md +47 -0
  216. package/src/ui/docs/content/data.md +99 -0
  217. package/src/ui/docs/content/dialog.md +60 -0
  218. package/src/ui/docs/content/drawer.md +55 -0
  219. package/src/ui/docs/content/empty.md +66 -0
  220. package/src/ui/docs/content/events.md +61 -0
  221. package/src/ui/docs/content/field.md +58 -0
  222. package/src/ui/docs/content/getting-started.md +109 -0
  223. package/src/ui/docs/content/icon-picker.md +51 -0
  224. package/src/ui/docs/content/input-group.md +78 -0
  225. package/src/ui/docs/content/input-otp.md +72 -0
  226. package/src/ui/docs/content/input.md +78 -0
  227. package/src/ui/docs/content/item.md +84 -0
  228. package/src/ui/docs/content/kbd.md +62 -0
  229. package/src/ui/docs/content/label.md +32 -0
  230. package/src/ui/docs/content/log.md +55 -0
  231. package/src/ui/docs/content/markdown.md +41 -0
  232. package/src/ui/docs/content/mcp.md +44 -0
  233. package/src/ui/docs/content/menu.md +114 -0
  234. package/src/ui/docs/content/microcopy.md +83 -0
  235. package/src/ui/docs/content/page.md +34 -0
  236. package/src/ui/docs/content/pagination.md +99 -0
  237. package/src/ui/docs/content/popover.md +49 -0
  238. package/src/ui/docs/content/progress.md +69 -0
  239. package/src/ui/docs/content/queue.md +62 -0
  240. package/src/ui/docs/content/radio-group.md +77 -0
  241. package/src/ui/docs/content/resizable.md +86 -0
  242. package/src/ui/docs/content/router.md +56 -0
  243. package/src/ui/docs/content/runtime.md +77 -0
  244. package/src/ui/docs/content/scheduler.md +66 -0
  245. package/src/ui/docs/content/scroll-area.md +89 -0
  246. package/src/ui/docs/content/section-shell.md +121 -0
  247. package/src/ui/docs/content/select.md +342 -0
  248. package/src/ui/docs/content/separator.md +33 -0
  249. package/src/ui/docs/content/sidebar.md +38 -0
  250. package/src/ui/docs/content/skeleton.md +34 -0
  251. package/src/ui/docs/content/slider.md +64 -0
  252. package/src/ui/docs/content/spinner.md +37 -0
  253. package/src/ui/docs/content/split.md +33 -0
  254. package/src/ui/docs/content/storage.md +69 -0
  255. package/src/ui/docs/content/switch.md +69 -0
  256. package/src/ui/docs/content/table.md +102 -0
  257. package/src/ui/docs/content/tabs.md +94 -0
  258. package/src/ui/docs/content/testing.md +89 -0
  259. package/src/ui/docs/content/textarea.md +30 -0
  260. package/src/ui/docs/content/toast.md +67 -0
  261. package/src/ui/docs/content/toggle-group.md +81 -0
  262. package/src/ui/docs/content/toggle.md +72 -0
  263. package/src/ui/docs/content/tokens.md +171 -0
  264. package/src/ui/docs/content/tooltip.md +50 -0
  265. package/src/ui/docs/content/truncate.md +37 -0
  266. package/src/ui/docs/content/ui.md +40 -0
  267. package/src/ui/docs/content/upgrading.md +48 -0
  268. package/src/ui/docs/doc-client.tsx +214 -0
  269. package/src/ui/docs/doc.tsx +301 -0
  270. package/src/ui/docs/folder.tsx +149 -0
  271. package/src/ui/docs/index.ts +21 -0
  272. package/src/ui/docs/markdown.tsx +130 -0
  273. package/src/ui/docs/md-raw.d.ts +4 -0
  274. package/src/ui/docs/plugin.ts +104 -0
  275. package/src/ui/docs/registry.tsx +424 -0
  276. package/src/ui/docs/standalone.tsx +107 -0
  277. package/src/ui/drivers/react.tsx +627 -0
  278. package/src/ui/index.ts +92 -0
  279. package/src/ui/lib/cn.ts +10 -0
  280. package/src/ui/lib/zod-pt-br.ts +38 -0
  281. package/src/ui/meta.ts +412 -0
  282. package/src/ui/react.tsx +235 -0
  283. package/src/ui/router.ts +96 -0
  284. package/src/ui/theme.css +234 -0
  285. package/src/vite/design.ts +652 -0
  286. package/src/vite/index.ts +8 -0
@@ -0,0 +1,324 @@
1
+ /**
2
+ * @softize/opus/testing — o harness de teste do protocolo (EXPERIMENTAL).
3
+ *
4
+ * A unidade natural de teste é a ACTION (o contrato): input → output/erro, bordas do
5
+ * schema, autorização fail-closed. Este módulo dá isso sem montar runtime nem mockar
6
+ * contexto à mão:
7
+ *
8
+ * • `testContext()` — um ActionContext completo com fakes observáveis: `emit`
9
+ * capturado em `emitted`, log capturado em `logged`, `can` configurável,
10
+ * storage plugável (ex.: `memStorage()`).
11
+ * • `runAction(action, input)` — o MESMO pipeline do `runtime.execute`, em unidade:
12
+ * valida input → gate `public` → `authorize` (string DSL ou closure, semântica
13
+ * idêntica, `loaded` incluso) → handler → valida output (pulado em `list`, como
14
+ * no runtime). Falha estoura o MESMO `ActionError` tipado do runtime
15
+ * (`validation.invalid_input`, `auth.unauthenticated`, `auth.forbidden`…).
16
+ * Em `OPUS_MODE=design` roteia pro `mockHandler` como o runtime — contrato puro
17
+ * (sem handler) é testável em design; fora dele, erro claro (`testing.no_handler`).
18
+ * • `memStorage()` — StorageAdapter em memória pra testar handler que anexa
19
+ * arquivo, com a mesma régua de key dos drivers reais.
20
+ *
21
+ * O que fica de fora (de propósito — é harness de UNIDADE): loaders reais (o teste
22
+ * passa `loaded` pronto), audit, reactions em cadeia e o servidor. `requires` é
23
+ * declarativo no protocolo (o runtime não o executa) — aqui idem; handler que chama
24
+ * `ctx.can` responde ao `can` das options. Pra fluxo completo, teste de integração
25
+ * com o runtime.
26
+ */
27
+
28
+ import { evalExpression } from '../dsl/eval.ts'
29
+ import { parseExpression } from '../dsl/parser.ts'
30
+ import { normalizeKey } from '../storage/index.ts'
31
+ import { error } from '../core/errors.ts'
32
+ // Import direto do runtime (fora do index público): a régua handler×mockHandler
33
+ // tem UMA fonte — o harness nunca pode divergir do execute().
34
+ import { isOpusDesignMode } from '../core/runtime.ts'
35
+ import type {
36
+ ActionContext,
37
+ ActionKind,
38
+ AiAdapter,
39
+ BoundAi,
40
+ AuthorizeSpec,
41
+ HandlerFn,
42
+ Logger,
43
+ Paginated,
44
+ Schema,
45
+ StorageAdapter,
46
+ StorageObject,
47
+ StoragePutOptions,
48
+ User,
49
+ } from '../core/index.ts'
50
+
51
+ // =============================================================================
52
+ // testContext
53
+ // =============================================================================
54
+
55
+ export interface EmittedEvent {
56
+ event: string
57
+ data: unknown
58
+ meta?: Record<string, unknown>
59
+ }
60
+
61
+ export interface LoggedLine {
62
+ level: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal'
63
+ msg: string
64
+ meta?: object
65
+ }
66
+
67
+ export interface TestContextOptions {
68
+ user?: User | null
69
+ tenantId?: string | null
70
+ /** Resposta do `ctx.can` pra handlers que consultam permissão. Default: permite tudo. */
71
+ can?: ActionContext['can']
72
+ db?: unknown
73
+ storage?: StorageAdapter | null
74
+ ai?: AiAdapter | null
75
+ meta?: Record<string, unknown>
76
+ }
77
+
78
+ export interface TestContext {
79
+ ctx: ActionContext
80
+ /** Eventos emitidos pelo handler (`ctx.emit`), na ordem. */
81
+ emitted: EmittedEvent[]
82
+ /** Linhas logadas pelo handler (`ctx.log`), na ordem — o teste afirma o que importa. */
83
+ logged: LoggedLine[]
84
+ }
85
+
86
+ function captureLogger(sink: LoggedLine[]): Logger {
87
+ const log = (level: LoggedLine['level']) => (msg: string, meta?: object) => {
88
+ sink.push({ level, msg, ...(meta !== undefined ? { meta } : {}) })
89
+ }
90
+ const logger: Logger = {
91
+ trace: log('trace'),
92
+ debug: log('debug'),
93
+ info: log('info'),
94
+ warn: log('warn'),
95
+ error: log('error'),
96
+ fatal: log('fatal'),
97
+ child: () => logger,
98
+ }
99
+ return logger
100
+ }
101
+
102
+ /** Liga o adapter cru num `BoundAi` pro `ctx.ai` do teste. Sem runtime/registry, o `run`
103
+ * sai com tools vazias — testar handler agêntico usa `createRuntime` (não este harness). */
104
+ function toBoundAi(a: AiAdapter): BoundAi {
105
+ return {
106
+ complete: (p, o) => a.complete(p, o),
107
+ extract: (p, s, o) => a.extract(p, s, o),
108
+ run: (input, opts) =>
109
+ a.run
110
+ ? a.run(input, { ...opts, tools: [], execute: () => ({}) })
111
+ : Promise.reject(new Error('driver de IA sem run (loop agêntico).')),
112
+ runStream: (input, opts) => {
113
+ if (a.runStream === undefined) throw new Error('driver de IA sem runStream (loop em streaming).')
114
+ return a.runStream(input, { ...opts, tools: [], execute: () => ({}) })
115
+ },
116
+ }
117
+ }
118
+
119
+ /** ActionContext de teste: fakes observáveis, nada de rede/banco/runtime. */
120
+ export function testContext(options: TestContextOptions = {}): TestContext {
121
+ const emitted: EmittedEvent[] = []
122
+ const logged: LoggedLine[] = []
123
+ const ctx: ActionContext = {
124
+ user: options.user ?? null,
125
+ tenantId: options.tenantId ?? null,
126
+ can: options.can ?? (() => true),
127
+ db: options.db,
128
+ log: captureLogger(logged),
129
+ emit: (event, data, meta) => {
130
+ emitted.push({ event, data, ...(meta !== undefined ? { meta } : {}) })
131
+ return Promise.resolve()
132
+ },
133
+ storage: options.storage ?? null,
134
+ ai: options.ai ? toBoundAi(options.ai) : null,
135
+ provenance: { kind: 'system', source: 'test' },
136
+ meta: options.meta ?? {},
137
+ }
138
+ return { ctx, emitted, logged }
139
+ }
140
+
141
+ // =============================================================================
142
+ // memStorage
143
+ // =============================================================================
144
+
145
+ /**
146
+ * StorageAdapter em memória, com a régua de key dos drivers reais
147
+ * (`normalizeKey`). Métodos async como nos drivers: key inválida REJEITA,
148
+ * não estoura síncrono.
149
+ */
150
+ export function memStorage(): StorageAdapter & { keys(): string[] } {
151
+ const objects = new Map<string, StorageObject>()
152
+ return {
153
+ name: 'mem',
154
+ kind: 'storage',
155
+ async put(key: string, data: Uint8Array, opts?: StoragePutOptions): Promise<void> {
156
+ objects.set(normalizeKey(key), {
157
+ data,
158
+ ...(opts?.contentType !== undefined ? { contentType: opts.contentType } : {}),
159
+ })
160
+ },
161
+ async get(key: string): Promise<StorageObject | null> {
162
+ return objects.get(normalizeKey(key)) ?? null
163
+ },
164
+ async delete(key: string): Promise<void> {
165
+ objects.delete(normalizeKey(key))
166
+ },
167
+ async url(key: string): Promise<string> {
168
+ return `mem://${normalizeKey(key)}`
169
+ },
170
+ keys(): string[] {
171
+ return [...objects.keys()].sort()
172
+ },
173
+ }
174
+ }
175
+
176
+ // =============================================================================
177
+ // fake (fixtures do schema)
178
+ // =============================================================================
179
+
180
+ export { fake, fakeMany, type FakeOptions } from './fake.ts'
181
+
182
+ // =============================================================================
183
+ // runAction
184
+ // =============================================================================
185
+
186
+ /**
187
+ * O subset estrutural de action que o harness lê. Qualquer action real (defineAction
188
+ * ou objeto literal) satisfaz por dever campos a mais — `loads`, `requires` etc. são
189
+ * ignorados aqui (loaders não rodam em unidade; passe `loaded` nas options).
190
+ *
191
+ * Como no runtime, `authorize` e `handler` recebem o input PARSEADO (`ParsedIn`,
192
+ * o output do schema de input — defaults aplicados); `In` é o lado do fio.
193
+ */
194
+ export interface TestableAction<In, Out, ParsedIn = In> {
195
+ name: string
196
+ kind?: ActionKind
197
+ public?: boolean
198
+ authorize?: AuthorizeSpec<ParsedIn>
199
+ input: Schema<In, ParsedIn>
200
+ output: Schema<Out> | Schema<unknown, Out>
201
+ /** Opcional: contrato puro (sem handler) executa via `mockHandler` em modo design. */
202
+ handler?: HandlerFn<ParsedIn, Out>
203
+ /** Em `OPUS_MODE=design` vence o handler real — mesma régua do runtime. */
204
+ mockHandler?: HandlerFn<ParsedIn, Out>
205
+ }
206
+
207
+ /** Variante list: o schema descreve o ITEM, o handler devolve `Paginated<Item>`. */
208
+ export interface TestableListAction<In, Item, ParsedIn = In> {
209
+ name: string
210
+ kind: 'list'
211
+ public?: boolean
212
+ authorize?: AuthorizeSpec<ParsedIn>
213
+ input: Schema<In, ParsedIn>
214
+ output: Schema<Item> | Schema<unknown, Item>
215
+ handler?: (
216
+ ctx: ActionContext,
217
+ input: ParsedIn,
218
+ loaded?: Record<string, unknown>,
219
+ ) => Paginated<Item> | Promise<Paginated<Item>>
220
+ mockHandler?: (
221
+ ctx: ActionContext,
222
+ input: ParsedIn,
223
+ loaded?: Record<string, unknown>,
224
+ ) => Paginated<Item> | Promise<Paginated<Item>>
225
+ }
226
+
227
+ export interface RunActionOptions extends TestContextOptions {
228
+ /** Substitui os loaders (não rodam em unidade): vai pro handler e pro evalCtx do authorize. */
229
+ loaded?: Record<string, unknown>
230
+ }
231
+
232
+ export interface RunActionResult<Out> extends TestContext {
233
+ output: Out
234
+ }
235
+
236
+ /** Espelho do `runtime.validate`: mesmo discriminador e mesmo `ActionError`. */
237
+ async function validate<T>(
238
+ schema: Schema<T> | Schema<unknown, T>,
239
+ value: unknown,
240
+ kind: 'input' | 'output',
241
+ ): Promise<T> {
242
+ const result = await Promise.resolve(schema['~standard'].validate(value))
243
+ if ('value' in result) return result.value as T
244
+ throw error({
245
+ code: kind === 'input' ? 'validation.invalid_input' : 'validation.invalid_output',
246
+ category: kind === 'input' ? 'validation' : 'internal',
247
+ message: kind === 'input' ? 'Input validation failed' : 'Output validation failed (dev)',
248
+ issues: result.issues.map((iss) => ({
249
+ path: (iss.path ?? []).join('.'),
250
+ code: 'invalid',
251
+ message: iss.message,
252
+ })),
253
+ })
254
+ }
255
+
256
+ /**
257
+ * Roda uma action em UNIDADE com o pipeline do `runtime.execute`: valida input →
258
+ * gate `public` → authorize → handler → valida output. Sucesso devolve o output
259
+ * validado + o contexto observável (`emitted`, `logged`); falha estoura o mesmo
260
+ * `ActionError` do runtime — afirme com `expect(...).rejects`.
261
+ *
262
+ * A escolha do executável segue a régua do `execute()`: em `OPUS_MODE=design`,
263
+ * `mockHandler` presente vence; senão, `handler` real. Contrato puro (sem
264
+ * handler) só roda em design — fora dele, `testing.no_handler`.
265
+ */
266
+ export async function runAction<In, Item, ParsedIn = In>(
267
+ action: TestableListAction<In, Item, ParsedIn>,
268
+ input: unknown,
269
+ options?: RunActionOptions,
270
+ ): Promise<RunActionResult<Paginated<Item>>>
271
+ export async function runAction<In, Out, ParsedIn = In>(
272
+ action: TestableAction<In, Out, ParsedIn>,
273
+ input: unknown,
274
+ options?: RunActionOptions,
275
+ ): Promise<RunActionResult<Out>>
276
+ export async function runAction<In, Out, ParsedIn = In>(
277
+ action: TestableAction<In, Out, ParsedIn>,
278
+ input: unknown,
279
+ options: RunActionOptions = {},
280
+ ): Promise<RunActionResult<Out>> {
281
+ const t = testContext(options)
282
+ const { ctx } = t
283
+ const loaded = options.loaded
284
+
285
+ const parsed = await validate<ParsedIn>(action.input, input, 'input')
286
+
287
+ if (action.public !== true && ctx.user === null) {
288
+ throw error({
289
+ code: 'auth.unauthenticated',
290
+ category: 'authentication',
291
+ message: 'Authentication required',
292
+ })
293
+ }
294
+
295
+ if (action.authorize !== undefined) {
296
+ const spec = action.authorize
297
+ const decision =
298
+ typeof spec === 'string'
299
+ ? Boolean(evalExpression(parseExpression(spec), { user: ctx.user, input: parsed, ctx, ...(loaded ?? {}) }))
300
+ : await spec(ctx, parsed, loaded)
301
+ if (decision === false) {
302
+ throw error({ code: 'auth.forbidden', category: 'authorization', message: 'Forbidden' })
303
+ }
304
+ if (decision !== true) throw decision
305
+ }
306
+
307
+ const handlerToRun =
308
+ isOpusDesignMode() && action.mockHandler !== undefined
309
+ ? action.mockHandler
310
+ : action.handler
311
+ if (handlerToRun === undefined) {
312
+ throw error({
313
+ code: 'testing.no_handler',
314
+ category: 'internal',
315
+ message: `Action "${action.name}" não tem handler executável — contrato puro roda em modo design (OPUS_MODE=design) com mockHandler; fora dele, teste o ActionDef do bindAction`,
316
+ })
317
+ }
318
+ const raw = await handlerToRun(ctx, parsed, loaded)
319
+
320
+ // Mesmo skip do runtime: list devolve Paginated<Item>, o schema descreve Item.
321
+ const output = action.kind === 'list' ? (raw as Out) : await validate<Out>(action.output, raw, 'output')
322
+
323
+ return { output, ...t }
324
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * <ActionFormCard /> — o ActionForm dentro de um Card do Opus (header / conteúdo / rodapé),
3
+ * pra estruturar uma seção da página como painel. Segue o padrão do CARD (não a chrome de
4
+ * modal): sem divisor nem faixa, com o respiro do Card. Pra form em overlay, use o
5
+ * ActionFormDialog; pra form cru (sem chrome), o ActionForm direto.
6
+ */
7
+ import {
8
+ Card,
9
+ CardContent,
10
+ CardDescription,
11
+ CardFooter,
12
+ CardHeader,
13
+ CardTitle,
14
+ } from '../primitives/card.tsx'
15
+ import { ActionForm, type ActionFormProps } from './form.tsx'
16
+
17
+ export interface ActionFormCardProps<TInput extends Record<string, unknown>, TData>
18
+ extends Omit<ActionFormProps<TInput, TData>, 'body' | 'footer'> {
19
+ /** Título do header do card. Sem ele, o card começa direto no conteúdo. */
20
+ title?: string
21
+ /** Subtítulo opcional, abaixo do título. */
22
+ description?: string
23
+ /** Classes do Card (a superfície). `className` vai pro <form>. */
24
+ cardClassName?: string
25
+ }
26
+
27
+ export function ActionFormCard<TInput extends Record<string, unknown>, TData>({
28
+ title,
29
+ description,
30
+ cardClassName,
31
+ ...rest
32
+ }: ActionFormCardProps<TInput, TData>) {
33
+ return (
34
+ <Card className={cardClassName}>
35
+ {(title !== undefined || description !== undefined) && (
36
+ <CardHeader>
37
+ {title !== undefined && <CardTitle>{title}</CardTitle>}
38
+ {description !== undefined && <CardDescription>{description}</CardDescription>}
39
+ </CardHeader>
40
+ )}
41
+ <ActionForm
42
+ {...rest}
43
+ body={(fields) => <CardContent>{fields}</CardContent>}
44
+ footer={(actions) => <CardFooter className="justify-end">{actions}</CardFooter>}
45
+ />
46
+ </Card>
47
+ )
48
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * <ActionListDialog action input /> — uma ListAction em modal (o modo modal da lista).
3
+ *
4
+ * CASCA sobre o <ActionList /> em modo composição: o dialog põe o chrome (título,
5
+ * descrição, a linha nota + ação de criar) e o ActionList faz o resto — fetch
6
+ * query-backed com refetch por `invalidates`, toolbar declarativa (busca/filtros/
7
+ * período, se o contrato declarar), estados de carga e o rodapé com total/pager.
8
+ *
9
+ * Escape hatches pro que o contrato não sabe (repassados ao ActionList):
10
+ * - `loading` AGREGA carga extra (a query irmã que os children precisam, ex.: papéis).
11
+ * - `empty` sobrepõe o vazio derivado (ex.: form inline de criar aberto ⇒ children).
12
+ */
13
+
14
+ import type { ReactNode } from 'react'
15
+ import { cn } from '../../lib/cn.ts'
16
+ import {
17
+ Dialog,
18
+ DialogBody,
19
+ DialogContent,
20
+ DialogDescription,
21
+ DialogHeader,
22
+ DialogTitle,
23
+ } from '../primitives/dialog.tsx'
24
+ import { ActionList } from './list.tsx'
25
+ import type { ListAction } from '../../../core/index.ts'
26
+
27
+ export interface ActionListDialogProps<TItem, TInput extends Record<string, unknown> = Record<string, unknown>> {
28
+ /** A ListAction do Opus (estrutural: name + kind — o mesmo contrato da família). */
29
+ action: { name: string; kind: 'list' }
30
+ /** Input da action (o escopo base; ex.: { workspaceId }). Mudou, re-busca. */
31
+ input?: TInput
32
+ open: boolean
33
+ onOpenChange: (open: boolean) => void
34
+ title: string
35
+ description?: string
36
+ /** Nota à esquerda da linha de ações (contexto curto). O total já mora no rodapé. */
37
+ note?: ReactNode
38
+ /** Ação à direita da linha — em geral o botão de criar. */
39
+ actions?: ReactNode
40
+ emptyText?: string
41
+ /** Sobrepõe o vazio derivado (items.length === 0) — ex.: form inline aberto. */
42
+ empty?: (items: TItem[]) => boolean
43
+ /** Carga EXTRA agregada à do fetch (ex.: a query irmã que os children precisam). */
44
+ loading?: boolean
45
+ /** Classes do DialogContent (largura). Default: sm:max-w-3xl (lista pede espaço). */
46
+ className?: string
47
+ /** O layout dos itens (cards/linhas) — os estados já saíram daqui. */
48
+ children: (items: TItem[], refetch: () => Promise<void>) => ReactNode
49
+ }
50
+
51
+ export function ActionListDialog<TItem, TInput extends Record<string, unknown> = Record<string, unknown>>({
52
+ action,
53
+ input,
54
+ open,
55
+ onOpenChange,
56
+ title,
57
+ description,
58
+ note,
59
+ actions,
60
+ emptyText,
61
+ empty,
62
+ loading,
63
+ className,
64
+ children,
65
+ }: ActionListDialogProps<TItem, TInput>): React.ReactElement {
66
+ return (
67
+ <Dialog open={open} onOpenChange={onOpenChange}>
68
+ <DialogContent className={cn('sm:max-w-3xl', className)}>
69
+ <DialogHeader>
70
+ <DialogTitle>{title}</DialogTitle>
71
+ {description !== undefined && <DialogDescription>{description}</DialogDescription>}
72
+ </DialogHeader>
73
+ <DialogBody>
74
+ {(note !== undefined || actions !== undefined) && (
75
+ <div data-slot="action-list-dialog-toolbar" className="mb-2 flex min-h-8 items-center justify-between gap-4">
76
+ <div className="min-w-0 text-xs text-muted-foreground/70">{note}</div>
77
+ {actions}
78
+ </div>
79
+ )}
80
+ <ActionList<TInput, TItem>
81
+ action={action as unknown as ListAction<TInput, TItem>}
82
+ input={(input ?? {}) as TInput}
83
+ {...(emptyText !== undefined ? { emptyMessage: emptyText } : {})}
84
+ {...(empty !== undefined ? { empty } : {})}
85
+ {...(loading !== undefined ? { loading } : {})}
86
+ >
87
+ {children}
88
+ </ActionList>
89
+ </DialogBody>
90
+ </DialogContent>
91
+ </Dialog>
92
+ )
93
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * <AppShell /> — o chrome da aplicação: sidebar de navegação + conteúdo, com rail
3
+ * opcional à direita (chat de agente, inspetor).
4
+ *
5
+ * Padroniza o esqueleto que os apps copiavam à mão (mesmas classes, várias vezes):
6
+ * raiz `flex h-screen`, aside `w-64` com header/nav/rodapé (o nav rola; o rodapé
7
+ * ancora), e o conteúdo. Com `rail`, conteúdo e rail viram painéis redimensionáveis
8
+ * (Resizable). Presentacional de propósito: o QUE vai em cada slot (PaneHeader do
9
+ * app, ChatRail, preview…) é do consumidor — o shell é o quadro, não o conteúdo.
10
+ *
11
+ * RECOLHER (`collapsible`): o shell controla a LARGURA e publica o estado; quem decide
12
+ * o que some é o conteúdo, porque o `sidebarNav` é do consumidor. O aside expõe
13
+ * `data-collapsed` e o grupo `sidebar`, então rótulo some por CSS, sem estado no
14
+ * consumidor: `className="group-data-[collapsed=true]/sidebar:hidden"`. Quem precisa do
15
+ * estado em JS usa `useAppShell()`; o botão é o `<AppShellTrigger />`, posicionado pelo
16
+ * consumidor (numa `<AppShellBar>`, por exemplo) — o shell não escolhe por ele.
17
+ */
18
+
19
+ import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react'
20
+ import { PanelLeft } from 'lucide-react'
21
+ import { cn } from '../../lib/cn.ts'
22
+ import { Button } from '../primitives/button.tsx'
23
+ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '../primitives/resizable.tsx'
24
+
25
+ interface AppShellContextValue {
26
+ /** O shell permite recolher? Sem isso o trigger não se renderiza. */
27
+ collapsible: boolean
28
+ collapsed: boolean
29
+ setCollapsed: (collapsed: boolean) => void
30
+ toggle: () => void
31
+ }
32
+
33
+ const AppShellContext = createContext<AppShellContextValue | null>(null)
34
+
35
+ /** Estado do shell (recolhido/expandido) pra quem precisa dele em JS. Em CSS, prefira o
36
+ * seletor de grupo (`group-data-[collapsed=true]/sidebar:…`) — não precisa de hook. */
37
+ export function useAppShell(): AppShellContextValue {
38
+ const ctx = useContext(AppShellContext)
39
+ if (ctx === null) throw new Error('useAppShell precisa de um <AppShell> acima na árvore.')
40
+ return ctx
41
+ }
42
+
43
+ interface SidebarSlotValue {
44
+ collapsed: boolean
45
+ }
46
+ /** Contexto presente SÓ dentro dos slots da sidebar (header/nav/rodapé), AUSENTE no
47
+ * conteúdo. `useAppShell` não distingue os dois (o colapso vale a árvore toda); este sim.
48
+ * É como um nav PIVOTÁVEL (o `<ShellNav>`) sabe se está NA sidebar — e se ela está
49
+ * recolhida — sem o app passar isso em duas mãos (fecha a nota n=2 do gb-nav-rail). */
50
+ const SidebarSlotContext = createContext<SidebarSlotValue | null>(null)
51
+ /** Null quando FORA da sidebar (ex.: um `<ShellNav>` dentro do conteúdo, num SectionShell). */
52
+ export function useSidebarSlot(): SidebarSlotValue | null {
53
+ return useContext(SidebarSlotContext)
54
+ }
55
+
56
+ export interface AppShellProps {
57
+ /** Topo da sidebar (marca, seletor de contexto/usuário). */
58
+ sidebarHeader?: ReactNode
59
+ /** Miolo da sidebar — a navegação; rola sozinho quando passa da altura. */
60
+ sidebarNav: ReactNode
61
+ /** Rodapé da sidebar, ancorado embaixo (status, ações globais). */
62
+ sidebarFooter?: ReactNode
63
+ /** Rail opcional à direita (chat de agente, inspetor). Sem ele, o conteúdo ocupa tudo. */
64
+ rail?: ReactNode
65
+ /** Tamanho inicial (%) do painel de conteúdo quando há rail. Default: 62. */
66
+ mainDefaultSize?: number
67
+ /** Tamanho mínimo (%) do painel de conteúdo. Default: 35. */
68
+ mainMinSize?: number
69
+ /** Tamanho mínimo (%) do rail. Default: 25. */
70
+ railMinSize?: number
71
+ /** Classes da raiz. Default: altura da tela (`h-screen`); em embeds, passe ex. `h-96`. */
72
+ className?: string
73
+ /** Classes do aside. Default: `w-64 shrink-0 flex-col p-2`. Largura passada aqui manda no
74
+ * estado EXPANDIDO; no recolhido quem vence é `sidebarCollapsedClassName` (recolher é
75
+ * estado, não estilo — senão o aside ficaria meio-recolhido). */
76
+ sidebarClassName?: string
77
+ /** Permite recolher a sidebar — OPT-IN: sem isso o shell é o de sempre. */
78
+ collapsible?: boolean
79
+ /** Recolhida? Passe junto com `onCollapsedChange` pra controlar de fora (ex.: persistir
80
+ * a preferência); omita e o shell guarda o estado sozinho. */
81
+ collapsed?: boolean
82
+ /** Avisa que o estado mudou (obrigatório na forma controlada). */
83
+ onCollapsedChange?: (collapsed: boolean) => void
84
+ /** Largura do aside recolhido — só o suficiente pros ícones. Default: `w-14`. */
85
+ sidebarCollapsedClassName?: string
86
+ /** Chrome RENTE ao browser (sem inset): sidebar sem padding e conteúdo full-bleed
87
+ * apartado pelo filete (`border-l`) — o shell NÃO pinta fundo (o canvas vem do
88
+ * body); superfície branca é decisão do conteúdo (pinte `bg-background` no seu
89
+ * contêiner). Combine com `<AppShellBar>` na sidebar e no topo do conteúdo pra
90
+ * linha do header atravessar a tela inteira. Default: false (chrome clássico). */
91
+ flush?: boolean
92
+ children: ReactNode
93
+ }
94
+
95
+ export function AppShell({
96
+ sidebarHeader,
97
+ sidebarNav,
98
+ sidebarFooter,
99
+ rail,
100
+ mainDefaultSize = 62,
101
+ mainMinSize = 35,
102
+ railMinSize = 25,
103
+ className,
104
+ sidebarClassName,
105
+ collapsible = false,
106
+ collapsed: collapsedProp,
107
+ onCollapsedChange,
108
+ sidebarCollapsedClassName = 'w-14',
109
+ flush = false,
110
+ children,
111
+ }: AppShellProps): React.ReactElement {
112
+ // Controlado quando `collapsed` vem de fora; senão o shell guarda sozinho.
113
+ const [internal, setInternal] = useState(false)
114
+ const isControlled = collapsedProp !== undefined
115
+ const collapsed = collapsible && (isControlled ? collapsedProp : internal)
116
+
117
+ const setCollapsed = useCallback(
118
+ (next: boolean) => {
119
+ if (!isControlled) setInternal(next)
120
+ onCollapsedChange?.(next)
121
+ },
122
+ [isControlled, onCollapsedChange],
123
+ )
124
+ const ctx = useMemo<AppShellContextValue>(
125
+ () => ({ collapsible, collapsed, setCollapsed, toggle: () => setCollapsed(!collapsed) }),
126
+ [collapsible, collapsed, setCollapsed],
127
+ )
128
+ const sidebarSlot = useMemo<SidebarSlotValue>(() => ({ collapsed: collapsible && collapsed }), [collapsible, collapsed])
129
+
130
+ const main = (
131
+ <div
132
+ data-slot="app-shell-main"
133
+ className={cn('flex h-full min-w-0 flex-1 flex-col', flush && 'border-l border-border')}
134
+ >
135
+ {children}
136
+ </div>
137
+ )
138
+ return (
139
+ <AppShellContext.Provider value={ctx}>
140
+ <div data-slot="app-shell" className={cn('flex h-screen', className)}>
141
+ <aside
142
+ data-slot="app-shell-sidebar"
143
+ // `data-collapsed` + grupo nomeado: o conteúdo do consumidor reage por CSS.
144
+ data-collapsed={collapsible ? String(collapsed) : undefined}
145
+ className={cn(
146
+ 'group/sidebar flex shrink-0 flex-col',
147
+ collapsible && 'transition-[width] duration-200 ease-out',
148
+ 'w-64',
149
+ flush ? 'p-0' : 'p-2',
150
+ sidebarClassName,
151
+ // DEPOIS do `sidebarClassName` de propósito: recolhido é ESTADO, não estilo
152
+ // default. Quem passa a própria largura (`sidebarClassName="w-72"`) manda no
153
+ // expandido, mas não pode impedir o aside de estreitar — senão o shell ficaria
154
+ // meio-recolhido (rótulo some, largura fica) sem erro nenhum.
155
+ collapsed && sidebarCollapsedClassName,
156
+ )}
157
+ >
158
+ <SidebarSlotContext.Provider value={sidebarSlot}>
159
+ {sidebarHeader}
160
+ <div data-slot="app-shell-nav" className="min-h-0 flex-1 overflow-y-auto">
161
+ {sidebarNav}
162
+ </div>
163
+ {sidebarFooter}
164
+ </SidebarSlotContext.Provider>
165
+ </aside>
166
+ {rail === undefined ? (
167
+ main
168
+ ) : (
169
+ <ResizablePanelGroup orientation="horizontal" className="min-w-0 flex-1">
170
+ <ResizablePanel defaultSize={mainDefaultSize} minSize={mainMinSize}>
171
+ {main}
172
+ </ResizablePanel>
173
+ <ResizableHandle withHandle />
174
+ <ResizablePanel defaultSize={100 - mainDefaultSize} minSize={railMinSize}>
175
+ <div data-slot="app-shell-rail" className="h-full">
176
+ {rail}
177
+ </div>
178
+ </ResizablePanel>
179
+ </ResizablePanelGroup>
180
+ )}
181
+ </div>
182
+ </AppShellContext.Provider>
183
+ )
184
+ }
185
+
186
+ /**
187
+ * <AppShellTrigger /> — recolhe/expande a sidebar. Some sozinho quando o shell não é
188
+ * `collapsible`, então dá pra deixar fixo no layout sem condicional no consumidor.
189
+ */
190
+ export function AppShellTrigger({ className }: { className?: string }): React.ReactElement | null {
191
+ const { collapsible, collapsed, toggle } = useAppShell()
192
+ if (!collapsible) return null
193
+ return (
194
+ <Button
195
+ variant="ghost"
196
+ size="icon"
197
+ onClick={toggle}
198
+ aria-label={collapsed ? 'Expandir a barra lateral' : 'Recolher a barra lateral'}
199
+ aria-expanded={!collapsed}
200
+ className={className}
201
+ >
202
+ <PanelLeft className={cn('transition-transform duration-200', collapsed && 'rotate-180')} />
203
+ </Button>
204
+ )
205
+ }
206
+
207
+ /**
208
+ * <AppShellBar /> — a linha de header do chrome `flush`: faixa h-12 com `border-b`.
209
+ * Use UMA na sidebar (a marca) e OUTRA no topo do conteúdo (breadcrumb/ações): as
210
+ * alturas casam e as bordas emendam — uma régua contínua atravessando a tela.
211
+ */
212
+ export function AppShellBar({
213
+ className,
214
+ children,
215
+ }: {
216
+ className?: string
217
+ children?: ReactNode
218
+ }): React.ReactElement {
219
+ return (
220
+ <div
221
+ data-slot="app-shell-bar"
222
+ className={cn('flex h-12 shrink-0 items-center gap-2.5 border-b border-border px-4', className)}
223
+ >
224
+ {children}
225
+ </div>
226
+ )
227
+ }