@softize/opus 25.5.0 → 25.7.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.
@@ -0,0 +1,255 @@
1
+ import { useEffect, type ReactElement, type ReactNode } from "react";
2
+ import { Search } from "lucide-react";
3
+ import { Input } from "../components/primitives/input.tsx";
4
+ import { Badge } from "../components/primitives/badge.tsx";
5
+ import {
6
+ Alert,
7
+ AlertHeader,
8
+ AlertTitle,
9
+ AlertDescription,
10
+ } from "../components/primitives/alert.tsx";
11
+ import { Markdown, markdownIt } from "../components/primitives/markdown.tsx";
12
+ import {
13
+ filterDocumentationRecords,
14
+ type DocumentationRecord,
15
+ } from "./records.ts";
16
+ import { navigate } from "../router.ts";
17
+
18
+ function follow(
19
+ event: React.MouseEvent<HTMLAnchorElement>,
20
+ href: string,
21
+ ): void {
22
+ if (
23
+ event.button !== 0 ||
24
+ event.metaKey ||
25
+ event.ctrlKey ||
26
+ event.shiftKey ||
27
+ event.altKey
28
+ )
29
+ return;
30
+ event.preventDefault();
31
+ navigate(href);
32
+ }
33
+
34
+ function DocumentationLink({
35
+ href,
36
+ children,
37
+ }: {
38
+ href: string;
39
+ children: ReactNode;
40
+ }): ReactElement {
41
+ return (
42
+ <a
43
+ href={href}
44
+ onClick={(event) => follow(event, href)}
45
+ className="underline-offset-4 hover:underline"
46
+ >
47
+ {children}
48
+ </a>
49
+ );
50
+ }
51
+
52
+ /** O resumo orienta a escolha; links e formatação continuam no documento completo. */
53
+ function summaryText(source: string): string {
54
+ return (markdownIt.parseInline(source, {})[0]?.children ?? [])
55
+ .map((token) =>
56
+ token.type === "softbreak" || token.type === "hardbreak"
57
+ ? " "
58
+ : token.content,
59
+ )
60
+ .join("");
61
+ }
62
+
63
+ export interface DocCatalogProps {
64
+ title: string;
65
+ description: string;
66
+ records: readonly DocumentationRecord[];
67
+ /** Consulta local do catálogo, independente da busca documental do cabeçalho. */
68
+ query: string;
69
+ onQueryChange: (query: string) => void;
70
+ kind: "decisions" | "releases";
71
+ }
72
+
73
+ /** Leitura dos arquivos publicados, com busca textual e sem controles de coleção. */
74
+ export function DocCatalog({
75
+ title,
76
+ description,
77
+ records,
78
+ query,
79
+ onQueryChange,
80
+ kind,
81
+ }: DocCatalogProps): ReactElement {
82
+ const filtered = filterDocumentationRecords(records, query).sort(
83
+ (left, right) =>
84
+ kind === "decisions"
85
+ ? Number(right.id) - Number(left.id)
86
+ : right.id.localeCompare(left.id, "en", { numeric: true }),
87
+ );
88
+ return (
89
+ <div data-slot="doc-catalog" className="h-full overflow-y-auto">
90
+ <section className="mx-auto w-full max-w-3xl px-4 py-8 md:px-8 md:py-12">
91
+ <h1 className="text-3xl font-semibold tracking-tight">{title}</h1>
92
+ <p className="mt-3 text-base text-muted-foreground">{description}</p>
93
+ <div className="mt-6 max-w-[28rem]">
94
+ <Input
95
+ type="search"
96
+ icon={<Search aria-hidden="true" />}
97
+ aria-label={
98
+ kind === "decisions" ? "Buscar nas ADRs" : "Buscar no changelog"
99
+ }
100
+ placeholder={
101
+ kind === "decisions" ? "Buscar nas ADRs…" : "Buscar no changelog…"
102
+ }
103
+ value={query}
104
+ onChange={(event) => onQueryChange(event.target.value)}
105
+ />
106
+ </div>
107
+ <p
108
+ role="status"
109
+ aria-live="polite"
110
+ aria-atomic="true"
111
+ className={
112
+ query.trim() ? "mt-5 text-sm text-muted-foreground" : "sr-only"
113
+ }
114
+ >
115
+ {filtered.length} de {records.length} registros
116
+ </p>
117
+ <div className="mt-10 space-y-10">
118
+ {filtered.map((record) => (
119
+ <article
120
+ key={record.href}
121
+ data-slot="doc-catalog-entry"
122
+ className="border-t border-border/60 pt-6"
123
+ >
124
+ <div className="flex flex-wrap items-baseline gap-x-3 gap-y-2">
125
+ <h2 className="text-xl font-semibold tracking-tight">
126
+ <DocumentationLink href={record.href}>
127
+ {kind === "decisions"
128
+ ? `ADR ${record.id} — ${record.title}`
129
+ : record.id}
130
+ </DocumentationLink>
131
+ </h2>
132
+ {kind === "releases" && (
133
+ <span className="text-sm text-muted-foreground">
134
+ {record.date ?? "Data não informada"}
135
+ </span>
136
+ )}
137
+ {kind === "releases" && record.breaking !== undefined && (
138
+ <Badge context="danger" variant="solid">
139
+ Incompatível
140
+ </Badge>
141
+ )}
142
+ </div>
143
+ {kind === "decisions" ? (
144
+ <>
145
+ <p className="mt-2 text-sm text-muted-foreground">
146
+ {record.date ?? "Data não informada"} ·{" "}
147
+ {record.status ?? "Status não informado"}
148
+ </p>
149
+ {record.summary && (
150
+ <p className="mt-3 text-sm leading-7">
151
+ {summaryText(record.summary)}
152
+ </p>
153
+ )}
154
+ </>
155
+ ) : (
156
+ <>
157
+ <div className="mt-3">
158
+ <Markdown
159
+ content={record.content ?? record.body}
160
+ resolveHref={(href) =>
161
+ href.startsWith("#") ? `${record.href}${href}` : href
162
+ }
163
+ />
164
+ </div>
165
+ {record.breaking !== undefined && (
166
+ <Alert context="danger" role="note" className="mt-4">
167
+ <AlertHeader>
168
+ <AlertTitle>Breaking: cuidados ao atualizar</AlertTitle>
169
+ <AlertDescription>
170
+ <Markdown
171
+ content={record.breaking}
172
+ resolveHref={(href) =>
173
+ href.startsWith("#") ? `${record.href}${href}` : href
174
+ }
175
+ />
176
+ </AlertDescription>
177
+ </AlertHeader>
178
+ </Alert>
179
+ )}
180
+ {record.afterBreaking && (
181
+ <div className="mt-3">
182
+ <Markdown
183
+ content={record.afterBreaking}
184
+ resolveHref={(href) =>
185
+ href.startsWith("#") ? `${record.href}${href}` : href
186
+ }
187
+ />
188
+ </div>
189
+ )}
190
+ </>
191
+ )}
192
+ </article>
193
+ ))}
194
+ {filtered.length === 0 && (
195
+ <p className="border-t border-border/60 pt-6 text-sm text-muted-foreground">
196
+ Nenhum registro encontrado. Tente outro termo.
197
+ </p>
198
+ )}
199
+ </div>
200
+ </section>
201
+ </div>
202
+ );
203
+ }
204
+
205
+ export interface DocRecordDetailProps {
206
+ catalogLabel: string;
207
+ children: ReactNode;
208
+ }
209
+
210
+ export function DocRecordDetail({
211
+ catalogLabel,
212
+ children,
213
+ }: DocRecordDetailProps): ReactElement {
214
+ useEffect(() => {
215
+ let frame: number | undefined;
216
+ let locationKey: string | undefined;
217
+ const scrollToFragment = () => {
218
+ const nextKey = window.location.pathname + window.location.hash;
219
+ if (nextKey === locationKey) return;
220
+ locationKey = nextKey;
221
+ if (frame !== undefined) cancelAnimationFrame(frame);
222
+ if (!window.location.hash) return;
223
+ frame = requestAnimationFrame(() => {
224
+ let id = window.location.hash.slice(1);
225
+ try {
226
+ id = decodeURIComponent(id);
227
+ } catch {
228
+ /* Um fragmento inválido não interrompe a leitura. */
229
+ }
230
+ document.getElementById(id)?.scrollIntoView({ block: "start" });
231
+ });
232
+ };
233
+ scrollToFragment();
234
+ window.addEventListener("popstate", scrollToFragment);
235
+ window.addEventListener("hashchange", scrollToFragment);
236
+ return () => {
237
+ if (frame !== undefined) cancelAnimationFrame(frame);
238
+ window.removeEventListener("popstate", scrollToFragment);
239
+ window.removeEventListener("hashchange", scrollToFragment);
240
+ };
241
+ }, []);
242
+ return (
243
+ <div data-slot="doc-record-detail" className="h-full overflow-y-auto">
244
+ <div className="mx-auto max-w-3xl px-4 py-8 md:px-8">
245
+ <p
246
+ data-slot="doc-record-context"
247
+ className="mb-3 text-sm text-muted-foreground"
248
+ >
249
+ {catalogLabel}
250
+ </p>
251
+ <article>{children}</article>
252
+ </div>
253
+ </div>
254
+ );
255
+ }
@@ -0,0 +1,74 @@
1
+ import type {
2
+ ComponentProps,
3
+ CSSProperties,
4
+ ReactElement,
5
+ ReactNode,
6
+ } from "react";
7
+ import { cn } from "../lib/cn.ts";
8
+
9
+ export interface DocHeaderProps extends Omit<
10
+ ComponentProps<"header">,
11
+ "children"
12
+ > {
13
+ brand: ReactNode;
14
+ search?: ReactNode;
15
+ actions?: ReactNode;
16
+ navigation?: ReactNode;
17
+ /** Largura da coluna de marca, normalmente igual à lateral. Default: 17rem. */
18
+ brandWidth?: string;
19
+ }
20
+
21
+ /** Topo documental: controles alinhados e navegação global em uma linha própria. */
22
+ export function DocHeader({
23
+ brand,
24
+ search,
25
+ actions,
26
+ navigation,
27
+ brandWidth = "17rem",
28
+ className,
29
+ style,
30
+ ...props
31
+ }: DocHeaderProps): ReactElement {
32
+ return (
33
+ <header
34
+ data-slot="doc-header"
35
+ className={cn("shrink-0 border-b border-border bg-background", className)}
36
+ style={
37
+ { "--doc-header-brand-width": brandWidth, ...style } as CSSProperties
38
+ }
39
+ {...props}
40
+ >
41
+ <div
42
+ data-slot="doc-header-controls"
43
+ className="grid min-h-[4.25rem] grid-cols-[minmax(0,1fr)_auto] items-center gap-x-8 gap-y-4 px-4 py-3 md:px-8 lg:grid-cols-[var(--doc-header-brand-width)_minmax(0,1fr)_var(--doc-header-brand-width)]"
44
+ >
45
+ <div data-slot="doc-header-brand" className="min-w-0">
46
+ {brand}
47
+ </div>
48
+ <div
49
+ data-slot="doc-header-search"
50
+ className={cn(
51
+ "col-span-2 row-start-2 min-w-0 lg:col-span-1 lg:col-start-2 lg:row-start-1",
52
+ search == null && "hidden lg:block",
53
+ )}
54
+ >
55
+ <div className="mx-auto w-full max-w-[22rem]">{search}</div>
56
+ </div>
57
+ <div
58
+ data-slot="doc-header-actions"
59
+ className="col-start-2 row-start-1 flex items-center justify-end gap-1 lg:col-start-3"
60
+ >
61
+ {actions}
62
+ </div>
63
+ </div>
64
+ {navigation != null && (
65
+ <div
66
+ data-slot="doc-header-navigation"
67
+ className="overflow-x-auto px-4 md:px-8"
68
+ >
69
+ {navigation}
70
+ </div>
71
+ )}
72
+ </header>
73
+ );
74
+ }
@@ -3,9 +3,25 @@
3
3
  * Opus, o compilador de exemplos TSX nem os componentes usados apenas por esses exemplos.
