@softize/opus 12.0.0 → 12.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ Depois de qualquer bump, rode os gates (`typecheck` · `test` · `opus check` ·
7
7
  `opus copy --check` · `base copy check` · `manifest:check`) — eles apontam o que a
8
8
  mudança cobra do seu código.
9
9
 
10
+ ## 12.0.1 — 2026-08-22
11
+
12
+ `Split` volta a atender layouts que precisam preservar uma medida física e a preferência da
13
+ pessoa entre visitas. `Pane.initialSize`, `minSize` e `maxSize` agora aceitam `%`, `rem`, `em`,
14
+ `vh`, `vw` e `px`, enquanto valores numéricos continuam representando porcentagens por
15
+ compatibilidade. `Split` também expõe `id`, `defaultLayout` e `onLayoutChanged`, e cada `Pane`
16
+ pode declarar um `id` estável. Assim, a aplicação pode restaurar e persistir o layout em
17
+ `localStorage`, `sessionStorage` ou outra camada sem voltar aos primitives removidos na 11.0.0.
18
+
19
+ A mudança é aditiva. Separadores continuam acessíveis por teclado, e consumidores que usam
20
+ apenas tamanhos percentuais não precisam alterar o código.
21
+
10
22
  ## 12.0.0 — 2026-08-21
11
23
 
12
24
  **Copy declarativa passa a ter inventário semântico e gate reproduzível.** `opus copy`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "12.0.0",
3
+ "version": "12.0.1",
4
4
  "description": "End-to-end action protocol for TypeScript. Single package with subpath exports (core + adapters).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -2,13 +2,21 @@ import { Children, isValidElement, type CSSProperties, type ReactElement, type R
2
2
  import { cn } from '../../lib/cn.ts'
3
3
  import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '../primitives/resizable.tsx'
4
4
 
