From a57dd01a96458f395b84f6bcdbf8170c0dedaded Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Tue, 21 Apr 2026 14:06:22 +0900 Subject: [PATCH 1/4] =?UTF-8?q?refactor(ui):=20TreeGrid=20Simple/Row/Cell/?= =?UTF-8?q?Columns=20=EB=B6=84=ED=95=A0=20+=20KeyHintBar/MockupBar=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TreeGrid를 역할별로 분할 (Simple/Row/Cell/Columns) - KeyHintBar: 하단 키보드 단축키 힌트 바 - MockupBar: fidelity ladder 전환 네비게이션 - tone 'neutral-muted' → 'neutral-dim' (AxTone 값 보정) --- .claude/hooks/{ => backup}/guardBash.mjs | 0 src/interactive-os/ui/KeyHintBar.tsx | 31 +++ src/interactive-os/ui/MockupBar.tsx | 31 +++ src/interactive-os/ui/TreeGrid.tsx | 235 +--------------------- src/interactive-os/ui/TreeGridCell.tsx | 44 ++++ src/interactive-os/ui/TreeGridColumns.tsx | 127 ++++++++++++ src/interactive-os/ui/TreeGridRow.tsx | 30 +++ src/interactive-os/ui/TreeGridSimple.tsx | 62 ++++++ 8 files changed, 336 insertions(+), 224 deletions(-) rename .claude/hooks/{ => backup}/guardBash.mjs (100%) create mode 100644 src/interactive-os/ui/KeyHintBar.tsx create mode 100644 src/interactive-os/ui/MockupBar.tsx create mode 100644 src/interactive-os/ui/TreeGridCell.tsx create mode 100644 src/interactive-os/ui/TreeGridColumns.tsx create mode 100644 src/interactive-os/ui/TreeGridRow.tsx create mode 100644 src/interactive-os/ui/TreeGridSimple.tsx diff --git a/.claude/hooks/guardBash.mjs b/.claude/hooks/backup/guardBash.mjs similarity index 100% rename from .claude/hooks/guardBash.mjs rename to .claude/hooks/backup/guardBash.mjs 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 ( + + + + ) +} From 4ac15a24684348ac6cba1ea05342a8d759f57576 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Tue, 21 Apr 2026 14:06:44 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix(hooks):=20guardBash.mjs=EB=A5=BC=20hook?= =?UTF-8?q?s/=20=EB=A3=A8=ED=8A=B8=EB=A1=9C=20=EB=B3=B5=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이전 커밋에서 실수로 backup/ 하위로 이동되어 훅이 비활성화됐던 문제 수정 --- .claude/hooks/{backup => }/guardBash.mjs | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .claude/hooks/{backup => }/guardBash.mjs (100%) diff --git a/.claude/hooks/backup/guardBash.mjs b/.claude/hooks/guardBash.mjs similarity index 100% rename from .claude/hooks/backup/guardBash.mjs rename to .claude/hooks/guardBash.mjs From 1fe719169ac5a7916740f69e063069788457cc7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Tue, 21 Apr 2026 14:09:29 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix(deps):=20use-sync-external-store=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80=20(CI=20build)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + pnpm-lock.yaml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/package.json b/package.json index 161c25faa..db0a8aa77 100644 --- a/package.json +++ b/package.json @@ -154,6 +154,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..db36784be 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 From 52b3b8b596e63bc6862a98d555546c8143555afc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Tue, 21 Apr 2026 14:11:32 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix(deps):=20@types/use-sync-external-store?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + pnpm-lock.yaml | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/package.json b/package.json index db0a8aa77..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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db36784be..dd30ac8bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,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)) @@ -2218,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==} @@ -7282,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':