4
4
  */
5
5
  export { DocBrowser } from './DocBrowser.tsx'
6
- export type { DocBrowserProps } from './DocBrowser.tsx'
6
+ export { DocHeader } from './DocHeader.tsx'
7
+ export { DocCatalog, DocRecordDetail } from './DocCatalog.tsx'
8
+ export type { DocCatalogProps, DocRecordDetailProps } from './DocCatalog.tsx'
9
+ export { decisionRecord, releaseRecords, parseChangelog, filterDocumentationRecords } from './records.ts'
10
+ export type { DocumentationRecord } from './records.ts'
11
+ export type { DocHeaderProps } from './DocHeader.tsx'
12
+ export type {
13
+ DocBrowserProps,
14
+ DocBrowserSearchOptions,
15
+ } from './DocBrowser.tsx'
7
16
  export { docSectionsFromFolder } from './folder.tsx'
8
17
  export type { FolderOptions, FolderPage } from './folder.tsx'
18
+ export {
19
+ documentationAnchor,
20
+ documentationHeadingAnchor,
21
+ documentationSlugFromSourcePath,
22
+ resolveDocumentationLinkTarget,
23
+ } from './links.ts'
24
+ export type { DocumentationLinkTarget } from './links.ts'
9
25
  export type { DocEntry, DocGroup, DocSection } from './types.ts'