5
+ export type PaneSize = number | `${number}%` | `${number}rem` | `${number}em` | `${number}vh` | `${number}vw` | `${number}px`
6
+ export type SplitLayout = Record<string, number>
7
+ export interface SplitLayoutChange {
8
+ isUserInteraction: boolean
9
+ }
10
+
5
11
  export interface PaneProps {
6
- /** Fração inicial do split, em %. O pane que cresce recebe o restante. */
7
- initialSize?: number
8
- /** Fração mínima, em %, quando o split é redimensionável. */
9
- minSize?: number
10
- /** Fração máxima, em %, quando o split é redimensionável. */
11
- maxSize?: number
12
+ /** Identidade estável usada pelo layout redimensionável e por layouts persistidos. */
13
+ id?: string
14
+ /** Tamanho inicial. Números representam porcentagens; strings aceitam %, rem, em, vh, vw e px. */
15
+ initialSize?: PaneSize
16
+ /** Tamanho mínimo quando o split é redimensionável. */
17
+ minSize?: PaneSize
18
+ /** Tamanho máximo quando o split é redimensionável. */
19
+ maxSize?: PaneSize
12
20
  /** Ocupa o espaço remanescente no layout simples. */
13
21
  grow?: boolean
14
22
  /** Respiro interno padrão. Use `none` para chrome, navegação ou conteúdo com inset próprio. */
@@ -18,21 +26,27 @@ export interface PaneProps {
18
26
  }
19
27
 
20
28
  /** Um conteúdo encaixável. Sua posição é dada pela ordem dentro de <Split>. */
21
- export function Pane({ initialSize, grow = false, inset = 'md', className, children }: PaneProps): React.ReactElement {
22
- const style: CSSProperties | undefined = initialSize === undefined ? undefined : { flexBasis: `${initialSize}%` }
29
+ export function Pane({ id, initialSize, grow = false, inset = 'md', className, children }: PaneProps): React.ReactElement {
30
+ const style: CSSProperties | undefined = initialSize === undefined ? undefined : { flexBasis: sizeToCss(initialSize) }
23
31
  const insets = { none: '', sm: 'p-2', md: 'p-4', lg: 'p-6' } as const
24
32
  return (
25
- <div data-slot="pane" data-inset={inset} className={cn('min-h-0 min-w-0 shrink-0', grow && 'flex-1', insets[inset], className)} style={style}>
33
+ <div id={id === undefined ? undefined : String(id)} data-slot="pane" data-inset={inset} className={cn('min-h-0 min-w-0 shrink-0', grow && 'flex-1', insets[inset], className)} style={style}>
26
34
  {children}
27
35
  </div>
28
36
  )
29
37
  }
30
38
 
31
39
  export interface SplitProps {
40
+ /** Identidade estável do grupo redimensionável. */
41
+ id?: string
32
42
  direction?: 'horizontal' | 'vertical'
33
- /** Quando ligado, cada fronteira ganha um handle e os tamanhos passam a ser percentuais. */
43
+ /** Quando ligado, cada fronteira ganha um separador acessível e arrastável. */
34
44
  resizable?: boolean
35
45
  handle?: boolean
46
+ /** Layout percentual restaurado, indexado pelos ids dos panes. */
47
+ defaultLayout?: SplitLayout
48
+ /** Chamado ao concluir uma mudança de layout; pode persistir o resultado em localStorage. */
49
+ onLayoutChanged?: (layout: SplitLayout, meta: SplitLayoutChange) => void
36
50
  className?: string
37
51
  children: ReactNode
38
52
  }
@@ -43,11 +57,20 @@ function panesOf(children: ReactNode): ReactElement<PaneProps>[] {
43
57
  )
44
58
  }
45
59
 
46
- // react-resizable-panels v4 interpreta `number` como pixels. O contrato de Split/Pane
47
- // é percentual (e o layout flex simples abaixo também usa `%`), então a unidade precisa
48
- // ser explícita na fronteira com o primitive.
49
- function percent(value: number | undefined): string | undefined {
50
- return value === undefined ? undefined : `${value}%`
60
+ // Números continuam percentuais por compatibilidade com Split/Pane. Strings preservam
61
+ // a unidade explícita para layouts que precisam de um limite físico, como `28rem`.
62
+ function sizeToCss(value: PaneSize): string {
63
+ return typeof value === 'number' ? `${value}%` : value
64
+ }
65
+
66
+ function resizableSize(value: PaneSize | undefined): string | undefined {
67
+ return value === undefined ? undefined : sizeToCss(value)
68
+ }
69
+
70
+ function percentage(value: PaneSize | undefined): number | undefined {
71
+ if (typeof value === 'number') return value
72
+ if (value?.endsWith('%')) return Number.parseFloat(value)
73
+ return undefined
51
74
  }
52
75
 
53
76
  /**
@@ -55,35 +78,54 @@ function percent(value: number | undefined): string | undefined {
55
78
  * quando fixa e o primitive Resizable quando `resizable`; quem consome não troca de
56
79
  * modelo de layout para ganhar arraste.
57
80
  */
58
- export function Split({ direction = 'horizontal', resizable = false, handle = false, className, children }: SplitProps): React.ReactElement {
81
+ export function Split({
82
+ id,
83
+ direction = 'horizontal',
84
+ resizable = false,
85
+ handle = false,
86
+ defaultLayout,
87
+ onLayoutChanged,
88
+ className,
89
+ children,
90
+ }: SplitProps): React.ReactElement {
59
91
  const panes = panesOf(children)
60
92
  const vertical = direction === 'vertical'
61
93
 
62
94
  if (!resizable) {
63
95
  return (
64
- <div data-slot="split" data-direction={direction} className={cn('flex min-h-0 min-w-0 flex-1', vertical && 'flex-col', className)}>
96
+ <div id={id === undefined ? undefined : String(id)} data-slot="split" data-direction={direction} className={cn('flex min-h-0 min-w-0 flex-1', vertical && 'flex-col', className)}>
65
97
  {panes}
66
98
  </div>
67
99
  )
68
100
  }
69
101
 
70
- const fixed = panes.reduce((sum, pane) => sum + (pane.props.initialSize ?? 0), 0)
102
+ const fixed = panes.reduce((sum, pane) => sum + (percentage(pane.props.initialSize) ?? 0), 0)
103
+ const hasAbsoluteSize = panes.some((pane) => typeof pane.props.initialSize === 'string' && !pane.props.initialSize.endsWith('%'))
71
104
  const growing = panes.filter((pane) => pane.props.grow)
72
105
  const rest = Math.max(0, 100 - fixed)
73
106
  const fallback = panes.length === 0 ? 100 : 100 / panes.length
74
107
 
75
108
  return (
76
- <ResizablePanelGroup orientation={direction} data-slot="split" className={cn('min-h-0 min-w-0 flex-1', className)}>
109
+ <ResizablePanelGroup
110
+ id={id}
111
+ orientation={direction}
112
+ defaultLayout={defaultLayout}
113
+ onLayoutChanged={onLayoutChanged}
114
+ data-slot="split"
115
+ className={cn('min-h-0 min-w-0 flex-1', className)}
116
+ >
77
117
  {panes.flatMap((pane, index) => {
78
- const size = pane.props.initialSize ?? (pane.props.grow ? rest / Math.max(1, growing.length) : fallback)
118
+ const size = pane.props.initialSize
119
+ ?? (hasAbsoluteSize ? undefined : pane.props.grow ? rest / Math.max(1, growing.length) : fallback)
79
120
  const panel = (
80
121
  <ResizablePanel
81
122
  key={pane.key ?? index}
82
- defaultSize={percent(size)}
83
- minSize={percent(pane.props.minSize)}
84
- maxSize={percent(pane.props.maxSize)}
123
+ id={pane.props.id}
124
+ defaultSize={resizableSize(size)}
125
+ minSize={resizableSize(pane.props.minSize)}
126
+ maxSize={resizableSize(pane.props.maxSize)}
85
127
  >
86
- <Pane {...pane.props} className={cn('h-full w-full', pane.props.className)} />
128
+ <Pane {...pane.props} id={undefined} className={cn('h-full w-full', pane.props.className)} />
87
129
  </ResizablePanel>
88
130
  )
89
131
  return index === 0 ? [panel] : [<ResizableHandle key={`handle-${pane.key ?? index}`} withHandle={handle} />, panel]
@@ -1,6 +1,6 @@
1
1
  ## Split & Pane
2
2
 
3
- `Split` é o primitive espacial da UI. A ordem dos `Pane` define esquerda→direita (ou cima→baixo); o pane não sabe onde está. Sem `resizable`, é flex simples. Com ele, a fronteira vira arrastável sem trocar o modelo do componente.
3
+ `Split` organiza áreas lado a lado ou uma sobre a outra. A ordem dos `Pane` define a posição de cada área. Sem `resizable`, o layout usa flex; com essa opção, a pessoa também pode ajustar as divisórias com ponteiro ou teclado.
4
4
 
5
5
  ```tsx preview
6
6
  render(
@@ -23,11 +23,51 @@ O `Pane` traz `inset="md"` por default. Use `none` para chrome, navegação e co
23
23
 
24
24
  ## Rail
25
25
 
26
- Um rail não é uma prop especial: é o último pane.
26
+ Quando uma área precisa continuar legível em telas largas, use uma unidade absoluta para o tamanho inicial ou mínimo. Números continuam representando porcentagens; strings aceitam `%`, `rem`, `em`, `vh`, `vw` e `px`. Prefira `rem` para acompanhar a escala tipográfica configurada pela aplicação.
27
27
 
28
28
  ```tsx
29
29
  <Split resizable>
30
30
  <Pane grow inset="none"><Main /></Pane>
31
- <Pane initialSize={28} minSize={20} inset="none"><Inspector /></Pane>
31
+ <Pane initialSize="28rem" minSize="20rem" inset="none"><Inspector /></Pane>
32
32
  </Split>
33
33
  ```
34
+
35
+ ## Persistência
36
+
37
+ Um layout pode ser restaurado sem acoplar `Split` a um mecanismo específico de armazenamento. Dê um `id` estável a cada `Pane`, leia o valor salvo para formar `defaultLayout` e persista apenas mudanças concluídas pela pessoa.
38
+
39
+ ```tsx
40
+ function readLayout(key: string): SplitLayout | undefined {
41
+ if (typeof window === 'undefined') return undefined
42
+
43
+ try {
44
+ const parsed: unknown = JSON.parse(localStorage.getItem(key) ?? 'null')
45
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined
46
+ const entries = Object.entries(parsed)
47
+ const valid = entries.every(([id, size]) =>
48
+ id !== '' && typeof size === 'number' && Number.isFinite(size) && size >= 0 && size <= 100,
49
+ )
50
+ return valid ? Object.fromEntries(entries) : undefined
51
+ } catch {
52
+ return undefined
53
+ }
54
+ }
55
+
56
+ const defaultLayout = readLayout('workspace-layout')
57
+
58
+ <Split
59
+ id="workspace"
60
+ resizable
61
+ defaultLayout={defaultLayout}
62
+ onLayoutChanged={(layout, meta) => {
63
+ if (meta.isUserInteraction) {
64
+ localStorage.setItem('workspace-layout', JSON.stringify(layout))
65
+ }
66
+ }}
67
+ >
68
+ <Pane id="navigation" initialSize="28rem" minSize="28rem"><Navigation /></Pane>
69
+ <Pane id="content" grow><Content /></Pane>
70
+ </Split>
71
+ ```
72
+
73
+ Em aplicações renderizadas no servidor, leia o armazenamento somente no cliente. `onLayoutChanged` também permite usar `sessionStorage` ou uma camada própria quando o layout precisa acompanhar outro escopo.
package/src/ui/react.tsx CHANGED
@@ -207,7 +207,7 @@ export type { PageProps } from './components/patterns/page.tsx'
207
207
 
208
208
  // Layout composicional: Split decide a relação espacial; Pane carrega conteúdo com inset.
209
209
  export { Split, Pane } from './components/patterns/split.tsx'
210
- export type { SplitProps, PaneProps } from './components/patterns/split.tsx'
210
+ export type { SplitProps, PaneProps, PaneSize, SplitLayout, SplitLayoutChange } from './components/patterns/split.tsx'
211
211
 
212
212
  // Barra lateral composicional: header/conteúdo/footer e navegação, sem possuir o layout.
213
213
  export { PaneHeader, PaneContent, PaneFooter, Sidebar, SidebarItem, SidebarNav } from './components/patterns/sidebar.tsx'