diff --git a/package.json b/package.json
index 161c25faa..f57abc95c 100644
--- a/package.json
+++ b/package.json
@@ -106,6 +106,7 @@
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
+ "@types/use-sync-external-store": "^1.5.0",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.2",
"apca-w3": "^0.1.9",
@@ -154,6 +155,7 @@
"remark-parse": "^11.0.0",
"remark-stringify": "^11.0.0",
"unified": "^11.0.5",
+ "use-sync-external-store": "^1.6.0",
"yaml": "^2.8.3",
"zod": "^4.3.6"
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3ad0f0db9..dd30ac8bc 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -29,6 +29,9 @@ importers:
unified:
specifier: ^11.0.5
version: 11.0.5
+ use-sync-external-store:
+ specifier: ^1.6.0
+ version: 1.6.0(react@19.2.4)
yaml:
specifier: ^2.8.3
version: 2.8.3
@@ -63,6 +66,9 @@ importers:
'@types/react-dom':
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.14)
+ '@types/use-sync-external-store':
+ specifier: ^1.5.0
+ version: 1.5.0
'@vitejs/plugin-react':
specifier: ^6.0.1
version: 6.0.1(@rolldown/plugin-babel@0.2.2(@babel/core@7.29.0)(@babel/runtime@7.29.2)(rolldown@1.0.0-rc.12)(vite@8.0.3(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)))(vite@8.0.3(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))
@@ -2215,6 +2221,9 @@ packages:
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
+ '@types/use-sync-external-store@1.5.0':
+ resolution: {integrity: sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==}
+
'@types/whatwg-mimetype@3.0.2':
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
@@ -7279,6 +7288,8 @@ snapshots:
'@types/unist@3.0.3': {}
+ '@types/use-sync-external-store@1.5.0': {}
+
'@types/whatwg-mimetype@3.0.2': {}
'@types/ws@8.18.1':
diff --git a/src/interactive-os/ui/KeyHintBar.tsx b/src/interactive-os/ui/KeyHintBar.tsx
new file mode 100644
index 000000000..6e40753b6
--- /dev/null
+++ b/src/interactive-os/ui/KeyHintBar.tsx
@@ -0,0 +1,31 @@
+/** @catalog 컨텍스트 키바인딩 힌트 바 — 하단 풋터 배치 */
+import type { ReactElement } from 'react'
+import { ax } from '@styles/ax'
+import { Kbd } from './Kbd'
+
+export interface KeyHint {
+ keys: string[]
+ label: string
+}
+
+export interface KeyHintBarProps {
+ hints: readonly KeyHint[]
+ 'aria-label'?: string
+}
+
+export function KeyHintBar({ hints, 'aria-label': ariaLabel }: KeyHintBarProps): ReactElement {
+ return (
+
+ {hints.map((hint) => (
+
+ {hint.keys.map((k) => {k})}
+ {hint.label}
+
+ ))}
+
+ )
+}
diff --git a/src/interactive-os/ui/MockupBar.tsx b/src/interactive-os/ui/MockupBar.tsx
new file mode 100644
index 000000000..f2f39c700
--- /dev/null
+++ b/src/interactive-os/ui/MockupBar.tsx
@@ -0,0 +1,31 @@
+/** @catalog 목업 플레이스홀더 바 — wireframe용 (ax 기반) */
+import { ax } from '@styles/ax'
+import type { AxWidth } from '@styles/ax'
+
+interface MockupBarProps {
+ width?: AxWidth
+ shape?: 'bar' | 'dot'
+}
+
+/**
+ * Mockup-only primitive. Solid placeholder using role:'item' + surface:'display'.
+ * Width token sizes the horizontal extent; nbsp inside gives it text-line height.
+ * Shape 'dot' renders a square (aspect:'1') for avatar/star placeholders.
+ *
+ * Use inside Phase 3 WireframeWidgets. role:'item' is intentional:
+ * — it has both `width` and `surface` axes (placeholder role lacks surface strength in dark theme).
+ */
+export function MockupBar({ width = 'md', shape = 'bar' }: MockupBarProps) {
+ if (shape === 'dot') {
+ return (
+
+ {'\u00A0'}
+
+ )
+ }
+ return (
+
+ {'\u00A0'}
+
+ )
+}
diff --git a/src/interactive-os/ui/TreeGrid.tsx b/src/interactive-os/ui/TreeGrid.tsx
index 690dfd91b..1b54a55f8 100644
--- a/src/interactive-os/ui/TreeGrid.tsx
+++ b/src/interactive-os/ui/TreeGrid.tsx
@@ -1,29 +1,21 @@
-/** @catalog 트리+그리드 복합 테이블 */
-import React from 'react'
+/** @catalog 트리+그리드 복합 테이블 — 모드 분기 엔트리 */
+import type React from 'react'
import type { NodeState } from '../pattern/types'
-import type { AriaComponentProps, ItemSlots } from './types'
+import type { AriaComponentProps } from './types'
import { Aria } from '../primitives/aria'
-import { AriaInternalContext } from '../primitives/AriaInternalContext'
-import { GRID_COL_ID } from '../axis/navigate'
-import { treegrid } from '../pattern/roles/treegrid'
-import { history } from '../plugins/history'
-import { edit, replaceEditPlugin } from '../plugins/edit'
-import { search } from '../plugins/search'
-import { cellEdit } from '../plugins/cellEdit'
-import { ExpandIndicator, SortIndicator } from './indicators'
-import { TreeItem, EditableTreeItem } from './items'
-import { ax } from '@styles/ax'
+import { TreeGridColumns } from './TreeGridColumns'
+import { TreeGridSimple } from './TreeGridSimple'
-// ── Column mode (Grid-like columns + tree hierarchy) ──
+// ── Column mode types ──
-interface ColumnDef {
+export interface ColumnDef {
key: string
header: string
width?: string
}
-type RenderCell = (
+export type RenderCell = (
props: React.HTMLAttributes,
value: unknown,
column: ColumnDef,
@@ -31,7 +23,7 @@ type RenderCell = (
data?: Record,
) => React.ReactElement
-interface TreeGridColumnProps extends Omit {
+export interface TreeGridColumnProps extends Omit {
id?: string
columns: ColumnDef[]
renderCell?: RenderCell
@@ -44,9 +36,9 @@ interface TreeGridColumnProps extends Omit {
keyMap?: Record
}
-// ── Simple mode (TreeItem-based, no columns) ──
+// ── Simple mode types ──
-interface TreeGridSimpleProps extends AriaComponentProps {
+export interface TreeGridSimpleProps extends AriaComponentProps {
id?: string
enableEditing?: boolean
columns?: number
@@ -58,215 +50,10 @@ function isColumnMode(props: TreeGridProps): props is TreeGridColumnProps {
return Array.isArray((props as TreeGridColumnProps).columns)
}
-// ── Simple mode helpers ──
-
-function makeRenderItem(editable: boolean, slots?: ItemSlots) {
- const Item = editable ? EditableTreeItem : TreeItem
- if (!slots) return (props: React.HTMLAttributes, node: Record, state: NodeState): React.ReactElement =>
- Item(props, node, state)
- return (props: React.HTMLAttributes, node: Record, state: NodeState): React.ReactElement =>
- Item(props, node, state, {
- icon: slots.icon?.(node, state),
- rightContent: slots.rightContent?.(node, state),
- })
-}
-
-// ── Column mode: default cell renderer ──
-
-const defaultRenderCell: RenderCell = (props, value, _column, _state) => (
- {String(value ?? '')}
-)
-
// Re-export Cell for grid consumers (e.g. TreegridEmail)
// eslint-disable-next-line react-refresh/only-export-components
export const Cell = Aria.Cell
-/** Row wrapper that surfaces `data-row-mode` when colIndex === -1 so CSS can paint
- * the whole row (not individual cells) as the active selection target. */
-function TreeGridRow({
- ariaProps,
- focused,
- selected,
- children,
-}: {
- ariaProps: React.HTMLAttributes
- focused: boolean
- selected: boolean
- children: React.ReactNode
-}) {
- const aria = React.useContext(AriaInternalContext)
- const store = aria?.getStore()
- const colIndex = (store?.entities[GRID_COL_ID]?.colIndex as number | undefined) ?? -1
- const rowMode = colIndex < 0
- return (
-
- {children}
-
- )
-}
-
-// ── Column mode component ──
-
-function TreeGridColumns({
- id,
- data,
- columns,
- plugins: userPlugins,
- onChange,
- onActivate,
- onFocusChange,
- renderCell = defaultRenderCell,
- enableEditing = false,
- searchable = false,
- header = false,
- sortKey,
- sortDir,
- onSortColumn,
- keyMap,
- 'aria-label': ariaLabel,
-}: TreeGridColumnProps) {
- const plugins = userPlugins ?? []
- const pattern = React.useMemo(
- () => treegrid(columns.length),
- [columns.length],
- )
-
- const mergedPlugins = React.useMemo(
- () => {
- const result = [...plugins]
- if (enableEditing) { result.push(edit({ tree: true }), replaceEditPlugin(), cellEdit()) }
- if (searchable) { result.push(search()) }
- return result
- },
- [plugins, enableEditing, searchable],
- )
-
- const gridStyle = React.useMemo(
- () => {
- const hasCustomWidth = columns.some(c => c.width)
- if (hasCustomWidth) {
- return { '--grid-columns': columns.map(c => c.width ?? '1fr').join(' ') } as React.CSSProperties
- }
- return { '--grid-col-count': columns.length } as React.CSSProperties
- },
- [columns],
- )
-
- const renderRow = (props: React.HTMLAttributes, node: Record, state: NodeState): React.ReactElement => {
- const data = node.data as Record | undefined
- const cells = data?.cells as unknown[] | undefined
- const hasChildren = state.expanded !== undefined
- const depth = (state.level ?? 1) - 1
-
- return (
-
- {columns.map((col, i) => (
-
- {i === 0 ? (
-
- {hasChildren
- ?
- : depth > 0 && }
- {renderCell({} as React.HTMLAttributes, cells?.[i], col, state, data)}
-
- ) : (
- renderCell({} as React.HTMLAttributes, cells?.[i], col, state, data)
- )}
-
- ))}
-
- )
- }
-
- return (
-
- {header && (
-
- {columns.map((col) => (
-
-
-
- ))}
-
- )}
-
- {searchable && }
-
-
-
- )
-}
-
-// ── Simple mode component ──
-
-function TreeGridSimple({
- id,
- data,
- plugins = [history()],
- onChange,
- renderItem,
- itemSlots,
- enableEditing = false,
- columns,
- onActivate,
- onFocusChange,
- 'aria-label': ariaLabel,
-}: TreeGridSimpleProps) {
- const defaultRenderer = React.useMemo(() => makeRenderItem(enableEditing, itemSlots), [enableEditing, itemSlots])
- const resolvedRenderItem = renderItem ?? defaultRenderer
- const pattern = React.useMemo(
- () => columns ? treegrid(columns) : treegrid(1),
- [columns],
- )
-
- const mergedPlugins = React.useMemo(
- () => enableEditing ? [...plugins, edit({ tree: true }), replaceEditPlugin()] : plugins,
- [plugins, enableEditing],
- )
-
- return (
-
-
-
- )
-}
-
// ── Public API ──
export function TreeGrid(props: TreeGridProps) {
diff --git a/src/interactive-os/ui/TreeGridCell.tsx b/src/interactive-os/ui/TreeGridCell.tsx
new file mode 100644
index 000000000..7cf05d55a
--- /dev/null
+++ b/src/interactive-os/ui/TreeGridCell.tsx
@@ -0,0 +1,44 @@
+/** @catalog TreeGrid 셀 한 칸 — Aria.Cell + col 0의 depth indent + ExpandIndicator 주입 */
+import React from 'react'
+import type { NodeState } from '../pattern/types'
+import { Aria } from '../primitives/aria'
+import { ExpandIndicator } from './indicators'
+import { ax } from '@styles/ax'
+import type { ColumnDef, RenderCell } from './TreeGrid'
+
+interface TreeGridCellProps {
+ column: ColumnDef
+ columnIndex: number
+ value: unknown
+ state: NodeState
+ data?: Record
+ depth: number
+ hasChildren: boolean
+ renderCell: RenderCell
+}
+
+export function TreeGridCell({
+ column,
+ columnIndex,
+ value,
+ state,
+ data,
+ depth,
+ hasChildren,
+ renderCell,
+}: TreeGridCellProps): React.ReactElement {
+ return (
+
+ {columnIndex === 0 ? (
+
+ {hasChildren
+ ?
+ : depth > 0 && }
+ {renderCell({} as React.HTMLAttributes, value, column, state, data)}
+
+ ) : (
+ renderCell({} as React.HTMLAttributes, value, column, state, data)
+ )}
+
+ )
+}
diff --git a/src/interactive-os/ui/TreeGridColumns.tsx b/src/interactive-os/ui/TreeGridColumns.tsx
new file mode 100644
index 000000000..7cd6bdcae
--- /dev/null
+++ b/src/interactive-os/ui/TreeGridColumns.tsx
@@ -0,0 +1,127 @@
+/** @catalog TreeGrid column 모드 — header + row 렌더 + plugin 합성 */
+import React from 'react'
+import type { NodeState } from '../pattern/types'
+import { Aria } from '../primitives/aria'
+import { treegrid } from '../pattern/roles/treegrid'
+import { edit, replaceEditPlugin } from '../plugins/edit'
+import { search } from '../plugins/search'
+import { cellEdit } from '../plugins/cellEdit'
+import { SortIndicator } from './indicators'
+import { ax } from '@styles/ax'
+import { TreeGridRow } from './TreeGridRow'
+import { TreeGridCell } from './TreeGridCell'
+import type { TreeGridColumnProps, RenderCell } from './TreeGrid'
+
+const defaultRenderCell: RenderCell = (props, value) => (
+ {String(value ?? '')}
+)
+
+export function TreeGridColumns({
+ id,
+ data,
+ columns,
+ plugins: userPlugins,
+ onChange,
+ onActivate,
+ onFocusChange,
+ renderCell = defaultRenderCell,
+ enableEditing = false,
+ searchable = false,
+ header = false,
+ sortKey,
+ sortDir,
+ onSortColumn,
+ keyMap,
+ 'aria-label': ariaLabel,
+}: TreeGridColumnProps): React.ReactElement {
+ const plugins = userPlugins ?? []
+ const pattern = React.useMemo(
+ () => treegrid(columns.length),
+ [columns.length],
+ )
+
+ const mergedPlugins = React.useMemo(
+ () => {
+ const result = [...plugins]
+ if (enableEditing) { result.push(edit({ tree: true }), replaceEditPlugin(), cellEdit()) }
+ if (searchable) { result.push(search()) }
+ return result
+ },
+ [plugins, enableEditing, searchable],
+ )
+
+ const gridStyle = React.useMemo(
+ () => {
+ const hasCustomWidth = columns.some(c => c.width)
+ if (hasCustomWidth) {
+ return { '--grid-columns': columns.map(c => c.width ?? '1fr').join(' ') } as React.CSSProperties
+ }
+ return { '--grid-col-count': columns.length } as React.CSSProperties
+ },
+ [columns],
+ )
+
+ const renderRow = (props: React.HTMLAttributes, node: Record, state: NodeState): React.ReactElement => {
+ const nodeData = node.data as Record | undefined
+ const cells = nodeData?.cells as unknown[] | undefined
+ const hasChildren = state.expanded !== undefined
+ const depth = (state.level ?? 1) - 1
+
+ return (
+
+ {columns.map((col, i) => (
+
+ ))}
+
+ )
+ }
+
+ return (
+
+ {header && (
+
+ {columns.map((col) => (
+
+
+
+ ))}
+
+ )}
+
+ {searchable && }
+
+
+
+ )
+}
diff --git a/src/interactive-os/ui/TreeGridRow.tsx b/src/interactive-os/ui/TreeGridRow.tsx
new file mode 100644
index 000000000..fc3e28ffc
--- /dev/null
+++ b/src/interactive-os/ui/TreeGridRow.tsx
@@ -0,0 +1,30 @@
+/** @catalog TreeGrid 행 래퍼 — colIndex === -1일 때 data-row-mode 노출 (CSS가 행 전체를 selection target으로 칠함) */
+import React from 'react'
+import { AriaInternalContext } from '../primitives/AriaInternalContext'
+import { GRID_COL_ID } from '../axis/navigate'
+import { ax } from '@styles/ax'
+
+interface TreeGridRowProps {
+ ariaProps: React.HTMLAttributes
+ focused: boolean
+ selected: boolean
+ children: React.ReactNode
+}
+
+export function TreeGridRow({ ariaProps, focused, selected, children }: TreeGridRowProps): React.ReactElement {
+ const aria = React.useContext(AriaInternalContext)
+ const store = aria?.getStore()
+ const colIndex = (store?.entities[GRID_COL_ID]?.colIndex as number | undefined) ?? -1
+ const rowMode = colIndex < 0
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/interactive-os/ui/TreeGridSimple.tsx b/src/interactive-os/ui/TreeGridSimple.tsx
new file mode 100644
index 000000000..b2bde91d7
--- /dev/null
+++ b/src/interactive-os/ui/TreeGridSimple.tsx
@@ -0,0 +1,62 @@
+/** @catalog TreeGrid simple 모드 — TreeItem 기반, column 없음 */
+import React from 'react'
+import type { NodeState } from '../pattern/types'
+import type { ItemSlots } from './types'
+import { Aria } from '../primitives/aria'
+import { treegrid } from '../pattern/roles/treegrid'
+import { history } from '../plugins/history'
+import { edit, replaceEditPlugin } from '../plugins/edit'
+import { TreeItem, EditableTreeItem } from './items'
+import type { TreeGridSimpleProps } from './TreeGrid'
+
+function makeRenderItem(editable: boolean, slots?: ItemSlots) {
+ const Item = editable ? EditableTreeItem : TreeItem
+ if (!slots) return (props: React.HTMLAttributes, node: Record, state: NodeState): React.ReactElement =>
+ Item(props, node, state)
+ return (props: React.HTMLAttributes, node: Record, state: NodeState): React.ReactElement =>
+ Item(props, node, state, {
+ icon: slots.icon?.(node, state),
+ rightContent: slots.rightContent?.(node, state),
+ })
+}
+
+export function TreeGridSimple({
+ id,
+ data,
+ plugins = [history()],
+ onChange,
+ renderItem,
+ itemSlots,
+ enableEditing = false,
+ columns,
+ onActivate,
+ onFocusChange,
+ 'aria-label': ariaLabel,
+}: TreeGridSimpleProps): React.ReactElement {
+ const defaultRenderer = React.useMemo(() => makeRenderItem(enableEditing, itemSlots), [enableEditing, itemSlots])
+ const resolvedRenderItem = renderItem ?? defaultRenderer
+ const pattern = React.useMemo(
+ () => columns ? treegrid(columns) : treegrid(1),
+ [columns],
+ )
+
+ const mergedPlugins = React.useMemo(
+ () => enableEditing ? [...plugins, edit({ tree: true }), replaceEditPlugin()] : plugins,
+ [plugins, enableEditing],
+ )
+
+ return (
+
+
+
+ )
+}