10
26
  export { Markdown } from '../components/primitives/markdown.tsx'
11
27
  export type { MarkdownProps } from '../components/primitives/markdown.tsx'
@@ -5,43 +5,13 @@
5
5
  * O parser é convenção-fina sobre o markdown (`## <versão> — <data>` + `### Breaking`);
6
6
  * o corpo de cada trecho segue renderizado pelo DocMarkdown (fidelidade total).
7
7
  */
8
- import { Badge } from '../react.tsx'
8
+ import { Alert, AlertHeader, AlertTitle, AlertDescription, Badge } from '../react.tsx'
9
9
  import { DocMarkdown } from './markdown.tsx'
10
10
  // A fonte é o CHANGELOG DO PACOTE (raiz) — a mesma que viaja em node_modules.
11
11
  import changelogMd from '../../../CHANGELOG.md?raw'
12
-
13
- export interface ChangelogVersion {
14
- version: string
15
- date?: string
16
- body: string
17
- breaking?: string
18
- }
19
-
20
- export interface ParsedChangelog {
21
- intro: string
22
- versions: ChangelogVersion[]
23
- }
24
-
25
- /** Parseia o changelog: intro (antes do 1º `## `) + versões (`## x — data`, `### Breaking`). */
26
- export function parseChangelog(source: string): ParsedChangelog {
27
- const chunks = `\n${source.trim()}`.split(/\n## /)
28
- const intro = chunks[0]?.trim() ?? ''
29
- const versions: ChangelogVersion[] = []
30
- for (const chunk of chunks.slice(1)) {
31
- const nl = chunk.indexOf('\n')
32
- const header = (nl === -1 ? chunk : chunk.slice(0, nl)).trim()
33
- const rest = nl === -1 ? '' : chunk.slice(nl + 1)
34
- const [version, date] = header.split(/\s+—\s+/)
35
- const [body, breaking] = rest.split(/\n### Breaking\s*\n/)
36
- versions.push({
37
- version: (version ?? header).trim(),
38
- ...(date !== undefined ? { date: date.trim() } : {}),
39
- body: (body ?? '').trim(),
40
- ...(breaking !== undefined ? { breaking: breaking.trim() } : {}),
41
- })
42
- }
43
- return { intro, versions }
44
- }
12
+ import { parseChangelog } from './records.ts'
13
+ export { parseChangelog } from './records.ts'
14
+ export type { ChangelogVersion, ParsedChangelog } from './records.ts'
45
15
 
46
16
  export function ChangelogView({ source }: { source: string }): React.ReactElement {
47
17
  const { intro, versions } = parseChangelog(source)
@@ -56,16 +26,21 @@ export function ChangelogView({ source }: { source: string }): React.ReactElemen
56
26
  {v.date !== undefined && <span className="text-sm text-muted-foreground">{v.date}</span>}
57
27
  {v.breaking !== undefined && <Badge context="danger" variant="solid">Incompatível</Badge>}
58
28
  </div>
59
- {v.body.length > 0 && (
29
+ {(v.beforeBreaking ?? v.body).length > 0 && (
60
30
  <div className="mt-3">
61
- <DocMarkdown content={v.body} />
31
+ <DocMarkdown content={v.beforeBreaking ?? v.body} />
62
32
  </div>
63
33
  )}
64
34
  {v.breaking !== undefined && (
65
- <div className="mt-4 rounded-md border border-destructive/30 bg-destructive/5 px-4 py-3">
66
- <div className="mb-2 text-xs font-medium text-destructive">Breaking — o que fazer</div>
67
- <DocMarkdown content={v.breaking} />
68
- </div>
35
+ <Alert context="danger" role="note" className="mt-4">
36
+ <AlertHeader>
37
+ <AlertTitle>Breaking: cuidados ao atualizar</AlertTitle>
38
+ <AlertDescription><DocMarkdown content={v.breaking} /></AlertDescription>
39
+ </AlertHeader>
40
+ </Alert>
41
+ )}
42
+ {v.afterBreaking && (
43
+ <div className="mt-3"><DocMarkdown content={v.afterBreaking} /></div>
69
44
  )}
70
45
  </section>
71
46
  ))}
@@ -74,7 +49,7 @@ export function ChangelogView({ source }: { source: string }): React.ReactElemen
74
49
  )
75
50
  }
