@softize/opus 10.0.0 → 11.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 +43 -0
- package/bin/cli.mjs +1 -1
- package/bin/lib/db-check-runner.mjs +5 -5
- package/bin/lib/db-migrate-runner.mjs +3 -3
- package/bin/lib/db-scaffold-runner.mjs +4 -4
- package/bin/lib/db.mjs +1 -1
- package/bin/lib/gen-manifest.mjs +0 -3
- package/bin/lib/gen-runner.mjs +2 -7
- package/bin/lib/init.mjs +6 -0
- package/bin/lib/materialize.mjs +3 -1
- package/docs/data-layer.md +2 -2
- package/docs/protocol.md +2 -2
- package/package.json +1 -1
- package/registry/instructions/opus.md +3 -0
- package/registry/skills/build-opus-ui/SKILL.md +2 -0
- package/registry/skills/create-opus-action/SKILL.md +2 -0
- package/registry/skills/implement-opus-change/SKILL.md +2 -0
- package/registry/skills/upgrade-opus/SKILL.md +2 -0
- package/registry/skills/write-product-communication/SKILL.md +28 -0
- package/registry/skills/write-product-communication/agents/openai.yaml +4 -0
- package/src/core/domain.ts +3 -6
- package/src/data/readonly-pool.ts +2 -2
- package/src/schema/entity.ts +1 -1
- package/src/ui/components/patterns/shell-nav.tsx +5 -9
- package/src/ui/components/patterns/sidebar.tsx +40 -15
- package/src/ui/docs/DocBrowser.tsx +25 -10
- package/src/ui/docs/content/actions.md +7 -6
- package/src/ui/docs/content/auth.md +3 -2
- package/src/ui/docs/content/cli.md +2 -1
- package/src/ui/docs/content/confirm.md +2 -2
- package/src/ui/docs/content/data.md +1 -1
- package/src/ui/docs/content/getting-started.md +15 -23
- package/src/ui/docs/content/microcopy.md +119 -72
- package/src/ui/docs/content/sidebar.md +21 -1
- package/src/ui/docs/content/upgrading.md +1 -1
- package/src/ui/docs/registry.tsx +1 -7
- package/src/ui/meta.ts +2 -23
- package/src/ui/react.tsx +4 -19
- package/docs/shellnav.md +0 -131
- package/src/ui/components/patterns/app-shell.tsx +0 -227
- package/src/ui/components/patterns/section-shell.tsx +0 -246
- package/src/ui/docs/content/app-shell.md +0 -155
- package/src/ui/docs/content/resizable.md +0 -86
- package/src/ui/docs/content/section-shell.md +0 -121
package/docs/shellnav.md
DELETED
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
# Proposta: `ShellNav` — o menu como primitivo pivotável
|
|
2
|
-
|
|
3
|
-
> **Status: EM DISCUSSÃO.** Desenhado com o João (2026-07-22), a partir do trabalho na
|
|
4
|
-
> cabine do Maestro (a nav do projeto virou `SectionShell`; a sidebar ganhou Início +
|
|
5
|
-
> Configurações). Pendente de aprovação e de uma versão do Opus. Refina o
|
|
6
|
-
> [`ownership-vs-shadcn-lock`](ownership-vs-shadcn-lock.md): o item de menu é o próximo
|
|
7
|
-
> primitivo a sair do "cada app copia ad-hoc".
|
|
8
|
-
|
|
9
|
-
## O problema
|
|
10
|
-
|
|
11
|
-
O `AppShell` entrega a **moldura** da sidebar (o slot `sidebarNav`) e o `SectionShell`
|
|
12
|
-
entrega a **nav de seção** (nav + painel, dentro do conteúdo). Mas o **item de menu** — a
|
|
13
|
-
linha com ícone + label + estado ativo + badge — não é primitivo de ninguém: cada app
|
|
14
|
-
escreve o `<button className="…">` na mão. Resultado, medido (não suposto):
|
|
15
|
-
|
|
16
|
-
| Menu | Onde | Como é feito hoje |
|
|
17
|
-
|---|---|---|
|
|
18
|
-
| Aplicação (sidebar) | `AppShell.sidebarNav` | `<button>` à mão — sem primitivo |
|
|
19
|
-
| Configurações | dentro do conteúdo | `SectionShell` (primitivo) ✓ |
|
|
20
|
-
| Relatórios | dentro do conteúdo | `<aside>`/`<nav>` à mão — **não usa o SectionShell**, mesmo sendo a mesma forma |
|
|
21
|
-
|
|
22
|
-
E isso se repete em **três produtos** (`softize/apps/maestro`, `softize/apps/main/web`,
|
|
23
|
-
`empresa-x/apps/main`), cada um com a própria cópia do item — e, dentro de cada um, o
|
|
24
|
-
markup ainda se repete por estado (ativo/inativo/recolhido) e nível (pai/filho).
|
|
25
|
-
|
|
26
|
-
Dois fatos que calibram a urgência:
|
|
27
|
-
|
|
28
|
-
- **Não é drift visível — é duplicação + risco.** Diff token a token: mesma versão do Opus
|
|
29
|
-
(2.37), mesmo `--radius` (0.625rem), classes de forma **byte-a-byte iguais**
|
|
30
|
-
(`rounded-md px-2.5 py-1.5 gap-2.5`). Só continuam iguais porque um humano copia; nada
|
|
31
|
-
cobra. A "sensação de divergência" (item mais retangular numa app que noutra) vem de
|
|
32
|
-
comparar **componentes diferentes** (nav de app × nav de seção × switcher), não de drift
|
|
33
|
-
do mesmo componente.
|
|
34
|
-
- **Já há uma inconsistência viva:** o menu de Relatórios do GB é uma nav-dentro-do-conteúdo
|
|
35
|
-
— a MESMA forma do Configurações — e mesmo assim hand-rola em vez de usar o `SectionShell`.
|
|
36
|
-
|
|
37
|
-
## A proposta
|
|
38
|
-
|
|
39
|
-
Um primitivo só: **`ShellNav`** — **pivotável e controlado** (presentacional, como o
|
|
40
|
-
`SectionShell` já é). O app passa **dados**, não markup, e o mesmo componente serve os dois
|
|
41
|
-
lares sem flag de "modo app" vs "modo seção":
|
|
42
|
-
|
|
43
|
-
- Na **sidebar do AppShell** (`sidebarNav={<ShellNav …/>}`): lê o estado de recolhido do
|
|
44
|
-
contexto `useAppShell` e vira ícone-só sozinho.
|
|
45
|
-
- **Dentro do conteúdo** (o que o `SectionShell` faz): sem esse contexto, renderiza
|
|
46
|
-
expandido. O `SectionShell` **passa a compor** `ShellNav` (a nav) + o painel — um nav,
|
|
47
|
-
dois lares.
|
|
48
|
-
|
|
49
|
-
Isto é o "tanto faz onde, mesmo comportamento": o componente **se adapta ao contexto** via
|
|
50
|
-
`useAppShell`, sem o consumidor configurar nada.
|
|
51
|
-
|
|
52
|
-
### Heading com espaço pra botão
|
|
53
|
-
|
|
54
|
-
Slot `heading` — título + ação opcional. É o "+" de novo relatório, o título da seção, a
|
|
55
|
-
marca na sidebar. Trivial e resolve o gesto que hoje mora à mão em cada nav.
|
|
56
|
-
|
|
57
|
-
### Árvore embutida; DnD composto (a régua)
|
|
58
|
-
|
|
59
|
-
- **Aninhamento/árvore → dentro do `ShellNav`.** Não é feature nova: o accordion da sidebar
|
|
60
|
-
(Vendas → Leads/Propostas, no GB) já é uma árvore de um nível. Barato, já necessário.
|
|
61
|
-
- **Drag-drop de reordenar → COMPOSTO por fora**, estilo `dnd-kit`: o `ShellNav` expõe a
|
|
62
|
-
costura (um wrapper de item + um slot pro indicador de drop); um companheiro
|
|
63
|
-
(`useNavReorder`) pluga o arrasto só onde se opta. O peso — estado de drag, indicadores,
|
|
64
|
-
toque e principalmente a **a11y** (nav é *landmark*; reorder é *widget de manipulação* —
|
|
65
|
-
identidades diferentes) — vive fora do primitivo.
|
|
66
|
-
|
|
67
|
-
**O critério (viaja pra próximas decisões de primitivo): embute o que a maioria
|
|
68
|
-
compartilha; compõe o que um só precisa.** Reordenar é feature de um consumidor (só o
|
|
69
|
-
Relatórios); embuti-la faria os muitos pagarem a complexidade pelo um.
|
|
70
|
-
|
|
71
|
-
### GB é a aparência de referência
|
|
72
|
-
|
|
73
|
-
Das três, a implementação do GB é a mais madura (tem rail/colapso, accordion, estados
|
|
74
|
-
ativos, âncora inferior de Configurações). **O `ShellNav` codifica o look do GB como
|
|
75
|
-
default** — nada regride: o GB migra pro primitivo e renderiza igual; a cabine (já alinhada
|
|
76
|
-
às classes do GB) passa a ser *literalmente* o mesmo componente, e a "sensação de
|
|
77
|
-
retangular" some. A **identidade por app** continua nos **tokens** (`--radius`, cores — o
|
|
78
|
-
Opus já permite sobrescrever no `theme.css` do app), não num fork do componente: a forma é
|
|
79
|
-
compartilhada; a pele é temável.
|
|
80
|
-
|
|
81
|
-
## API (esboço)
|
|
82
|
-
|
|
83
|
-
```tsx
|
|
84
|
-
interface ShellNavItem {
|
|
85
|
-
id: string
|
|
86
|
-
label: string
|
|
87
|
-
icon?: ReactNode
|
|
88
|
-
badge?: ReactNode
|
|
89
|
-
disabled?: boolean
|
|
90
|
-
children?: ShellNavItem[] // accordion (app) / pasta (relatórios)
|
|
91
|
-
}
|
|
92
|
-
interface ShellNavGroup { label?: string; items: ShellNavItem[] }
|
|
93
|
-
|
|
94
|
-
interface ShellNavProps {
|
|
95
|
-
groups: ShellNavGroup[]
|
|
96
|
-
activeId?: string
|
|
97
|
-
onSelect: (id: string) => void
|
|
98
|
-
heading?: ReactNode // título + espaço pra botão (o "+" do Relatórios)
|
|
99
|
-
footer?: ReactNode // âncora inferior (Configurações, no app)
|
|
100
|
-
collapsed?: boolean // override; default lê do useAppShell quando presente
|
|
101
|
-
navLabel?: string
|
|
102
|
-
reorder?: NavReorderSeam // opt-in; sem isto, ZERO DnD (o dnd-kit/useNavReorder pluga aqui)
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Conveniência do heading (padroniza título + ação):
|
|
106
|
-
<ShellNavHeading title="Relatórios" action={<Button size="icon" icon={Plus} …/>} />
|
|
107
|
-
```
|
|
108
|
-
|
|
109
|
-
O `SectionShell` reescreve por dentro pra `<ShellNav/>` + painel; sua API pública não muda
|
|
110
|
-
(consumidores existentes seguem).
|
|
111
|
-
|
|
112
|
-
## Hoje × proposto
|
|
113
|
-
|
|
114
|
-
| Menu | Hoje | Proposto |
|
|
115
|
-
|---|---|---|
|
|
116
|
-
| App (sidebar) | `<button>` à mão, N cópias por estado | `AppShell sidebarNav={<ShellNav groups footer=Configurações/>}` — colapso via contexto |
|
|
117
|
-
| Configurações | `SectionShell` (nav+painel à mão por dentro) | `SectionShell` compõe `ShellNav` — sem mudança pro consumidor |
|
|
118
|
-
| Relatórios | `<aside>` + tree + DnD, tudo à mão | `<ShellNav heading={título+"+"} …/>` + `useNavReorder` ao lado |
|
|
119
|
-
|
|
120
|
-
## Consequências / plano
|
|
121
|
-
|
|
122
|
-
- **Escopo:** sessão dedicada no Opus (não é ajuste de app). Ciclo: desenhar a API →
|
|
123
|
-
publicar versão do Opus → migrar os consumidores (cada `<button>` à mão vira `groups={…}`).
|
|
124
|
-
- **Custo:** baixo em código, alto em coordenação (bump + migração dos 3 produtos + gates
|
|
125
|
-
em cada). O `SectionShell` é o precedente vivo — foi o mesmo movimento, um nível abaixo.
|
|
126
|
-
- **Ordem sugerida:** (1) `ShellNav` + `ShellNavHeading` no Opus com o look do GB como
|
|
127
|
-
default; (2) `SectionShell` passa a compor; (3) migrar a cabine do Maestro (primeiro
|
|
128
|
-
cliente, já alinhada); (4) migrar o back-office; (5) migrar o Relatórios do GB pra
|
|
129
|
-
`ShellNav` + `useNavReorder` (fecha a inconsistência viva).
|
|
130
|
-
- **Não-objetivo:** o `ShellNav` não vira dono de DnD, tree-editor ou roteamento — segue
|
|
131
|
-
presentacional/controlado; o app é dono do roteamento e do estado.
|
|
@@ -1,227 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,246 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* <SectionShell /> — o terceiro nível do chrome: uma SEÇÃO com navegação própria.
|
|
3
|
-
*
|
|
4
|
-
* O vocabulário de layout tinha dois níveis (AppShell → Page) e faltava o do meio:
|
|
5
|
-
* seção que agrega N telas irmãs (Configurações, Relatórios, a doc) e quer a lista
|
|
6
|
-
* DENTRO do conteúdo, não pendurada na sidebar do app. Sem isso, cada seção
|
|
7
|
-
* redesenhava o mesmo casco `nav + painel` à mão — o DocBrowser, o site do Opus e o
|
|
8
|
-
* back-office da Empresa X chegaram nas mesmas classes por três caminhos.
|
|
9
|
-
*
|
|
10
|
-
* A nav é `w-56` de propósito: mais estreita que o `w-64` do AppShell, pra hierarquia
|
|
11
|
-
* ficar legível quando as duas aparecem lado a lado.
|
|
12
|
-
*
|
|
13
|
-
* Presentacional e CONTROLADO — mesma regra do AppShell: o shell é o quadro. Quem
|
|
14
|
-
* escolhe o item ativo, o que fazer no clique e o que renderizar no painel é o
|
|
15
|
-
* consumidor; roteamento (path, querystring, estado) fica de fora por decisão.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import type { ReactNode } from 'react'
|
|
19
|
-
import { cn } from '../../lib/cn.ts'
|
|
20
|
-
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '../primitives/resizable.tsx'
|
|
21
|
-
|
|
22
|
-
export interface SectionNavItem {
|
|
23
|
-
/** Coordenada estável do item — é o que volta em `onSelect` e casa com `activeId`.
|
|
24
|
-
* Único em TODA a nav (não só no grupo): `activeId` casa por id, então id repetido
|
|
25
|
-
* marca dois itens como atuais e o painel não remonta ao alternar entre eles. */
|
|
26
|
-
id: string
|
|
27
|
-
label: string
|
|
28
|
-
/** Ícone à esquerda do label. */
|
|
29
|
-
icon?: ReactNode
|
|
30
|
-
/** Selo curto à direita (contagem, `beta`, subpath do SDK…). */
|
|
31
|
-
badge?: ReactNode
|
|
32
|
-
/** Visível mas inerte — a tela existe no mapa e ainda não abre. */
|
|
33
|
-
disabled?: boolean
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Sub-rótulo DENTRO de um grupo (o 3º nível: sub-pasta na doc, família num catálogo).
|
|
37
|
-
* Sai mais fraco e mais indentado que o rótulo do grupo. */
|
|
38
|
-
export interface SectionNavSubgroup {
|
|
39
|
-
label?: string
|
|
40
|
-
items: SectionNavItem[]
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export interface SectionNavGroup {
|
|
44
|
-
/** Cabeçalho do grupo. Ausente (ou vazio) = itens soltos, sem rótulo acima. */
|
|
45
|
-
label?: string
|
|
46
|
-
/** Itens direto no grupo. */
|
|
47
|
-
items?: SectionNavItem[]
|
|
48
|
-
/** Subgrupos rotulados, DEPOIS dos `items` soltos. A maioria das seções não precisa
|
|
49
|
-
* — é o nível que a doc usa quando a pasta tem sub-pasta. */
|
|
50
|
-
subgroups?: SectionNavSubgroup[]
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export interface SectionShellProps {
|
|
54
|
-
/** A navegação da seção: grupos (rotulados ou não) → itens. */
|
|
55
|
-
groups: SectionNavGroup[]
|
|
56
|
-
/** `id` do item ativo. Ausente = nenhum destacado (estado vazio, a cargo do consumidor). */
|
|
57
|
-
activeId?: string
|
|
58
|
-
onSelect: (id: string) => void
|
|
59
|
-
/** Topo da nav — título da seção, busca, botão de criar. */
|
|
60
|
-
navHeader?: ReactNode
|
|
61
|
-
/** Rodapé da nav, ancorado embaixo. */
|
|
62
|
-
navFooter?: ReactNode
|
|
63
|
-
/** Classes da raiz. Default: ocupa a altura disponível (`h-full`). */
|
|
64
|
-
className?: string
|
|
65
|
-
/** Classes da COLUNA da nav (o wrapper que empilha header + lista + rodapé), não da
|
|
66
|
-
* `<nav>` de dentro. Default: `w-56 shrink-0 border-r`. */
|
|
67
|
-
navClassName?: string
|
|
68
|
-
/** Rótulo da landmark `<nav>` — o que o leitor de tela anuncia. Só importa de verdade
|
|
69
|
-
* quando o app já tem outra nav na tela (a da sidebar): sem rótulo, as duas viram
|
|
70
|
-
* "navigation" e não se distinguem. */
|
|
71
|
-
navLabel?: string
|
|
72
|
-
/** O que remonta o painel — e remontar é o que zera o scroll. Default: `activeId`
|
|
73
|
-
* (trocar de tela volta ao topo, o esperado). Dois usos legítimos de sobrescrever:
|
|
74
|
-
* uma chave CONSTANTE pra NUNCA remontar (a seção preserva o scroll ao trocar de
|
|
75
|
-
* item), ou uma chave MAIS FINA que o `activeId` pra remontar também dentro da mesma
|
|
76
|
-
* tela (ex.: `${activeId}/${recordId}` numa sub-rota que troca de registro). */
|
|
77
|
-
scrollResetKey?: string
|
|
78
|
-
/** Nav REDIMENSIONÁVEL pelo usuário (arrastar a divisa), como o `rail` do AppShell.
|
|
79
|
-
* Opt-in: sem isto a nav é a coluna fixa `w-56` de sempre. Ligado, nav e painel viram
|
|
80
|
-
* painéis (Resizable) e a largura passa a ser % — o `navClassName` de largura deixa
|
|
81
|
-
* de valer (quem manda é `navDefaultSize`). */
|
|
82
|
-
resizable?: boolean
|
|
83
|
-
/** Largura inicial (%) da nav quando `resizable`. Default: 18 (≈ o w-56 num 1280). */
|
|
84
|
-
navDefaultSize?: number
|
|
85
|
-
/** Largura mínima (%) da nav quando `resizable`. Default: 12. */
|
|
86
|
-
navMinSize?: number
|
|
87
|
-
/** O painel: a tela do item ativo (em geral uma `<Page>`), ou o estado vazio. */
|
|
88
|
-
children: ReactNode
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** Rótulo só existe se tiver texto: vazio (ou só espaço) não vira cabeçalho fantasma —
|
|
92
|
-
* a normalização mora AQUI pra não se repetir em cada consumidor. */
|
|
93
|
-
const titled = (label?: string): boolean => (label ?? '').trim() !== ''
|
|
94
|
-
|
|
95
|
-
/** Idem pro selo: vazio viraria uma pílula com borda e sem texto. `false` é o caso que
|
|
96
|
-
* mais aparece — `badge={isBeta && 'beta'}` é idioma corrente. `0` CONTA (contagem
|
|
97
|
-
* legítima), então truthy não serve. */
|
|
98
|
-
const shown = (badge: ReactNode): boolean => {
|
|
99
|
-
if (badge === undefined || badge === null || badge === false || badge === '') return false
|
|
100
|
-
if (typeof badge === 'number' && Number.isNaN(badge)) return false
|
|
101
|
-
return !(Array.isArray(badge) && badge.length === 0)
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/** Subgrupo só conta se tiver item: `items: []` (uma busca sem match, p.ex.) sairia
|
|
105
|
-
* como sub-cabeçalho órfão. */
|
|
106
|
-
const subFilled = (sub: SectionNavSubgroup): boolean => sub.items.length > 0
|
|
107
|
-
|
|
108
|
-
/** Grupo sem NADA dentro (nem itens, nem subgrupo COM item) não vira `<div>` vazio: ele
|
|
109
|
-
* consumiria um degrau do `space-y-5` do pai e abriria um buraco na nav — ou pior,
|
|
110
|
-
* deixaria o rótulo do grupo pairando sobre nada. */
|
|
111
|
-
const filled = (group: SectionNavGroup): boolean =>
|
|
112
|
-
(group.items?.length ?? 0) > 0 || (group.subgroups?.some(subFilled) ?? false)
|
|
113
|
-
|
|
114
|
-
export function SectionShell({
|
|
115
|
-
groups,
|
|
116
|
-
activeId,
|
|
117
|
-
onSelect,
|
|
118
|
-
navHeader,
|
|
119
|
-
navFooter,
|
|
120
|
-
className,
|
|
121
|
-
navClassName,
|
|
122
|
-
navLabel = 'Navegação da seção',
|
|
123
|
-
scrollResetKey,
|
|
124
|
-
resizable = false,
|
|
125
|
-
navDefaultSize = 18,
|
|
126
|
-
navMinSize = 12,
|
|
127
|
-
children,
|
|
128
|
-
}: SectionShellProps): React.ReactElement {
|
|
129
|
-
// `nested`: item de subgrupo ROTULADO — recua sob o rótulo da categoria, senão os três
|
|
130
|
-
// níveis (seção → categoria → página) sairiam todos na mesma margem.
|
|
131
|
-
const renderItem = (item: SectionNavItem, nested = false): React.ReactElement => {
|
|
132
|
-
const active = item.id === activeId
|
|
133
|
-
return (
|
|
134
|
-
<button
|
|
135
|
-
key={item.id}
|
|
136
|
-
type="button"
|
|
137
|
-
disabled={item.disabled}
|
|
138
|
-
// O destaque do ativo é visual; `aria-current` é o que o leitor de tela ouve —
|
|
139
|
-
// sem ele a nav é uma lista de botões indistintos.
|
|
140
|
-
aria-current={active ? 'page' : undefined}
|
|
141
|
-
onClick={() => onSelect(item.id)}
|
|
142
|
-
className={cn(
|
|
143
|
-
'flex w-full items-center gap-2 rounded-md py-1.5 pr-2.5 text-left text-sm transition-colors',
|
|
144
|
-
nested ? 'pl-6' : 'pl-4',
|
|
145
|
-
'disabled:pointer-events-none disabled:opacity-40',
|
|
146
|
-
active ? 'bg-muted font-medium text-foreground' : 'text-foreground/80 hover:bg-muted/60',
|
|
147
|
-
)}
|
|
148
|
-
>
|
|
149
|
-
{item.icon}
|
|
150
|
-
<span className="min-w-0 flex-1 truncate">{item.label}</span>
|
|
151
|
-
{shown(item.badge) && (
|
|
152
|
-
<span className="shrink-0 rounded border border-border/60 px-1 font-mono text-[10px] leading-tight text-muted-foreground/60">
|
|
153
|
-
{item.badge}
|
|
154
|
-
</span>
|
|
155
|
-
)}
|
|
156
|
-
</button>
|
|
157
|
-
)
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// `div`, não `aside`: isto só empilha header/nav/rodapé. `aside` abriria uma landmark
|
|
161
|
-
// `complementary` anônima ao lado da `navigation` — ruído pro leitor de tela, já que a
|
|
162
|
-
// nav é quem carrega o sentido. Com `resizable`, a largura vem do painel (%), então a
|
|
163
|
-
// coluna perde o `w-56 shrink-0` e ocupa o painel inteiro.
|
|
164
|
-
const nav = (
|
|
165
|
-
<div
|
|
166
|
-
data-slot="section-shell-nav"
|
|
167
|
-
className={cn(
|
|
168
|
-
'flex flex-col border-r border-border',
|
|
169
|
-
resizable ? 'h-full min-w-0' : 'w-56 shrink-0',
|
|
170
|
-
navClassName,
|
|
171
|
-
)}
|
|
172
|
-
>
|
|
173
|
-
{navHeader}
|
|
174
|
-
<nav aria-label={navLabel} className="min-h-0 flex-1 space-y-5 overflow-y-auto px-3 py-5">
|
|
175
|
-
{/* Chave = POSIÇÃO. Rótulo não serve: ele é opcional e repetível (o catálogo
|
|
176
|
-
da doc tem DUAS seções com label vazio), e chave duplicada é reconciliação
|
|
177
|
-
indefinida. Aqui posição é estável e o conteúdo não guarda estado — os
|
|
178
|
-
botões têm chave própria (o id do item). */}
|
|
179
|
-
{groups.map((group, gi) =>
|
|
180
|
-
!filled(group) ? null : (
|
|
181
|
-
<div key={gi} className="space-y-0.5">
|
|
182
|
-
{titled(group.label) && (
|
|
183
|
-
<div className="px-2.5 pb-1 text-xs font-semibold tracking-tight text-foreground/80">
|
|
184
|
-
{group.label}
|
|
185
|
-
</div>
|
|
186
|
-
)}
|
|
187
|
-
{group.items?.map((item) => renderItem(item))}
|
|
188
|
-
{group.subgroups?.map((sub, si) =>
|
|
189
|
-
!subFilled(sub) ? null : (
|
|
190
|
-
<div key={si} className="space-y-0.5">
|
|
191
|
-
{titled(sub.label) && (
|
|
192
|
-
<div className="pb-0.5 pl-4 pr-2.5 pt-1.5 text-[11px] font-medium text-muted-foreground/50">
|
|
193
|
-
{sub.label}
|
|
194
|
-
</div>
|
|
195
|
-
)}
|
|
196
|
-
{sub.items.map((item) => renderItem(item, titled(sub.label)))}
|
|
197
|
-
</div>
|
|
198
|
-
),
|
|
199
|
-
)}
|
|
200
|
-
</div>
|
|
201
|
-
),
|
|
202
|
-
)}
|
|
203
|
-
</nav>
|
|
204
|
-
{navFooter}
|
|
205
|
-
</div>
|
|
206
|
-
)
|
|
207
|
-
|
|
208
|
-
// key: trocar de tela remonta o painel — é o que zera o scroll (senão a tela nova abre
|
|
209
|
-
// na altura em que a anterior estava).
|
|
210
|
-
const main = (
|
|
211
|
-
<div
|
|
212
|
-
key={scrollResetKey ?? activeId}
|
|
213
|
-
data-slot="section-shell-main"
|
|
214
|
-
className={cn('min-h-0 overflow-y-auto', resizable ? 'h-full' : 'flex-1')}
|
|
215
|
-
>
|
|
216
|
-
{children}
|
|
217
|
-
</div>
|
|
218
|
-
)
|
|
219
|
-
|
|
220
|
-
// Redimensionável: nav e painel viram painéis com divisa arrastável — o mesmo
|
|
221
|
-
// mecanismo do `rail` do AppShell, agora no terceiro nível do chrome.
|
|
222
|
-
if (resizable) {
|
|
223
|
-
return (
|
|
224
|
-
<ResizablePanelGroup
|
|
225
|
-
orientation="horizontal"
|
|
226
|
-
data-slot="section-shell"
|
|
227
|
-
className={cn('h-full min-h-0', className)}
|
|
228
|
-
>
|
|
229
|
-
<ResizablePanel defaultSize={navDefaultSize} minSize={navMinSize}>
|
|
230
|
-
{nav}
|
|
231
|
-
</ResizablePanel>
|
|
232
|
-
<ResizableHandle />
|
|
233
|
-
<ResizablePanel defaultSize={100 - navDefaultSize} minSize={30}>
|
|
234
|
-
{main}
|
|
235
|
-
</ResizablePanel>
|
|
236
|
-
</ResizablePanelGroup>
|
|
237
|
-
)
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
return (
|
|
241
|
-
<div data-slot="section-shell" className={cn('flex h-full min-h-0', className)}>
|
|
242
|
-
{nav}
|
|
243
|
-
{main}
|
|
244
|
-
</div>
|
|
245
|
-
)
|
|
246
|
-
}
|