76
51
 
77
- /** O changelog do Opus pronto pra montar (site: página de topo; Maestro: página da doc). */
52
+ /** O changelog do Opus pronto para montar no portal ou na documentação embutida. */
78
53
  export function OpusChangelog(): React.ReactElement {
79
54
  return <ChangelogView source={changelogMd} />
80
55
  }
@@ -222,7 +222,7 @@ receber só `{ id }`: o `id`, do tipo texto, é o único campo obrigatório da e
222
222
  se houver, têm valor padrão ou são opcionais. Os caminhos usam ponto para descer em objetos; um
223
223
  caminho que passa por uma lista vale para cada item, e um campo intermediário `null` aparece como
224
224
  `null`. Declare em `context` só o que orienta a conversa: o exemplo deixa fora contato, documento e
225
- endereço, e o nome já vai como `title`. Um caminho que termina num objeto ou numa lista leva tudo o
225
+ endereço, e o nome já vai como `title`. Um caminho que termina em um objeto ou em uma lista leva tudo o
226
226
  que está abaixo dele, inclusive campos acrescentados depois à saída; para conferir num teste o que
227
227
  cada caminho alcança, use `assistantContextLeaves`, de `@softize/opus/assistant-schema`, sobre o
228
228
  schema de saída do manifest. Ela devolve, por caminho, as folhas em `leaves` e, em `open`, os pontos
@@ -272,7 +272,7 @@ caracteres. Um caminho que falta na saída em execução é omitido com o motivo
272
272
  aviso no log, sem falhar a leitura. Quem recebe a saída por outro canal, como o MCP, aplica a mesma
273
273
  projeção com `projectAssistantContext(assistant, output)`.
274
274
 
275
- Entregue o dado ao modelo como bloco delimitado, numa mensagem de dado, nunca no prompt de sistema.
275
+ Entregue o dado ao modelo como bloco delimitado, em uma mensagem de dado, nunca no prompt de sistema.
276
276
  `resourceDataBlock`, de `@softize/opus/ai`, monta o bloco com o tipo, o id, o título, os dados e,
277
277
  quando houve corte ou ausência, `truncated` e `omitted`, e escapa dentro dos valores o que poderia
278
278
  fechar o delimitador. Quando o modelo chama a action dona como ferramenta, recebe a saída completa
@@ -326,7 +326,7 @@ de uma action que não existe, ou dois cenários para a mesma action, impedem o
326
326
  subir, e o erro lista os nomes.
327
327
 
328
328
  O Opus não decide onde os cenários ficam nem se entram no repositório. Eles não fazem parte da
329
- aplicação: podem ficar numa pasta ignorada pelo git e ser escritos durante a sessão de design,
329
+ aplicação: podem ficar em uma pasta ignorada pelo git e ser escritos durante a sessão de design,
330
330
  quando forem necessários.
331
331
 
332
332
  Dados de design não pertencem ao contrato. Quando uma resposta específica importar para a revisão
@@ -168,7 +168,7 @@ componente da feature em vez de copiá-lo.
168
168
 
169
169
  ## Largura mínima e densidade
170
170
 
171
- O Opus oferece suporte a partir de `64rem` e não muda a composição conforme a largura da viewport.
171
+ A UI das aplicações oferece suporte a partir de `64rem` e não muda a composição conforme a largura da viewport.
172
172
  Props de tamanho, como `size="sm"`, escolhem a densidade do componente; não representam
173
173
  breakpoints. Overflow e limites contra a janela continuam protegendo o conteúdo.
174
174
 
@@ -176,6 +176,10 @@ Não adicione em `className` uma adaptação isolada por viewport ou container.
176
176
  futura precisa começar pelo shell e formar um contrato comum para navegação, splits, tabelas e
177
177
  componentes.
178
178
 
179
+ O [portal de documentação](/docs/documentation-portal) tem uma exceção própria para adaptar
180
+ cabeçalho, leitura editorial e catálogos em telas estreitas. Essa exceção não altera o contrato
181
+ dos componentes demonstrados nos exemplos vivos nem autoriza adaptações isoladas na aplicação.
182
+
179
183
  ## O que fica fora das alavancas
180
184
 
181
185
  Alguns detalhes fazem parte da identidade visual do Opus e são iguais em todo projeto: a faixa do