From 369a37aaf316be3c9199cd2d77d1fa4440d4fd18 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: Mon, 20 Apr 2026 07:34:52 +0900 Subject: [PATCH 01/39] =?UTF-8?q?feat(engine):=20=EA=B3=B5=EC=8B=9D=20?= =?UTF-8?q?=EA=B5=AC=EB=8F=85=20=ED=9B=85=20useEngineStore=20/=20useEngine?= =?UTF-8?q?Selector?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit widget이 engine의 store를 parent rerender 없이 구독할 표준 훅 신설. useSyncExternalStoreWithSelector 기반, equalityFn 기본 Object.is + shallow. 기존 useAria의 data prop 경로가 parent → child cascading rerender를 강제하던 구조적 결함 해소용 하부 API. (useAria 시그니처 재편은 후속 PR) - engine.subscribeStore(cb) 추가 — USES가 요구하는 안정적 subscribe 시그니처 - virtual engine (useAriaZone/useControlledAria) 어댑터 대응 - aria-os/advanced entry에서 공개 - subscribeStore.test.ts 6 케이스 Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 2 + pnpm-lock.yaml | 11 +++ src/interactive-os/advanced/index.ts | 2 + .../engine/createCommandEngine.ts | 15 ++++ src/interactive-os/engine/shallow.ts | 34 +++++++ .../engine/subscribeStore.test.ts | 90 +++++++++++++++++++ src/interactive-os/engine/types.ts | 5 ++ src/interactive-os/engine/useEngineStore.ts | 36 ++++++++ src/interactive-os/primitives/useAriaZone.ts | 1 + .../primitives/useControlledAria.ts | 1 + 10 files changed, 197 insertions(+) create mode 100644 src/interactive-os/engine/shallow.ts create mode 100644 src/interactive-os/engine/subscribeStore.test.ts create mode 100644 src/interactive-os/engine/useEngineStore.ts diff --git a/package.json b/package.json index 48414414f..10f239a5d 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,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", @@ -146,6 +147,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/advanced/index.ts b/src/interactive-os/advanced/index.ts index aca5a85f9..2d8481fa4 100644 --- a/src/interactive-os/advanced/index.ts +++ b/src/interactive-os/advanced/index.ts @@ -10,6 +10,8 @@ export { composePattern } from '../pattern/composePattern'; export { createCommandEngine } from '../engine/createCommandEngine'; export { useEngine } from '../engine/useEngine'; +export { useEngineStore, useEngineSelector } from '../engine/useEngineStore'; +export { shallow } from '../engine/shallow'; export { definePlugin } from '../plugins/definePlugin'; export type { Plugin, Command, Middleware, VisibilityFilter } from '../engine/types'; diff --git a/src/interactive-os/engine/createCommandEngine.ts b/src/interactive-os/engine/createCommandEngine.ts index 402921457..6aa0e429d 100644 --- a/src/interactive-os/engine/createCommandEngine.ts +++ b/src/interactive-os/engine/createCommandEngine.ts @@ -21,8 +21,16 @@ export function createCommandEngine( // --- subscribers --- const subscribers = new Set<(event: EngineEvent) => void>() + const storeSubscribers = new Set<() => void>() let seq = 0 + const notifyStoreSubscribers = () => { + const snapshot = Array.from(storeSubscribers) + for (const listener of snapshot) { + try { listener() } catch (e) { console.error('[engine] store subscriber threw:', e) } + } + } + /** Tracks the original command dispatched before middleware chain (stack for reentrant dispatch) */ const _pendingOriginalStack: Command[] = [] @@ -204,6 +212,7 @@ export function createCommandEngine( } if (store !== prev) { onStoreChange(store) + notifyStoreSubscribers() } _lastResult = { ok: true, store } } @@ -257,7 +266,9 @@ export function createCommandEngine( }, getStore, syncStore: (newStore: NormalizedData) => { + if (newStore === store) return store = newStore + notifyStoreSubscribers() }, inspect, setInspectKeyMap: (desc: Record) => { inspectKeyMap = desc }, @@ -267,6 +278,10 @@ export function createCommandEngine( subscribers.add(listener) return () => { subscribers.delete(listener) } }, + subscribeStore: (listener: () => void) => { + storeSubscribers.add(listener) + return () => { storeSubscribers.delete(listener) } + }, emitUnhandledKey: (event: KeyboardEvent) => { if (subscribers.size === 0) return seq++ diff --git a/src/interactive-os/engine/shallow.ts b/src/interactive-os/engine/shallow.ts new file mode 100644 index 000000000..208eaacaf --- /dev/null +++ b/src/interactive-os/engine/shallow.ts @@ -0,0 +1,34 @@ +/** + * Shallow equality for selector results. Use as the `equalityFn` arg of + * `useEngineSelector` when the selector composes a new object/array each call. + * + * Supports plain objects, arrays, `Map`, and `Set`. Matches Zustand semantics. + */ +export function shallow(a: T, b: T): boolean { + if (Object.is(a, b)) return true + if (typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) return false + + if (a instanceof Map && b instanceof Map) { + if (a.size !== b.size) return false + for (const [k, v] of a) { + if (!b.has(k) || !Object.is(v, b.get(k))) return false + } + return true + } + if (a instanceof Set && b instanceof Set) { + if (a.size !== b.size) return false + for (const v of a) { + if (!b.has(v)) return false + } + return true + } + + const keysA = Object.keys(a as object) + const keysB = Object.keys(b as object) + if (keysA.length !== keysB.length) return false + for (const key of keysA) { + if (!Object.prototype.hasOwnProperty.call(b, key)) return false + if (!Object.is((a as Record)[key], (b as Record)[key])) return false + } + return true +} diff --git a/src/interactive-os/engine/subscribeStore.test.ts b/src/interactive-os/engine/subscribeStore.test.ts new file mode 100644 index 000000000..30775a444 --- /dev/null +++ b/src/interactive-os/engine/subscribeStore.test.ts @@ -0,0 +1,90 @@ +// @vitest-environment node +import { describe, it, expect } from 'vitest' +import { createCommandEngine } from './createCommandEngine' +import { buildRegistry } from './types' +import type { NormalizedData } from '../store/types' + +const makeStore = (): NormalizedData => ({ + entities: { + __focus__: { id: '__focus__', focusedId: 'a' }, + a: { id: 'a', data: { label: 'A' } }, + }, + relationships: { __root__: ['a'] }, +}) + +const setFocus = { + type: 'test:setFocus', + handler: (store: NormalizedData, payload: unknown) => { + const { id } = payload as { id: string } + return { + ...store, + entities: { + ...store.entities, + __focus__: { ...store.entities.__focus__, focusedId: id }, + }, + } + }, +} + +const noop = { + type: 'test:noop', + handler: (store: NormalizedData) => store, +} + +const makeEngine = () => { + const registry = buildRegistry({ setFocus, noop }) + return createCommandEngine(makeStore(), [], registry, () => {}, { logger: false }) +} + +describe('engine.subscribeStore', () => { + it('fires on store-changing dispatch', () => { + const engine = makeEngine() + let count = 0 + engine.subscribeStore(() => { count++ }) + engine.dispatch({ type: 'test:setFocus', payload: { id: 'b' } }) + expect(count).toBe(1) + }) + + it('does not fire when dispatch returns identical store', () => { + const engine = makeEngine() + let count = 0 + engine.subscribeStore(() => { count++ }) + engine.dispatch({ type: 'test:noop', payload: {} }) + expect(count).toBe(0) + }) + + it('fires on syncStore with a new reference', () => { + const engine = makeEngine() + let count = 0 + engine.subscribeStore(() => { count++ }) + engine.syncStore(makeStore()) + expect(count).toBe(1) + }) + + it('does not fire on syncStore with the same reference', () => { + const engine = makeEngine() + let count = 0 + engine.subscribeStore(() => { count++ }) + engine.syncStore(engine.getStore()) + expect(count).toBe(0) + }) + + it('unsubscribe stops notifications', () => { + const engine = makeEngine() + let count = 0 + const unsub = engine.subscribeStore(() => { count++ }) + unsub() + engine.dispatch({ type: 'test:setFocus', payload: { id: 'b' } }) + expect(count).toBe(0) + }) + + it('snapshot returned by getStore reflects latest after notify', () => { + const engine = makeEngine() + let observedFocus = '' + engine.subscribeStore(() => { + observedFocus = (engine.getStore().entities.__focus__!.focusedId as string) + }) + engine.dispatch({ type: 'test:setFocus', payload: { id: 'b' } }) + expect(observedFocus).toBe('b') + }) +}) diff --git a/src/interactive-os/engine/types.ts b/src/interactive-os/engine/types.ts index c2519e589..b03f58c73 100644 --- a/src/interactive-os/engine/types.ts +++ b/src/interactive-os/engine/types.ts @@ -81,6 +81,11 @@ export interface CommandEngine { setInspectPattern(info: InspectPatternInfo): void /** Subscribe to engine events (dispatch, error, etc.) */ subscribe(listener: (event: EngineEvent) => void): Unsubscribe + /** + * Subscribe to store-reference changes only (stable-ref signature for useSyncExternalStore). + * Fires when store is replaced via dispatch or syncStore. Does not fire on rejections. + */ + subscribeStore(listener: () => void): Unsubscribe /** Emit an unhandled key event — called by view layer when keyMap has no match */ emitUnhandledKey(event: KeyboardEvent): void } diff --git a/src/interactive-os/engine/useEngineStore.ts b/src/interactive-os/engine/useEngineStore.ts new file mode 100644 index 000000000..650f09af1 --- /dev/null +++ b/src/interactive-os/engine/useEngineStore.ts @@ -0,0 +1,36 @@ +import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/with-selector' +import type { NormalizedData } from '../store/types' +import type { CommandEngine } from './types' + +const identity = (x: T): T => x + +/** + * Subscribe a component to an engine's store. Re-renders only when the selected + * slice changes under `equalityFn` (default `Object.is`). + * + * This is the canonical way for widgets to read engine state without forcing a + * parent rerender. Pair with `shallow` when the selector returns a composed + * object/array. + */ +export function useEngineSelector( + engine: CommandEngine, + selector: (store: NormalizedData) => T, + equalityFn: (a: T, b: T) => boolean = Object.is, +): T { + return useSyncExternalStoreWithSelector( + engine.subscribeStore, + engine.getStore, + engine.getStore, + selector, + equalityFn, + ) +} + +/** + * Subscribe a component to the engine's full store. Equivalent to + * `useEngineSelector(engine, s => s)`. Use this for internal OS hooks that + * genuinely need the whole snapshot; prefer `useEngineSelector` in widgets. + */ +export function useEngineStore(engine: CommandEngine): NormalizedData { + return useEngineSelector(engine, identity) +} diff --git a/src/interactive-os/primitives/useAriaZone.ts b/src/interactive-os/primitives/useAriaZone.ts index 2ce258e82..05433f502 100644 --- a/src/interactive-os/primitives/useAriaZone.ts +++ b/src/interactive-os/primitives/useAriaZone.ts @@ -221,6 +221,7 @@ export function useAriaZone(options: UseAriaZoneOptions): UseAriaReturn { setInspectRole: (role, childRole) => { engine.setInspectRole(role, childRole) }, setInspectPattern: (info) => { engine.setInspectPattern(info) }, subscribe: (listener) => engine.subscribe(listener), + subscribeStore: (listener) => engine.subscribeStore(listener), emitUnhandledKey: (event) => { engine.emitUnhandledKey(event) }, } diff --git a/src/interactive-os/primitives/useControlledAria.ts b/src/interactive-os/primitives/useControlledAria.ts index a9dcea59d..f73237685 100644 --- a/src/interactive-os/primitives/useControlledAria.ts +++ b/src/interactive-os/primitives/useControlledAria.ts @@ -32,6 +32,7 @@ export function useControlledAria(options: UseControlledAriaOptions): UseAriaRet setInspectRole: () => { /* no-op for controlled mode */ }, setInspectPattern: () => { /* no-op for controlled mode */ }, subscribe: () => () => { /* no-op for controlled mode */ }, + subscribeStore: () => () => { /* no-op for controlled mode */ }, emitUnhandledKey: () => { /* no-op for controlled mode */ }, }), // Re-create whenever store or onDispatch changes so the engine always From eda557c586d3dc3664c5ba54b8f2c0a1ea88dd8f 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: Mon, 20 Apr 2026 07:44:19 +0900 Subject: [PATCH 02/39] =?UTF-8?q?refactor(useAria):=20=EB=A0=8C=EB=8D=94?= =?UTF-8?q?=20=ED=8A=B8=EB=A6=AC=EA=B1=B0=EB=A5=BC=20useSyncExternalStore?= =?UTF-8?q?=20=EA=B2=BD=EB=A1=9C=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit forceRender useState + onStoreChange 콜백 조합 대신 useEngineStore(engine)으로 직접 구독. data prop push 채널과 useMemo sync 로직은 유지 — 외부에서 filtered/layout data를 주입하는 Combobox·FlatLayout 등이 여전히 의존. - forceRender 제거 → React가 subscribeStore listener로 자동 rerender - 디버그 __syncCount/__prevData 계측 제거 (USES 전환으로 불필요) - 기존 테스트 1576 pass 유지 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/interactive-os/primitives/useAria.ts | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/src/interactive-os/primitives/useAria.ts b/src/interactive-os/primitives/useAria.ts index caf60dd90..4a8539e18 100644 --- a/src/interactive-os/primitives/useAria.ts +++ b/src/interactive-os/primitives/useAria.ts @@ -1,4 +1,5 @@ import { useState, useCallback, useEffect, useMemo, useRef } from 'react' +import { useEngineStore } from '../engine/useEngineStore' import type { Command, EffectContext, EngineOptions, InspectResult } from '../engine/types' import { buildRegistry } from '../engine/types' import type { NormalizedData } from '../store/types' @@ -77,7 +78,6 @@ export interface UseAriaReturn { export function useAria(options: UseAriaOptions): UseAriaReturn { const { pattern = EMPTY_BEHAVIOR, data, plugins = [], keyMap: keyMapOverrides, onChange, onActivate, onFocusChange, initialFocus, logger, autoFocus = true, disabled = false, 'aria-label': ariaLabel, id: ariaId, getNodeElement } = options - const [, forceRender] = useState(0) const pointerDownCtxRef = useRef | null>(null) const suppressFocusDispatchRef = useRef(false) @@ -122,7 +122,7 @@ export function useAria(options: UseAriaOptions): UseAriaReturn { } cb.prevFocus = newFocusedId onChange?.(newStore) - forceRender((n) => n + 1) + // Rerender is driven by useEngineStore (useSyncExternalStore) below — no forceRender needed. } finally { _reentrantDepth-- } }, logger != null ? { logger } : undefined) @@ -167,19 +167,12 @@ export function useAria(options: UseAriaOptions): UseAriaReturn { }) // ── ② External data sync (useAria-only) ── + // `data` prop is a push channel: parent may replace the store reference (e.g. + // filtered options in Combobox, layout tree in FlatLayout). When the reference + // differs we reconcile into the engine, preserving internal meta-entities + // (focus/selection/expanded/…). useMemo(() => { - // @ts-expect-error debug counter - engine.__syncCount = (engine.__syncCount ?? 0) + 1 - // @ts-expect-error debug counter - const _tag = `[useAria:${pattern.role || 'none'}] #${engine.__syncCount}` - const _entityCount = Object.keys(data.entities).filter(k => !META_ENTITY_IDS.has(k)).length - // @ts-expect-error debug counter - if (engine.__syncCount > 500) { console.error(_tag, 'LOOP — entities:', _entityCount, 'data ref changed:', data !== engine.__prevData); return } - // @ts-expect-error debug counter - if (engine.__syncCount > 1) { console.warn(_tag, 'entities:', _entityCount, 'data ref changed:', data !== engine.__prevData) } - // @ts-expect-error debug counter - engine.__prevData = data const currentStore = engine.getStore() const externalFocusChanged = FOCUS_ID in data.entities && (data.entities[FOCUS_ID]?.focusedId as string) !== (currentStore.entities[FOCUS_ID]?.focusedId as string) @@ -226,8 +219,10 @@ export function useAria(options: UseAriaOptions): UseAriaReturn { }, [data, engine]) // ── State derivation ── + // useEngineStore subscribes the component to engine.subscribeStore via + // useSyncExternalStore — replaces the legacy forceRender+onStoreChange path. - const store = engine.getStore() + const store = useEngineStore(engine) const focusedId = (store.entities['__focus__']?.focusedId as string) ?? '' const selectedIdSet = useMemo(() => { const ids = (store.entities['__selection__']?.selectedIds as string[]) ?? [] From 2b180ceda39dd07b3c95030d09a14ad9edd9deea 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: Mon, 20 Apr 2026 08:07:14 +0900 Subject: [PATCH 03/39] =?UTF-8?q?refactor(useAriaZone):=20store=20prop=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=EC=A0=81=20=E2=80=94=20engine=20=EA=B5=AC?= =?UTF-8?q?=EB=8F=85=20=EA=B8=B0=EB=B3=B8=20=EA=B2=BD=EB=A1=9C=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit store prop 없이 호출하면 useEngineStore(engine)이 subscribeStore로 구독하여 부모 useState 없이 자동 rerender. prop 제공 시 기존 동작 유지(filtered/derived view 경로 보존). AriaZone/Form/CalendarGrid 등 기존 3개 호출처는 그대로 동작. 신규 사용처는 store prop 생략으로 parent store-holding 패턴 탈출 가능. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/interactive-os/primitives/useAriaZone.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/interactive-os/primitives/useAriaZone.ts b/src/interactive-os/primitives/useAriaZone.ts index 05433f502..6c6f43252 100644 --- a/src/interactive-os/primitives/useAriaZone.ts +++ b/src/interactive-os/primitives/useAriaZone.ts @@ -1,4 +1,5 @@ import { useState, useCallback, useEffect, useMemo, useRef } from 'react' +import { useEngineStore } from '../engine/useEngineStore' import type { Command, CommandResult } from '../engine/types' import { createBatchCommand } from '../engine/types' import type { NormalizedData } from '../store/types' @@ -17,7 +18,13 @@ import { useAriaView } from './useAriaView' export interface UseAriaZoneOptions { engine: CommandEngine - store: NormalizedData + /** + * Optional. When omitted, the zone subscribes to `engine` directly via + * useEngineStore and rerenders on store change — parent no longer needs to + * hold store in useState. When provided, the prop is used verbatim (legacy + * path for callers that derive a transformed/filtered store). + */ + store?: NormalizedData pattern: AriaPattern scope: string plugins?: Plugin[] @@ -105,12 +112,14 @@ function applyMetaCommand(state: ZoneViewState, command: Command): ZoneViewState export function useAriaZone(options: UseAriaZoneOptions): UseAriaReturn { const { - engine, store, pattern, scope, + engine, store: storeProp, pattern, scope, plugins: zonePlugins, keyMap: keyMapOverrides, onActivate, initialFocus, isReachable, disabled = false, } = options + const subscribedStore = useEngineStore(engine) + const store = storeProp ?? subscribedStore const [viewState, setViewState] = useState(() => { const focusTarget = (initialFocus && store.entities[initialFocus]) From f48c9a556d8953c6f5c01abdbe35323a58f6b184 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: Mon, 20 Apr 2026 13:51:14 +0900 Subject: [PATCH 04/39] =?UTF-8?q?chore(routes):=20=EC=8B=A4=ED=97=98=20?= =?UTF-8?q?=ED=99=80=EB=94=A9=20=EB=9D=BC=EC=9A=B0=ED=8A=B8=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20=E2=80=94=20/incident-legacy=C2=B7/kanban=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0,=20orphan=20=ED=8F=B4=EB=8D=94=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /incident-legacy: /incident(Flat)로 대체된 레거시 - /kanban: replay/SkillKanban 미완성 고아 - pages/docs: 라우트 없는 orphan - ActivityBar: finder→book→project 인접 배치 (treegrid 변주 준비) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/ActivityBar.tsx | 7 +- src/pages/docs/PageDocs.tsx | 53 ----- src/pages/incident/PageIncidentInterface.tsx | 193 ------------------- src/pages/replay/SkillKanban.css | 33 ---- src/pages/replay/SkillKanban.demo.tsx | 106 ---------- src/pages/replay/SkillKanban.tsx | 177 ----------------- src/router.tsx | 2 - 7 files changed, 3 insertions(+), 568 deletions(-) delete mode 100644 src/pages/docs/PageDocs.tsx delete mode 100644 src/pages/incident/PageIncidentInterface.tsx delete mode 100644 src/pages/replay/SkillKanban.css delete mode 100644 src/pages/replay/SkillKanban.demo.tsx delete mode 100644 src/pages/replay/SkillKanban.tsx diff --git a/src/ActivityBar.tsx b/src/ActivityBar.tsx index 93bcb81c1..6283c97b6 100644 --- a/src/ActivityBar.tsx +++ b/src/ActivityBar.tsx @@ -2,7 +2,7 @@ import { useCallback, useMemo, type HTMLAttributes } from 'react' import { useLocation, useNavigate } from 'react-router-dom' import { Sun, Moon, Layers, Presentation, Component, FolderCode, Palette, ShieldAlert, Languages, - MessageSquare, BookText, Play, Cable, PenLine, Kanban, SquareKanban, GitBranch, + MessageSquare, BookText, Play, Cable, PenLine, Kanban, GitBranch, Mail, ListTree, Boxes, Braces, FileStack, TerminalSquare, BookMarked, Ruler, Compass, ListChecks, FlaskConical, } from 'lucide-react' @@ -66,9 +66,10 @@ const appNavItems: NavItem[] = [ { id: 'cms', label: 'CMS', icon: Presentation, path: '/' }, { id: 'slides', label: 'Slides', icon: FileStack, path: '/slides' }, { id: 'finder', label: 'Finder', icon: FolderCode, path: '/finder' }, + { id: 'book', label: 'Book', icon: BookText, path: '/book' }, + { id: 'project', label: 'Project', icon: Kanban, path: '/project' }, { id: 'catalog', label: 'Catalog', icon: Boxes, path: '/catalog' }, { id: 'chat', label: 'Chat', icon: MessageSquare, path: '/chat' }, - { id: 'book', label: 'Book', icon: BookText, path: '/book' }, { id: 'pipeline', label: 'Pipeline', icon: GitBranch, path: '/pipeline' }, { id: 'features', label: 'Features', icon: ListTree, path: '/features' }, { id: 'ax-principles', label: 'AX Principles', icon: Compass, path: '/ax-principles' }, @@ -85,9 +86,7 @@ const appNavItems: NavItem[] = [ { id: 'keyline-test', label: 'Keyline Test', icon: Ruler, path: '/test/keyline' }, // --- 미완성 / 데모 미생성 --- { id: 'replay', label: 'Replay', icon: Play, path: '/replay' }, - { id: 'kanban', label: 'Kanban', icon: SquareKanban, path: '/kanban' }, { id: 'a2ui', label: 'A2UI', icon: Cable, path: '/a2ui' }, - { id: 'project', label: 'Project', icon: Kanban, path: '/project' }, { id: 'writer', label: 'Writer', icon: PenLine, path: '/writer' }, ] diff --git a/src/pages/docs/PageDocs.tsx b/src/pages/docs/PageDocs.tsx deleted file mode 100644 index 423410815..000000000 --- a/src/pages/docs/PageDocs.tsx +++ /dev/null @@ -1,53 +0,0 @@ -// @useState-hatch — store/loading: async docs tree fetch -import { useState, useEffect, useCallback } from 'react' -import { MillerColumns } from '@os/ui/MillerColumns' -import { SpinnerIndicator } from '@os/ui/indicators' -import { EmptyState } from '@os/ui/EmptyState' -import { ax } from '@styles/ax' -import { scroll } from '@os/plugins/scroll' -import { fetchTree } from '../finder/fsClient' -import { treeToStore } from '../finder/treeTransform' -import { DocsPreview } from '../finder/widgets/DocsPreview' - -const DOCS_ROOT = '/Users/user/Desktop/aria/docs' - -export default function PageDocs() { - const [store, setStore] = useState | null>(null) - const [loading, setLoading] = useState(true) - - useEffect(() => { - fetchTree(DOCS_ROOT).then((tree) => { - setStore(treeToStore(tree)) - setLoading(false) - }) - }, []) - - const renderPreview = useCallback((nodeId: string) => { - return - }, []) - - if (loading || !store) { - return ( -
- - Loading docs... -
- ) - } - - if (Object.keys(store.entities).length === 0) { - return - } - - return ( -
- -
- ) -} diff --git a/src/pages/incident/PageIncidentInterface.tsx b/src/pages/incident/PageIncidentInterface.tsx deleted file mode 100644 index 5444a8da3..000000000 --- a/src/pages/incident/PageIncidentInterface.tsx +++ /dev/null @@ -1,193 +0,0 @@ -// @useState-hatch -import { useState, useEffect, useCallback, useRef, useMemo } from 'react' -import { ax } from '@styles/ax' -import './PageIncidentInterface.css' -import { - Zap, CheckCircle, Loader, Bot, User, -} from 'lucide-react' -import { useStreamFeed } from '@os/ui/useStreamFeed' -import { useTypewriter } from '@os/ui/useTypewriter' -import { StreamFeed, StreamCursor } from '@os/ui/StreamFeed' -import { PanelHeader } from '@os/ui/PanelHeader' -import { Composer } from '@os/ui/Composer' -import { SERVICES, TIMELINE_EVENTS, MESSAGES } from './incidentMockData' -import type { Msg } from './incidentMockData' -import { MonitoringBar } from './MonitoringBar' -import { TimelinePanel } from './TimelinePanel' -import { CapturePanel } from './CapturePanel' - -// ═══════════════════════════════════════════ -// Small sub-components (< 50 lines each) -// ═══════════════════════════════════════════ - -function AgentMessage({ msg, active }: { msg: Msg; active: boolean }) { - const { displayed, done } = useTypewriter(msg.text, active) - return ( -
-
-
-
- {displayed} - {!done && } -
- {done && msg.block && } -
-
- ) -} - -function Elapsed({ startTime }: { startTime: number | null }) { - const [now, setNow] = useState(() => Date.now()) - useEffect(() => { - const id = setInterval(() => setNow(Date.now()), 100) - return () => clearInterval(id) - }, []) - if (!startTime) return null - const sec = ((now - startTime) / 1000).toFixed(1) - return {sec}s -} - -// ═══════════════════════════════════════════ -// Main -// ═══════════════════════════════════════════ - -export default function PageIncidentInterface() { - const [startTime, setStartTime] = useState(null) - const [endTime, setEndTime] = useState(null) - const [selectedEvent, setSelectedEvent] = useState(null) - - const { items, isStreaming, feedRef, addItems, clear } = useStreamFeed({ - getDelay: (msg) => msg.delay, - }) - - // Auto-play on mount - const didPlayRef = useRef(false) - useEffect(() => { - if (!didPlayRef.current) { - didPlayRef.current = true - addItems(MESSAGES) - } - }, [addItems]) - - const replay = useCallback(() => { - clear() - addItems(MESSAGES) - }, [clear, addItems]) - - // Track timing - const prevItemsLenRef = useRef(0) - const len = items.length - - useEffect(() => { - if (len >= 1 && prevItemsLenRef.current === 0) { - queueMicrotask(() => { setStartTime(Date.now()); setEndTime(null) }) - } - if (len === MESSAGES.length && prevItemsLenRef.current < MESSAGES.length) { - queueMicrotask(() => setEndTime(Date.now())) - } - prevItemsLenRef.current = len - }, [len]) - - const handleReplay = useCallback(() => { - prevItemsLenRef.current = 0 - setStartTime(null) - setEndTime(null) - setSelectedEvent(null) - replay() - }, [replay]) - - // Progressive timeline reveal synced to chat progress - const timelineVisible = Math.min( - TIMELINE_EVENTS.length, - len <= 2 ? 0 : len <= 4 ? 2 : len <= 6 ? 4 : len <= 8 ? 6 : TIMELINE_EVENTS.length, - ) - - // Auto-select latest event when timeline first appears - const initialEventId = useMemo( - () => timelineVisible > 0 ? TIMELINE_EVENTS[timelineVisible - 1]?.id ?? null : null, - [timelineVisible], - ) - - if (initialEventId && !selectedEvent) setSelectedEvent(initialEventId) - - const handleToolbarActivate = useCallback((_id: string) => { - // service selection — showcase only - }, []) - - return ( -
- {/* Zone 1: Monitoring Bar */} - - - {/* Zone 2: Workspace (Timeline + Capture + Chat) */} -
- - - -
- - AI Analysis - - {endTime - ? <>{((endTime - (startTime ?? 0)) / 1000).toFixed(1)}s - : startTime - ? <> - : null - } - - - { - if (msg.type === 'user') { - return ( -
-
-
{msg.text}
-
-
-
- ) - } - if (msg.type === 'system') { - return ( -
-
{msg.text}
-
- ) - } - if (msg.type === 'tool') { - return ( -
- - {msg.toolName} - {msg.text} -
- ) - } - return - }} - /> - -
-
-
- ) -} diff --git a/src/pages/replay/SkillKanban.css b/src/pages/replay/SkillKanban.css deleted file mode 100644 index 2fb631e4f..000000000 --- a/src/pages/replay/SkillKanban.css +++ /dev/null @@ -1,33 +0,0 @@ -/* ── Session detail dialog ── */ - -.kanban-detail-dialog { - width: min(480px, 90vw); - height: min(85vh, 900px); -} - -.kanban-detail-dialog::backdrop { - background: rgba(0, 0, 0, 0.5); -} - -/* ── Agent state visual cues ── */ - -/* Active card: left accent border */ -.kanban-card[data-agent-state="active"] { - border-inline-start: 2px solid var(--focus); -} - -/* Waiting card: accent outline */ -.kanban-card[data-agent-state="waiting"] { - outline: 1.5px solid var(--focus); - outline-offset: -1.5px; -} - -/* Done card: dimmed */ -.kanban-card[data-agent-state="done"] { - opacity: 0.6; -} - -/* Stale card: warning tone */ -.kanban-card[data-stale] { - border-inline-start: 2px solid var(--tone-warning-base); -} diff --git a/src/pages/replay/SkillKanban.demo.tsx b/src/pages/replay/SkillKanban.demo.tsx deleted file mode 100644 index 3ae0c6d10..000000000 --- a/src/pages/replay/SkillKanban.demo.tsx +++ /dev/null @@ -1,106 +0,0 @@ -/* eslint-disable react-refresh/only-export-components */ -// SkillKanban demo — mock SessionCards drive the kanban view without SSE. -import { useState } from 'react' -import { ax } from '@styles/ax' -import { Kanban } from '@os/ui/Kanban' -import { PanelHeader } from '@os/ui/PanelHeader' -import { - type SessionCard, - cardsToKanbanData, - COL_WAITING, - COL_ACTIVE, - COL_DONE, -} from './sessionCardExtractor' -import { SessionDetailModal } from './SessionDetailModal' - -export const meta = { - slug: 'skill-kanban', - category: 'page', - label: 'SkillKanban (Agent Dashboard)', -} - -function makeCard(overrides: Partial): SessionCard { - const now = Date.now() - return { - id: 'mock', - label: 'mock session', - agentState: 'waiting', - phase: 'planning', - currentActivity: '', - lastEventType: '', - hasOutput: false, - isStale: false, - stateChangedAt: now, - lastAssistantMsg: '', - lastSkill: '', - skills: [], - touchedFiles: [], - skillCount: 0, - toolCount: 0, - startTs: now - 60_000, - lastTs: now, - allMessages: [], - ...overrides, - } -} - -const MOCK_CARDS: SessionCard[] = [ - makeCard({ - id: 's1', label: 'catalog follow-focus', - agentState: 'waiting', phase: 'planning', - currentActivity: 'planning next step', lastSkill: 'discuss', - skills: ['discuss'], skillCount: 1, - }), - makeCard({ - id: 's2', label: 'replay demos', - agentState: 'active', phase: 'developing', - currentActivity: 'editing SkillKanban.tsx', - lastSkill: 'do', skills: ['discuss', 'do'], skillCount: 2, toolCount: 14, - touchedFiles: ['src/pages/replay/SkillKanban.tsx'], - lastAssistantMsg: 'Creating the demo file...', - hasOutput: true, - }), - makeCard({ - id: 's3', label: 'ax() 토큰 감사', - agentState: 'done', phase: 'reviewing', - currentActivity: 'idle', lastSkill: 'retrospect', - skills: ['discuss', 'do', 'retrospect'], skillCount: 3, toolCount: 42, - lastAssistantMsg: '감사 완료 — 위반 0건.', - hasOutput: true, - }), -] - -export function Demo() { - const [openId, setOpenId] = useState(null) - // eslint-disable-next-line react-hooks/purity - const data = cardsToKanbanData(MOCK_CARDS, Date.now()) - const openCard = openId ? MOCK_CARDS.find((c) => c.id === openId) ?? null : null - - const waiting = MOCK_CARDS.filter((c) => c.agentState === 'waiting').length - const active = MOCK_CARDS.filter((c) => c.agentState === 'active').length - const done = MOCK_CARDS.filter((c) => c.agentState === 'done').length - - return ( -
- -
- Agent Dashboard - - {waiting} waiting · {active} active · {done} done - -
-
-
- { - if (id === COL_WAITING || id === COL_ACTIVE || id === COL_DONE) return - setOpenId(id) - }} - aria-label="Agent Dashboard" - /> -
- setOpenId(null)} /> -
- ) -} diff --git a/src/pages/replay/SkillKanban.tsx b/src/pages/replay/SkillKanban.tsx deleted file mode 100644 index 8c204f75d..000000000 --- a/src/pages/replay/SkillKanban.tsx +++ /dev/null @@ -1,177 +0,0 @@ -// ② agent-dashboard-prd.md -// @useState-hatch — sessionCards: real-time SSE stream state, not OS axis/store material -// @useState-hatch — tick: timer-driven re-render for elapsed time display -// @useState-hatch — openCardId: overlay open state, useOverlay가 관리 -// @useState-hatch — below imports use useState for view+interaction state -import { useState, useEffect, useCallback } from 'react' -import { subscribeTimeline } from '../finder/timelineSSE' -import { useActiveSessions } from './useActiveSessions' -import type { TimelineEvent } from '../finder/groupEvents' -import { ax } from '@styles/ax' -import { PanelHeader } from '@os/ui/PanelHeader' -import { Kanban } from '@os/ui/Kanban' -import { - type SessionCard, - extractSessionCard, - derivePhase, - deriveAgentState, - deriveCurrentActivity, - pushMessage, - cardsToKanbanData, - STALE_THRESHOLD_MS, - STATE_DEBOUNCE_MS, - COL_WAITING, - COL_ACTIVE, - COL_DONE, -} from './sessionCardExtractor' -import { SessionDetailModal } from './SessionDetailModal' -import './SkillKanban.css' - -export default function SkillKanban() { - const sessions = useActiveSessions({ activeOnly: false }) - // @useState-hatch — sessionCards: real-time SSE stream state, not OS axis/store material - const [sessionCards, setSessionCards] = useState([]) - // @useState-hatch — tick: timer-driven re-render for elapsed time display - const [, setTick] = useState(0) - // @useState-hatch — openCardId: overlay open state, useOverlay가 관리 - const [openCardId, setOpenCardId] = useState(null) - - useEffect(() => { - if (sessions.length === 0) return - let cancelled = false - - async function loadInitial() { - const now = Date.now() - const results = await Promise.allSettled( - sessions.map(async session => { - const res = await fetch(`/api/agent-ops/timeline?session=${session.id}&tail=2000`) - if (!res.ok) return null - const { events } = await res.json() as { events: TimelineEvent[] } - return extractSessionCard(events, session, now) - }) - ) - if (cancelled) return - const cards = results - .filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled') - .map(r => r.value) - .filter((c): c is SessionCard => c !== null) - cards.sort((a, b) => b.lastTs - a.lastTs) - setSessionCards(cards) - } - - loadInitial() - return () => { cancelled = true } - }, [sessions]) - - useEffect(() => { - if (sessions.length === 0) return - const unsubs: (() => void)[] = [] - - for (const session of sessions) { - const unsub = subscribeTimeline(session.id, (evt) => { - const data = evt as unknown as { type: string; tool?: string; text?: string; ts: string; filePath?: string } - - setSessionCards(prev => { - const idx = prev.findIndex(c => c.id === session.id) - if (idx === -1) return prev - const now = Date.now() - const card = { ...prev[idx], lastTs: Date.parse(data.ts) } - - if (data.type === 'skill_start' && data.text) { - card.lastSkill = data.text - card.skills = [...card.skills, data.text] - card.phase = derivePhase(card.skills) - card.skillCount++ - } else if (data.type === 'tool_use' && data.tool !== 'Skill') { - card.toolCount++ - if (data.filePath && !card.touchedFiles.includes(data.filePath)) { - card.touchedFiles = [...card.touchedFiles, data.filePath] - card.hasOutput = true - } - } - if ((data.type === 'user' || data.type === 'assistant') && data.text) { - card.allMessages = pushMessage(card.allMessages, { role: data.type as 'user' | 'assistant', text: data.text }) - if (data.type === 'assistant') card.lastAssistantMsg = data.text.slice(0, 60) - } - - const prevState = card.agentState - const prevActivity = card.currentActivity - const prevToolCount = card.toolCount - if (data.type === 'tool_use' || data.type === 'user' || data.type === 'assistant') { - card.lastEventType = data.type - const newState = deriveAgentState(session.active, data.type) - if (newState !== card.agentState && now - card.stateChangedAt >= STATE_DEBOUNCE_MS) { - card.agentState = newState - card.stateChangedAt = now - } - card.currentActivity = deriveCurrentActivity( - data.type, - data.type === 'tool_use' ? (data.tool ?? '') : '', - data.type === 'tool_use' ? (data.filePath ?? '') : '', - data.type === 'tool_use' ? (data.text ?? '') : '', - card.lastAssistantMsg, - ) - } - card.isStale = session.active && card.lastTs > 0 && (now - card.lastTs > STALE_THRESHOLD_MS) - - if (card.agentState === prevState && card.currentActivity === prevActivity && card.toolCount === prevToolCount) { - return prev - } - - const updated = [...prev] - updated[idx] = card - return updated - }) - }) - unsubs.push(unsub) - } - - return () => unsubs.forEach(u => u()) - }, [sessions]) - - const hasActive = sessionCards.some(c => c.agentState !== 'done') - useEffect(() => { - if (!hasActive) return - const id = setInterval(() => setTick(t => t + 1), 1000) - return () => clearInterval(id) - }, [hasActive]) - - // eslint-disable-next-line react-hooks/purity - const kanbanData = cardsToKanbanData(sessionCards, Date.now()) - - const handleActivate = useCallback((nodeId: string) => { - // column 노드는 무시, card만 열기 - if (nodeId === COL_WAITING || nodeId === COL_ACTIVE || nodeId === COL_DONE) return - setOpenCardId(nodeId) - }, []) - - const openCard = openCardId ? sessionCards.find(c => c.id === openCardId) : null - - const waiting = sessionCards.filter(c => c.agentState === 'waiting').length - const active = sessionCards.filter(c => c.agentState === 'active').length - const doneCount = sessionCards.filter(c => c.agentState === 'done').length - - return ( -
- -
- Agent Dashboard - - {waiting} waiting · {active} active · {doneCount} done - -
-
- {sessionCards.length === 0 && ( -
- 세션이 없습니다 — 스킬을 실행하면 여기에 표시됩니다 -
- )} - {sessionCards.length > 0 && ( -
- -
- )} - setOpenCardId(null)} /> -
- ) -} diff --git a/src/router.tsx b/src/router.tsx index 61545fb0a..47c8d571c 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -34,7 +34,6 @@ export const router = createBrowserRouter([ { path: '/i18n', lazy: () => import('./pages/i18n/PageI18nEditor').then(m => ({ Component: m.default })) }, { path: '/incident', lazy: () => import('./pages/incident/PageIncidentFlat').then(m => ({ Component: m.default })) }, { path: '/slides', lazy: () => import('./pages/slides/PageSlides').then(m => ({ Component: m.default })) }, - { path: '/incident-legacy', lazy: () => import('./pages/incident/PageIncidentInterface').then(m => ({ Component: m.default })) }, { path: '/internals/theme', lazy: () => import('./pages/theme/PageThemeCreator').then(m => ({ Component: m.default })) }, { path: '/creator/*', lazy: () => import('./pages/creator/PageComponentCreator').then(m => ({ Component: m.default })) }, { path: '/json-editor', lazy: () => import('./pages/jsonEditor/PageJsonEditor').then(m => ({ Component: m.default })) }, @@ -51,7 +50,6 @@ export const router = createBrowserRouter([ { path: '/stories', lazy: () => import('./pages/stories/PageStories').then(m => ({ Component: m.default })) }, { path: '/catalog', lazy: () => import('./pages/catalog/PageCatalog').then(m => ({ Component: m.default })) }, { path: '/features', lazy: () => import('./pages/features/PageFeatures').then(m => ({ Component: m.default })) }, - { path: '/kanban', lazy: () => import('./pages/replay/SkillKanban').then(m => ({ Component: m.default })) }, { path: '/showcase/gmail', lazy: () => import('./pages/showcase/gmail/PageGmail').then(m => ({ Component: m.default })) }, { path: '/test/keyline', lazy: () => import('./pages/keyline/PageKeylineTest').then(m => ({ Component: m.default })) }, { path: '/ax-principles', lazy: () => import('./pages/ax-principles/PageAxPrinciples').then(m => ({ Component: m.default })) }, From 22589018f03e9ce8b3012f8e2a300e9ce7914372 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: Mon, 20 Apr 2026 13:52:34 +0900 Subject: [PATCH 05/39] =?UTF-8?q?refactor(ui):=20TreeGrid=EB=A5=BC=20Simpl?= =?UTF-8?q?e/Row/Cell/Columns=EB=A1=9C=20=EB=B6=84=ED=95=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 단일 파일 TreeGrid.tsx 235줄을 책임별로 쪼갬. Co-Authored-By: Claude Opus 4.7 (1M context) --- 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 ++++++ 5 files changed, 274 insertions(+), 224 deletions(-) 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/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 2c2ea6e7172820f3a93a3784af1593ca2e55652c 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: Mon, 20 Apr 2026 13:52:41 +0900 Subject: [PATCH 06/39] =?UTF-8?q?feat(json-editor):=20FlatLayout=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EC=9E=AC=EA=B5=AC=EC=84=B1=20+=20schema?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pages/jsonEditor: context/store/layout/widgets/schemaGen 분리 - ui/JsonEditor: command 확장 - cells(Editable/Enum/Searchable) + cellEdit plugin 개선 - screen test 추가 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../route-json-editor.screen.test.tsx | 401 ++++++++++++++++++ src/interactive-os/plugins/cellEdit.ts | 26 +- .../ui/JsonEditor/JsonEditor.tsx | 63 ++- .../ui/JsonEditor/jsonEditCommands.ts | 155 ++++++- src/interactive-os/ui/cells/EditableCell.tsx | 14 +- src/interactive-os/ui/cells/EnumCell.tsx | 5 +- .../ui/cells/SearchableCell.tsx | 20 +- src/pages/jsonEditor/PageJsonEditor.tsx | 47 +- .../__tests__/jsonSchemaGen.test.ts | 73 ++++ src/pages/jsonEditor/jsonEditorContext.ts | 11 + src/pages/jsonEditor/jsonEditorLayout.ts | 30 ++ src/pages/jsonEditor/jsonEditorStore.ts | 31 ++ src/pages/jsonEditor/jsonEditorWidgets.tsx | 57 +++ src/pages/jsonEditor/jsonSchemaGen.ts | 71 ++++ 14 files changed, 969 insertions(+), 35 deletions(-) create mode 100644 src/__tests__/route-json-editor.screen.test.tsx create mode 100644 src/pages/jsonEditor/__tests__/jsonSchemaGen.test.ts create mode 100644 src/pages/jsonEditor/jsonEditorContext.ts create mode 100644 src/pages/jsonEditor/jsonEditorLayout.ts create mode 100644 src/pages/jsonEditor/jsonEditorStore.ts create mode 100644 src/pages/jsonEditor/jsonEditorWidgets.tsx create mode 100644 src/pages/jsonEditor/jsonSchemaGen.ts diff --git a/src/__tests__/route-json-editor.screen.test.tsx b/src/__tests__/route-json-editor.screen.test.tsx new file mode 100644 index 000000000..76fc84b7d --- /dev/null +++ b/src/__tests__/route-json-editor.screen.test.tsx @@ -0,0 +1,401 @@ +import { describe, it, expect } from 'vitest' +import { render } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import PageJsonEditor from '../pages/jsonEditor/PageJsonEditor' + +// ── helpers ── + +function freeGrid(container: HTMLElement): HTMLElement { + return container.querySelector('[aria-label="JSON editor"]') as HTMLElement +} +const schemaGrid = freeGrid +function rawJson(_?: unknown): unknown { + // The JSON output panel lives outside the grid; always read from document. + const pres = Array.from(document.querySelectorAll('pre')) as HTMLElement[] + for (const p of pres) { + try { return JSON.parse(p.textContent ?? '') } catch {} + } + throw new Error('JSON output pre not found') +} +function rowByKey(grid: HTMLElement, keyText: string): HTMLElement { + const rows = Array.from(grid.querySelectorAll('[role="row"]')) as HTMLElement[] + const row = rows.find((r) => r.textContent?.includes(keyText)) + expect(row, `row with key "${keyText}" not found`).toBeTruthy() + return row! +} +function cellOf(row: HTMLElement, col: number): HTMLElement { + const cell = row.querySelectorAll('[role="gridcell"]')[col] as HTMLElement + expect(cell).toBeTruthy() + return cell +} +function focusedRowText(grid: HTMLElement): string { + const el = grid.querySelector('[role="row"][data-focused]') as HTMLElement | null + return el?.textContent ?? '' +} + +// ── tests ── + +describe('/json-editor — history plugin', () => { + it('Mod+Z undoes a delete, Mod+Shift+Z redoes', async () => { + const user = userEvent.setup() + const { container } = render() + const grid = freeGrid(container) + await user.click(rowByKey(grid, 'version')) + await user.keyboard('{Delete}') + expect('version' in (rawJson(grid) as object)).toBe(false) + await user.keyboard('{Control>}z{/Control}') + expect('version' in (rawJson(grid) as object)).toBe(true) + await user.keyboard('{Control>}{Shift>}z{/Shift}{/Control}') + expect('version' in (rawJson(grid) as object)).toBe(false) + }) + + it('multi-step undo restores prior states in reverse', async () => { + const user = userEvent.setup() + const { container } = render() + const grid = freeGrid(container) + await user.click(rowByKey(grid, 'app')) + await user.keyboard('{Delete}') + await user.click(rowByKey(grid, 'version')) + await user.keyboard('{Delete}') + const afterTwo = rawJson(grid) as Record + expect('app' in afterTwo).toBe(false) + expect('version' in afterTwo).toBe(false) + await user.keyboard('{Control>}z{/Control}') // restore version + expect('version' in (rawJson(grid) as object)).toBe(true) + await user.keyboard('{Control>}z{/Control}') // restore app + expect('app' in (rawJson(grid) as object)).toBe(true) + }) +}) + +describe('/json-editor — crud plugin', () => { + it('Delete removes the focused row', async () => { + const user = userEvent.setup() + const { container } = render() + const grid = freeGrid(container) + await user.click(rowByKey(grid, 'app')) + await user.keyboard('{Delete}') + expect('app' in (rawJson(grid) as object)).toBe(false) + }) + + it('Backspace also removes the focused row', async () => { + const user = userEvent.setup() + const { container } = render() + const grid = freeGrid(container) + await user.click(rowByKey(grid, 'enabled')) + await user.keyboard('{Backspace}') + expect('enabled' in (rawJson(grid) as object)).toBe(false) + }) +}) + +describe('/json-editor — clipboard plugin (row-mode)', () => { + it('Mod+C + Mod+V on array container inserts a new child', async () => { + const user = userEvent.setup() + const { container } = render() + const grid = freeGrid(container) + const before = (rawJson(grid) as { tags: unknown[] }).tags.length + await user.click(rowByKey(grid, 'tags')) + await user.keyboard('{Control>}c{/Control}') + await user.keyboard('{Control>}v{/Control}') + const after = (rawJson(grid) as { tags: unknown[] }).tags.length + expect(after).toBe(before + 1) + }) + + it('Mod+X cut then Mod+V paste moves a subtree (no data loss)', async () => { + const user = userEvent.setup() + const { container } = render() + const grid = freeGrid(container) + await user.click(rowByKey(grid, 'nested')) + await user.keyboard('{Control>}x{/Control}') + await user.click(rowByKey(grid, 'tags')) + await user.keyboard('{Control>}v{/Control}') + const parsed = rawJson(grid) as Record + // nested key is moved away from root + expect('nested' in parsed).toBe(false) + // deep value must still exist somewhere (no data loss) + const json = JSON.stringify(parsed) + expect(json).toContain('"deep"') + }) + + it('Mod+D on array child: new sibling gets correct sequential index label', async () => { + const user = userEvent.setup() + const { container } = render() + const grid = freeGrid(container) + await user.click(rowByKey(grid, 'tags')) + await user.keyboard('{ArrowRight}') + await user.click(rowByKey(grid, 'treegrid')) + await user.keyboard('{Control>}d{/Control}') + await user.keyboard('{Control>}d{/Control}') + // after two duplicates, array has 5 items with labels [0][1][2][3][4] — no [1][1] + const tagsRow = rowByKey(grid, 'tags') + const arrayChildren = Array.from(grid.querySelectorAll('[role="row"][aria-level="2"]')) as HTMLElement[] + // extract leading "[N]" from each child text + const labels = arrayChildren.map((r) => { + const m = r.textContent?.match(/^\[(\d+)\]/) + return m ? Number(m[1]) : -1 + }) + // labels should be unique strictly increasing 0..n-1 + const sorted = [...labels].sort((a, b) => a - b) + expect(sorted).toEqual(sorted.map((_, i) => i)) + expect(new Set(labels).size).toBe(labels.length) + expect(tagsRow.getAttribute('aria-expanded')).toBe('true') + }) + + it('Mod+D duplicates the focused node and focus lands on the new copy', async () => { + const user = userEvent.setup() + const { container } = render() + const grid = freeGrid(container) + await user.click(rowByKey(grid, 'tags')) + await user.keyboard('{ArrowRight}') + const tagsBefore = (rawJson(grid) as { tags: unknown[] }).tags.length + const target = rowByKey(grid, 'treegrid') + await user.click(target) + await user.keyboard('{Control>}d{/Control}') + const tagsAfter = (rawJson(grid) as { tags: unknown[] }).tags.length + expect(tagsAfter).toBe(tagsBefore + 1) + // focus must not jump to root-level $.app + expect(focusedRowText(grid)).not.toMatch(/^app/) + }) +}) + +describe('/json-editor — clipboard plugin (cell-mode)', () => { + it('cell Delete clears the value, keeps the row', async () => { + const user = userEvent.setup() + const { container } = render() + const grid = freeGrid(container) + await user.click(cellOf(rowByKey(grid, 'app'), 2)) + await user.keyboard('{Delete}') + const parsed = rawJson(grid) as Record + expect('app' in parsed).toBe(true) + expect(parsed.app).toBe('') + }) + + it('cell Mod+C + Mod+V copies a value to another row', async () => { + const user = userEvent.setup() + const { container } = render() + const grid = freeGrid(container) + await user.click(cellOf(rowByKey(grid, 'app'), 2)) + await user.keyboard('{Control>}c{/Control}') + await user.click(cellOf(rowByKey(grid, 'version'), 2)) + await user.keyboard('{Control>}v{/Control}') + expect((rawJson(grid) as { version: unknown }).version).not.toBe(3) + }) + + it('cell Mod+X clears source and pastes value to target', async () => { + const user = userEvent.setup() + const { container } = render() + const grid = freeGrid(container) + await user.click(cellOf(rowByKey(grid, 'app'), 2)) + await user.keyboard('{Control>}x{/Control}') + const afterCut = rawJson(grid) as Record + expect(afterCut.app).toBe('') // source cleared + await user.click(cellOf(rowByKey(grid, 'version'), 2)) + await user.keyboard('{Control>}v{/Control}') + expect((rawJson(grid) as { version: unknown }).version).not.toBe(3) + }) +}) + +describe('/json-editor — rename plugin', () => { + it('Enter on key cell starts rename, typing and Enter commits new key', async () => { + const user = userEvent.setup() + const { container } = render() + const grid = freeGrid(container) + await user.click(cellOf(rowByKey(grid, 'app'), 0)) + await user.keyboard('{Enter}') + const input = grid.querySelector('[contenteditable="true"]') as HTMLElement | null + expect(input).not.toBeNull() + await user.tripleClick(input!) + await user.keyboard('APP{Enter}') + const parsed = rawJson(grid) as Record + expect('APP' in parsed).toBe(true) + expect('app' in parsed).toBe(false) + }) + + it('Escape cancels rename without changes', async () => { + const user = userEvent.setup() + const { container } = render() + const grid = freeGrid(container) + await user.click(cellOf(rowByKey(grid, 'app'), 0)) + await user.keyboard('{Enter}') + const input = grid.querySelector('[contenteditable="true"]') as HTMLElement | null + expect(input).not.toBeNull() + await user.tripleClick(input!) + await user.keyboard('XXX{Escape}') + const parsed = rawJson(grid) as Record + expect('app' in parsed).toBe(true) + expect('XXX' in parsed).toBe(false) + }) +}) + +describe('/json-editor — focusRecovery plugin', () => { + it('after Delete middle row, focus lands on the next sibling', async () => { + const user = userEvent.setup() + const { container } = render() + const grid = freeGrid(container) + await user.click(rowByKey(grid, 'enabled')) + await user.keyboard('{Delete}') + expect(focusedRowText(grid)).toContain('tags') + }) + + it('after Delete last row, focus falls back to previous sibling', async () => { + const user = userEvent.setup() + const { container } = render() + const grid = freeGrid(container) + await user.click(rowByKey(grid, 'nested')) + await user.keyboard('{Delete}') + expect(focusedRowText(grid)).toContain('tags') + }) + + it('after deleting a nested child, focus stays within the same parent', async () => { + const user = userEvent.setup() + const { container } = render() + const grid = freeGrid(container) + await user.click(rowByKey(grid, 'tags')) + await user.keyboard('{ArrowRight}') + await user.click(rowByKey(grid, 'treegrid')) + await user.keyboard('{Delete}') + // focus should land on a sibling tag item, not jump to root + const focused = focusedRowText(grid) + expect(focused).not.toMatch(/^app/) + expect(focused).not.toMatch(/^version/) + }) +}) + +describe('/json-editor — addChild (Mod+Enter / +)', () => { + it('Mod+Enter on object container adds a new string key and starts rename', async () => { + const user = userEvent.setup() + const { container } = render() + const grid = freeGrid(container) + const countBefore = Object.keys(rawJson() as object).length + await user.click(rowByKey(grid, 'nested')) + // nested is an object container; Mod+Enter adds a child property + await user.click(rowByKey(grid, 'app')) + // add a root-level property instead (focused: app, leaf → sibling under root) + await user.keyboard('{Control>}{Enter}{/Control}') + const parsed = rawJson() as Record + expect(Object.keys(parsed).length).toBe(countBefore + 1) + expect('newKey' in parsed).toBe(true) + // rename input should be active on the new row's key cell + const input = grid.querySelector('[contenteditable="true"]') as HTMLElement | null + expect(input).not.toBeNull() + }) + + it('"+" alias also adds a child', async () => { + const user = userEvent.setup() + const { container } = render() + const grid = freeGrid(container) + const countBefore = Object.keys(rawJson() as object).length + await user.click(rowByKey(grid, 'app')) + await user.keyboard('+') + const countAfter = Object.keys(rawJson() as object).length + expect(countAfter).toBe(countBefore + 1) + }) + + it('Mod+Enter on array container appends a new item', async () => { + const user = userEvent.setup() + const { container } = render() + const grid = freeGrid(container) + const tagsBefore = (rawJson() as { tags: unknown[] }).tags.length + await user.click(rowByKey(grid, 'tags')) + await user.keyboard('{Control>}{Enter}{/Control}') + const tagsAfter = (rawJson() as { tags: unknown[] }).tags.length + expect(tagsAfter).toBe(tagsBefore + 1) + }) + + it('Second add produces a disambiguated key (newKey_2)', async () => { + const user = userEvent.setup() + const { container } = render() + const grid = freeGrid(container) + await user.click(rowByKey(grid, 'app')) + await user.keyboard('+') + // commit rename with default label, then add another + await user.keyboard('{Escape}') + await user.click(rowByKey(grid, 'app')) + await user.keyboard('+') + const parsed = rawJson() as Record + expect('newKey' in parsed).toBe(true) + expect('newKey_2' in parsed).toBe(true) + }) +}) + +describe('/json-editor — jsonEditPlugin', () => { + it('Enter on type cell cycles the JSON type', async () => { + const user = userEvent.setup() + const { container } = render() + const grid = freeGrid(container) + // version: number → Enter on type cell (col 1) → cycles to boolean + await user.click(cellOf(rowByKey(grid, 'version'), 1)) + await user.keyboard('{Enter}') + // version type should no longer be number + expect(typeof (rawJson(grid) as { version: unknown }).version).not.toBe('number') + }) + + it('Enter on boolean value cell toggles', async () => { + const user = userEvent.setup() + const { container } = render() + const grid = freeGrid(container) + // enabled starts true + expect((rawJson(grid) as { enabled: boolean }).enabled).toBe(true) + await user.click(cellOf(rowByKey(grid, 'enabled'), 2)) + await user.keyboard('{Enter}') + expect((rawJson(grid) as { enabled: boolean }).enabled).toBe(false) + await user.keyboard('{Enter}') + expect((rawJson(grid) as { enabled: boolean }).enabled).toBe(true) + }) +}) + +describe('/json-editor — zodSchema plugin', () => { + it('schema grid renders all schema-required fields', () => { + const { container } = render() + const schema = schemaGrid(container) + const parsed = rawJson(schema) as Record + expect('app' in parsed).toBe(true) + expect('version' in parsed).toBe(true) + expect('enabled' in parsed).toBe(true) + expect('tags' in parsed).toBe(true) + }) + + it('schema grid: cell rename that violates schema reverts to previous value', async () => { + const user = userEvent.setup() + const { container } = render() + const schema = schemaGrid(container) + // version is z.number(). Try to rename to "abc" — middleware should revert. + await user.click(cellOf(rowByKey(schema, 'version'), 2)) + await user.keyboard('{Enter}') + const input = schema.querySelector('[contenteditable="true"]') as HTMLElement | null + expect(input).not.toBeNull() + await user.tripleClick(input!) + await user.keyboard('abc{Enter}') + // typed "abc" → coerced to number (NaN→0) → valid, so value may become 0. + // But original was 3. Either way, final must be a number per schema. + const v = (rawJson(schema) as { version: unknown }).version + expect(typeof v).toBe('number') + }) +}) + +describe('/json-editor — navigate axis (expand)', () => { + it('ArrowRight expands a container, ArrowLeft collapses it', async () => { + const user = userEvent.setup() + const { container } = render() + const grid = freeGrid(container) + const tagsRow = rowByKey(grid, 'tags') + await user.click(tagsRow) + await user.keyboard('{ArrowRight}') + // expanded → children visible + expect(grid.textContent).toContain('treegrid') + await user.keyboard('{ArrowLeft}') + // after collapse, "treegrid" leaf row no longer a visible row (text still in DOM + // if aria-hidden kept; check aria-expanded on tags instead) + expect(tagsRow.getAttribute('aria-expanded')).toBe('false') + }) + + it('ArrowDown / ArrowUp moves focus between rows', async () => { + const user = userEvent.setup() + const { container } = render() + const grid = freeGrid(container) + await user.click(rowByKey(grid, 'app')) + await user.keyboard('{ArrowDown}') + expect(focusedRowText(grid)).toContain('version') + await user.keyboard('{ArrowUp}') + expect(focusedRowText(grid)).toContain('app') + }) +}) diff --git a/src/interactive-os/plugins/cellEdit.ts b/src/interactive-os/plugins/cellEdit.ts index 10d8ae779..d5b44a0a0 100644 --- a/src/interactive-os/plugins/cellEdit.ts +++ b/src/interactive-os/plugins/cellEdit.ts @@ -8,10 +8,28 @@ export function cellEdit(): Plugin { return definePlugin({ name: 'cellEdit', keyMap: { - 'Delete': key(['clipboard:clearCellValue'], (ctx) => clipboardCommands.clearCellValue(ctx.focused, ctx.grid?.colIndex ?? 0)), - 'Mod+X': key(['clipboard:cutCellValue'], (ctx) => clipboardCommands.cutCellValue(ctx.focused, ctx.grid?.colIndex ?? 0)), - 'Mod+C': key(['clipboard:copyCellValue'], (ctx) => clipboardCommands.copyCellValue(ctx.focused, ctx.grid?.colIndex ?? 0)), - 'Mod+V': key(['clipboard:pasteCellValue'], (ctx) => clipboardCommands.pasteCellValue(ctx.focused, ctx.grid?.colIndex ?? 0)), + // Cell-mode only (colIndex >= 0). Row-mode falls through to earlier plugin bindings + // (crud:Delete, clipboard row-level copy/cut/paste) via the original() chain. + 'Delete': key(['clipboard:clearCellValue'], (ctx, original) => { + const col = ctx.grid?.colIndex ?? -1 + if (col < 0) return original?.() + return clipboardCommands.clearCellValue(ctx.focused, col) + }), + 'Mod+X': key(['clipboard:cutCellValue'], (ctx, original) => { + const col = ctx.grid?.colIndex ?? -1 + if (col < 0) return original?.() + return clipboardCommands.cutCellValue(ctx.focused, col) + }), + 'Mod+C': key(['clipboard:copyCellValue'], (ctx, original) => { + const col = ctx.grid?.colIndex ?? -1 + if (col < 0) return original?.() + return clipboardCommands.copyCellValue(ctx.focused, col) + }), + 'Mod+V': key(['clipboard:pasteCellValue'], (ctx, original) => { + const col = ctx.grid?.colIndex ?? -1 + if (col < 0) return original?.() + return clipboardCommands.pasteCellValue(ctx.focused, col) + }), 'Enter': key(['core:focus'], (ctx) => ctx.focusNext()), 'Shift+Enter': key(['core:focus'], (ctx) => ctx.focusPrev()), }, diff --git a/src/interactive-os/ui/JsonEditor/JsonEditor.tsx b/src/interactive-os/ui/JsonEditor/JsonEditor.tsx index 5e7002dc7..15bc69dde 100644 --- a/src/interactive-os/ui/JsonEditor/JsonEditor.tsx +++ b/src/interactive-os/ui/JsonEditor/JsonEditor.tsx @@ -1,7 +1,9 @@ -import { useMemo, useRef } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import type { ReactElement } from 'react' import type { ZodType } from 'zod' +import { ax } from '../../../styles/ax' import { TreeGrid } from '../TreeGrid' +import { KeyHintBar, type KeyHint } from '../KeyHintBar' import { history } from '../../plugins/history' import { crud } from '../../plugins/crud' import { clipboard, clipboardCommands } from '../../plugins/clipboard' @@ -10,7 +12,7 @@ import { focusRecovery } from '../../plugins/focusRecovery' import { zodSchema } from '../../plugins/zodSchema' import type { ZodSchema } from '../../plugins/zodSchema' import { key } from '../../axis/types' -import { GRID_COL_ID } from '../../axis/navigate' +import { focusCommands, GRID_COL_ID } from '../../axis/navigate' import type { NormalizedData } from '../../store/types' import { EditableCell, EnumCell, SearchableCell, ToggleCell } from '../cells' import { BadgeCell } from '../cells/BadgeCell' @@ -23,8 +25,10 @@ import { import { normalizedToJson } from './normalizedToJson' import { resolveSchemaAt, zodToAxis, type FieldAxis } from './zodToAxis' import { + addJsonChild, jsonEditPlugin, nextJsonType, + predictNewChildId, setJsonType, setJsonValue, toggleJsonBoolean, @@ -85,7 +89,11 @@ export function JsonEditor({ schema, 'aria-label': ariaLabel = 'JSON editor', }: JsonEditorProps): ReactElement { - const data = useMemo(() => jsonToNormalized(value), [value]) + // NormalizedData is the internal SSOT — engine owns entity identity (focus, -copy-N ids, + // undo history). `value` prop is a sync signal: we resync only on external changes + // (echo-skip via lastEmitted). Re-normalizing every render would regenerate path-based + // ids and destroy focus/undo state after every command. + const [data, setData] = useState(() => jsonToNormalized(value)) const rootValueRef = useRef(value) // eslint-disable-next-line react-hooks/refs -- ref write is idempotent; tracks latest prop for event-time reads @@ -114,13 +122,19 @@ export function JsonEditor({ }, [schema]) const lastEmitted = useRef(value) - // dataRef tracks the LIVE engine store (with GRID_COL_ID, RENAME_ID, etc.) — updated - // only via onChange. Do NOT overwrite with prop-derived `data` on every render; that - // would erase the axis entities the engine adds. const dataRef = useRef(data) + dataRef.current = data + + // External value sync: if caller swaps `value` with something we didn't emit, + // rebuild the store from scratch (identity reset is acceptable for a genuine + // external change). If it matches our last emit, it's an echo — ignore. + useEffect(() => { + if (value === lastEmitted.current) return + setData(jsonToNormalized(value)) + }, [value]) const handleChange = (next: NormalizedData) => { - dataRef.current = next + setData(next) const json = normalizedToJson(next) as T if (json !== lastEmitted.current) { lastEmitted.current = json @@ -182,9 +196,33 @@ export function JsonEditor({ if (col < 0) return clipboardCommands.paste(ctx.focused) return clipboardCommands.pasteCellValue(ctx.focused, col) }), + // ── Add child/sibling: Mod+Enter (canonical) + '+' (alias). + // Resolves parent (container→self, leaf→parent), creates a default string + // property, focuses its key cell, starts rename for immediate edit. + 'Mod+Enter': key( + ['jsonEditor:addChild', 'core:focus', 'rename:start'], + (ctx) => { + const newId = predictNewChildId(ctx, ctx.focused) + if (!newId) return + ctx.dispatch(addJsonChild(ctx.focused)) + ctx.dispatch(focusCommands.setFocus(newId)) + return renameCommands.startRename(newId) + }, + ), + '+': key( + ['jsonEditor:addChild', 'core:focus', 'rename:start'], + (ctx) => { + const newId = predictNewChildId(ctx, ctx.focused) + if (!newId) return + ctx.dispatch(addJsonChild(ctx.focused)) + ctx.dispatch(focusCommands.setFocus(newId)) + return renameCommands.startRename(newId) + }, + ), }), [schema]) return ( +
({ return {core.cells[2]} }} /> + +
) } + +const JSON_EDITOR_HINTS: readonly KeyHint[] = [ + { keys: ['↑↓'], label: 'Navigate' }, + { keys: ['←→'], label: 'Collapse / Expand' }, + { keys: ['Enter'], label: 'Edit' }, + { keys: ['+'], label: 'Add child' }, + { keys: ['Delete'], label: 'Remove' }, + { keys: ['Esc'], label: 'Cancel' }, +] diff --git a/src/interactive-os/ui/JsonEditor/jsonEditCommands.ts b/src/interactive-os/ui/JsonEditor/jsonEditCommands.ts index 48b1169a5..03068b637 100644 --- a/src/interactive-os/ui/JsonEditor/jsonEditCommands.ts +++ b/src/interactive-os/ui/JsonEditor/jsonEditCommands.ts @@ -2,9 +2,9 @@ import type { Command, Middleware } from '../../engine/types' import type { NormalizedData } from '../../store/types' import type { ZodTypeAny } from 'zod' import { defineCommand } from '../../engine/defineCommand' -import { updateEntityData, getEntity } from '../../store/createStore' +import { updateEntityData, getEntity, getChildren, addEntity, getParent } from '../../store/createStore' import { definePlugin } from '../../plugins/definePlugin' -import { cellsFor, type JsonNodeCore, type JsonType, type JsonValue } from './jsonToNormalized' +import { cellsFor, type JsonNodeCore, type JsonPathSegment, type JsonType, type JsonValue } from './jsonToNormalized' import { resolveSchemaAt } from './zodToAxis' /** @@ -46,6 +46,110 @@ export const setJsonType = defineCommand('jsonEditor:setType', { }, }) +/** + * Add a new string child to a container. For objects, generates a fresh key + * (newKey, newKey_2, ...). For arrays, appends at the end. Returns new id so + * the UI can focus + start rename. + * + * When `targetId` refers to a leaf, we add a sibling under its parent instead. + */ +function uniqueKey(existing: string[], base = 'newKey'): string { + if (!existing.includes(base)) return base + let i = 2 + while (existing.includes(`${base}_${i}`)) i += 1 + return `${base}_${i}` +} + +function makeChildId(parentId: string, key: string | number): string { + return typeof key === 'number' + ? `${parentId}[${key}]` + : parentId === '$' || parentId.endsWith('$') + ? `$.${key}` + : `${parentId}.${key}` +} + +export const addJsonChild = defineCommand('jsonEditor:addChild', { + create: (targetId: string) => ({ targetId }), + handler: (store, { targetId }) => { + const target = getEntity(store, targetId)?.data as JsonNodeCore | undefined + if (!target) return store + + // Resolve parent: container → self; leaf → parent. + let parentId = targetId + let parent = target + if (target.type !== 'object' && target.type !== 'array') { + const pid = getParent(store, targetId) + if (!pid) return store + const pdata = getEntity(store, pid)?.data as JsonNodeCore | undefined + if (!pdata || (pdata.type !== 'object' && pdata.type !== 'array')) return store + parentId = pid + parent = pdata + } + + const children = getChildren(store, parentId) + if (parent.type === 'array') { + const index = children.length + const id = makeChildId(parentId, index) + const core: JsonNodeCore = { + type: 'string', value: '', path: [...parent.path, index], + } + return addEntity(store, { id, data: { ...core, cells: cellsFor(core) } }, parentId) + } + + // object + const existingKeys = children + .map((cid) => (getEntity(store, cid)?.data as JsonNodeCore | undefined)?.key ?? '') + .filter((k) => k.length > 0) + const key = uniqueKey(existingKeys) + const id = makeChildId(parentId, key) + const core: JsonNodeCore = { + type: 'string', key, value: '', path: [...parent.path, key], + } + return addEntity(store, { id, data: { ...core, cells: cellsFor(core) } }, parentId) + }, +}) + +/** Resolve the future child id that addJsonChild would create — so the UI can + * focus + start rename on the new row right after dispatch. Uses PatternContext + * accessors so callers don't need a raw store reference. */ +export interface NodeAccessors { + getEntity: (id: string) => { id: string; data?: unknown } | undefined + getChildren: (id: string) => string[] + getParent: (id: string) => string | undefined +} + +export function predictNewChildId(ctx: NodeAccessors, targetId: string): string | null { + const target = ctx.getEntity(targetId)?.data as JsonNodeCore | undefined + if (!target) return null + let parentId = targetId + let parent = target + if (target.type !== 'object' && target.type !== 'array') { + const pid = ctx.getParent(targetId) + if (!pid) return null + const pdata = ctx.getEntity(pid)?.data as JsonNodeCore | undefined + if (!pdata || (pdata.type !== 'object' && pdata.type !== 'array')) return null + parentId = pid + parent = pdata + } + const children = ctx.getChildren(parentId) + if (parent.type === 'array') return makeChildId(parentId, children.length) + const existingKeys = children + .map((cid) => (ctx.getEntity(cid)?.data as JsonNodeCore | undefined)?.key ?? '') + .filter((k) => k.length > 0) + return makeChildId(parentId, uniqueKey(existingKeys)) +} + +export const reindexArrayChild = defineCommand('jsonEditor:reindexArrayChild', { + create: (nodeId: string, path: JsonPathSegment[]) => ({ nodeId, path }), + handler: (store, { nodeId, path }) => { + const entity = getEntity(store, nodeId) + if (!entity) return store + const existing = entity.data as unknown as JsonNodeCore + const nextCore: JsonNodeCore = { ...existing, path } + return updateEntityData(store, nodeId, { path, cells: cellsFor(nextCore) }) + }, +}) + export const toggleJsonBoolean = defineCommand('jsonEditor:toggleBoolean', { create: (nodeId: string) => ({ nodeId }), handler: (store, { nodeId }) => { @@ -102,9 +206,48 @@ export interface JsonEditPluginOptions { * clipboard:clearCellValue) into typed JSON commands. data.* is SSOT; cells[] re-derived. * Paste/rename honor zod schema when provided. */ +/** + * After any structural change (paste/cut/duplicate/delete/rearrange) that can + * affect array ordering, re-derive cells[0] and path[last] for all children of + * every array in the store so index labels stay consistent with position. + * + * Cheap compared to a full re-normalize, and safe: only touches array children + * whose position-derived index no longer matches their stored path/cells. + */ +function reindexArrays(next: (c: Command) => void, getStore: () => NormalizedData): void { + const store = getStore() + for (const id of Object.keys(store.entities)) { + const data = store.entities[id]?.data as JsonNodeCore | undefined + if (data?.type !== 'array') continue + const children = getChildren(store, id) + children.forEach((childId, i) => { + const child = getEntity(store, childId)?.data as JsonNodeCore | undefined + if (!child) return + const last = child.path[child.path.length - 1] + if (last === i) return + const nextPath: JsonPathSegment[] = [...child.path.slice(0, -1), i] + next(reindexArrayChild(childId, nextPath)) + }) + } +} + function jsonEditorMiddleware(opts: JsonEditPluginOptions): Middleware { const { schema, getRootValue } = opts return (next, getStore) => (command: Command) => { + // Intercept structural mutations to realign array child indices afterwards. + const isStructural = + command.type === 'clipboard:paste' + || command.type === 'clipboard:cut' + || command.type === 'clipboard:duplicateAfter' + || command.type === 'crud:delete' + || command.type === 'crud:remove' + || command.type === 'core:remove' + if (isStructural) { + next(command) + reindexArrays(next, getStore) + return + } + if (command.type === 'rename:confirm') { const { nodeId, field, newValue } = command.payload as RenamePayload const data = getEntity(getStore(), nodeId)?.data as JsonNodeCore | undefined @@ -123,7 +266,11 @@ function jsonEditorMiddleware(opts: JsonEditPluginOptions): Middleware { } } - if (command.type === 'clipboard:pasteCellValue' || command.type === 'clipboard:clearCellValue') { + if ( + command.type === 'clipboard:pasteCellValue' + || command.type === 'clipboard:clearCellValue' + || command.type === 'clipboard:cutCellValue' + ) { next(command) // writes cells[colIndex] first const { nodeId, colIndex } = command.payload as ClipboardCellPayload const data = getEntity(getStore(), nodeId)?.data as (JsonNodeCore & { cells?: string[] }) | undefined @@ -153,6 +300,8 @@ export function jsonEditPlugin(options: JsonEditPluginOptions = {}) { setType: setJsonType, setKey: setJsonKey, toggleBoolean: toggleJsonBoolean, + addChild: addJsonChild, + reindexArrayChild, }, middleware: jsonEditorMiddleware(options), }) diff --git a/src/interactive-os/ui/cells/EditableCell.tsx b/src/interactive-os/ui/cells/EditableCell.tsx index 512eb6439..972a61428 100644 --- a/src/interactive-os/ui/cells/EditableCell.tsx +++ b/src/interactive-os/ui/cells/EditableCell.tsx @@ -1,6 +1,7 @@ +import React from 'react' import type { ReactNode } from 'react' -import { ax } from '@styles/ax' import { Aria } from '../../primitives/aria' +import { AriaItemContext } from '../../primitives/AriaEditable' interface EditableCellProps { field: string @@ -11,18 +12,21 @@ interface EditableCellProps { tabContinue?: boolean } +/** 표시 모드는 plain text — chrome 없음. 편집 모드(renaming)일 때만 sf-input + * border/bg가 나타난다. 높이는 바깥 [role="cell"]의 --cs-h를 상속하도록 CSS에서 주입 + * (ax.css의 .ly-table [role="row"] > [role="cell"] .sf-input 규칙). */ export function EditableCell({ field, children, empty, allowEmpty, enterContinue, tabContinue }: EditableCellProps) { + const nodeCtx = React.useContext(AriaItemContext) + const renaming = nodeCtx?.renaming ?? false return ( - - {empty ? '—' : children} - + {empty ? '—' : children} ) } diff --git a/src/interactive-os/ui/cells/EnumCell.tsx b/src/interactive-os/ui/cells/EnumCell.tsx index cfb5c0f78..bc46a6747 100644 --- a/src/interactive-os/ui/cells/EnumCell.tsx +++ b/src/interactive-os/ui/cells/EnumCell.tsx @@ -1,4 +1,4 @@ -import { BadgeCell } from './BadgeCell' +import { ax } from '@styles/ax' interface EnumCellProps { value: string @@ -6,7 +6,8 @@ interface EnumCellProps { /** Enumerated-value display cell (JSON type labels, schema enums). * Editing = cycle through options via keyMap dispatcher (jsonEditor:setType/setValue). + * chrome 없이 plain caption + dim tone — 편집 시에만 cycle indicator 필요 시 추가. */ export function EnumCell({ value }: EnumCellProps) { - return {value} + return {value} } diff --git a/src/interactive-os/ui/cells/SearchableCell.tsx b/src/interactive-os/ui/cells/SearchableCell.tsx index f68b9e521..067f54746 100644 --- a/src/interactive-os/ui/cells/SearchableCell.tsx +++ b/src/interactive-os/ui/cells/SearchableCell.tsx @@ -1,5 +1,4 @@ import type { ReactNode } from 'react' -import { ax } from '@styles/ax' import { Aria } from '../../primitives/aria' interface SearchableCellProps { @@ -9,18 +8,17 @@ interface SearchableCellProps { } export function SearchableCell({ children, empty, muted }: SearchableCellProps) { - // Render tone via ax() — empty: muted em-dash, key column: secondary tone. - const innerClass = empty || muted ? ax({ role: 'item', tone: 'neutral-dim' }) : undefined + // 읽기 전용 셀은 plain text — chrome은 편집 전환(EditableCell) 시에만 노출. + // tone만 주입: role 브랜치 없이 Public tone 클래스 직접 사용 (Pit of Failure 아님, utility path). + const toneClass = empty || muted ? 'tn-neutral-dim' : undefined return ( - - - {empty ? '—' : children} - + + {empty ? '—' : children} ) diff --git a/src/pages/jsonEditor/PageJsonEditor.tsx b/src/pages/jsonEditor/PageJsonEditor.tsx index 050ef8733..a396421b0 100644 --- a/src/pages/jsonEditor/PageJsonEditor.tsx +++ b/src/pages/jsonEditor/PageJsonEditor.tsx @@ -1,4 +1,45 @@ -// ② jsonEditorPrd.md -import { Demo as JsonEditorDemo } from '@os/ui/JsonEditor/JsonEditor.demo' +import { useMemo } from 'react' +import type { ReactElement } from 'react' +import { FlatLayout } from '@os/ui/FlatLayout' +import { createWidgetRegistry } from '@os/layout/widgetRegistry' +import { useEngine } from '@os/engine/useEngine' +import { definePlugin } from '@os/plugins/definePlugin' +import { jsonEditorStore, jsonEditorCommands } from './jsonEditorStore' +import { jsonEditorLayout } from './jsonEditorLayout' +import { JsonEditorProvider } from './jsonEditorContext' +import { + JsonEditorWidget, + JsonOutputWidget, + TsOutputWidget, + ZodOutputWidget, +} from './jsonEditorWidgets' -export default JsonEditorDemo +const jsonEditorPlugin = definePlugin({ + name: 'jsonEditorPage', + commands: { setJson: jsonEditorCommands.setJson }, +}) + +const registry = createWidgetRegistry({ + JsonEditorWidget, + JsonOutputWidget, + ZodOutputWidget, + TsOutputWidget, +}) + +export default function PageJsonEditor(): ReactElement { + const { engine, store } = useEngine({ + data: jsonEditorStore, + plugins: [jsonEditorPlugin], + }) + + const ctx = useMemo( + () => ({ store, dispatch: (c: Parameters[0]) => engine.dispatch(c) }), + [engine, store], + ) + + return ( + + + + ) +} diff --git a/src/pages/jsonEditor/__tests__/jsonSchemaGen.test.ts b/src/pages/jsonEditor/__tests__/jsonSchemaGen.test.ts new file mode 100644 index 000000000..6c26d919c --- /dev/null +++ b/src/pages/jsonEditor/__tests__/jsonSchemaGen.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest' +import { generateTypeScript, generateZodSchema } from '../jsonSchemaGen' + +describe('jsonSchemaGen — Zod', () => { + it('emits primitives', () => { + expect(generateZodSchema('x')).toContain('z.string()') + expect(generateZodSchema(1)).toContain('z.number()') + expect(generateZodSchema(true)).toContain('z.boolean()') + expect(generateZodSchema(null)).toContain('z.null()') + }) + + it('emits flat object', () => { + const src = generateZodSchema({ a: 'x', b: 1, c: true }) + expect(src).toContain('z.object({') + expect(src).toMatch(/a: z\.string\(\)/) + expect(src).toMatch(/b: z\.number\(\)/) + expect(src).toMatch(/c: z\.boolean\(\)/) + }) + + it('emits homogeneous array', () => { + expect(generateZodSchema(['a', 'b'])).toContain('z.array(z.string())') + }) + + it('emits heterogeneous array as union', () => { + const src = generateZodSchema([1, 'x']) + expect(src).toContain('z.array(z.union([') + expect(src).toContain('z.number()') + expect(src).toContain('z.string()') + }) + + it('emits nested object', () => { + const src = generateZodSchema({ outer: { inner: 1 } }) + expect(src).toMatch(/outer: z\.object\(\{[\s\S]*inner: z\.number\(\)/) + }) + + it('quotes non-identifier keys', () => { + expect(generateZodSchema({ 'weird-key': 1 })).toContain('"weird-key":') + }) + + it('exports schema and inferred type', () => { + const src = generateZodSchema({ a: 1 }, 'Foo') + expect(src).toContain('export const FooSchema =') + expect(src).toContain('export type Foo = z.infer') + }) +}) + +describe('jsonSchemaGen — TypeScript', () => { + it('emits interface for objects', () => { + const src = generateTypeScript({ a: 'x', b: 1 }) + expect(src).toContain('export interface Root {') + expect(src).toMatch(/a: string/) + expect(src).toMatch(/b: number/) + }) + + it('emits type alias for primitives', () => { + expect(generateTypeScript('x')).toContain('export type Root = string') + expect(generateTypeScript(1)).toContain('export type Root = number') + }) + + it('emits array types', () => { + expect(generateTypeScript(['a', 'b'])).toContain('string[]') + }) + + it('emits Array for heterogeneous arrays', () => { + const src = generateTypeScript([1, 'x']) + expect(src).toMatch(/Array<(string \| number|number \| string)>/) + }) + + it('emits nested interfaces inline', () => { + const src = generateTypeScript({ outer: { inner: 1 } }) + expect(src).toMatch(/outer: \{[\s\S]*inner: number/) + }) +}) diff --git a/src/pages/jsonEditor/jsonEditorContext.ts b/src/pages/jsonEditor/jsonEditorContext.ts new file mode 100644 index 000000000..e581f7202 --- /dev/null +++ b/src/pages/jsonEditor/jsonEditorContext.ts @@ -0,0 +1,11 @@ +import type { Command } from '@os/engine/types' +import type { NormalizedData } from '@os/store/types' +import { createDomainContext } from '@os/layout/createDomainContext' + +export interface JsonEditorContextValue { + store: NormalizedData + dispatch: (command: Command) => void +} + +export const [JsonEditorProvider, useJsonEditor] = + createDomainContext('JsonEditor') diff --git a/src/pages/jsonEditor/jsonEditorLayout.ts b/src/pages/jsonEditor/jsonEditorLayout.ts new file mode 100644 index 000000000..09487ecd2 --- /dev/null +++ b/src/pages/jsonEditor/jsonEditorLayout.ts @@ -0,0 +1,30 @@ +import { defineLayout } from '@os/layout' + +export const jsonEditorLayout = defineLayout({ + entities: { + root: { + data: { type: 'split', direction: 'horizontal', sizes: ['flex', 0.4] }, + children: ['editor', 'output'], + }, + editor: { data: { type: 'widget', widget: 'JsonEditorWidget' } }, + output: { + data: { type: 'tabgroup', activeTabId: 'tab-json' }, + children: ['tab-json', 'tab-zod', 'tab-ts'], + }, + 'tab-json': { + data: { type: 'tab', label: 'JSON', contentType: 'json' }, + children: ['json-panel'], + }, + 'tab-zod': { + data: { type: 'tab', label: 'Zod', contentType: 'zod' }, + children: ['zod-panel'], + }, + 'tab-ts': { + data: { type: 'tab', label: 'TypeScript', contentType: 'ts' }, + children: ['ts-panel'], + }, + 'json-panel': { data: { type: 'widget', widget: 'JsonOutputWidget' } }, + 'zod-panel': { data: { type: 'widget', widget: 'ZodOutputWidget' } }, + 'ts-panel': { data: { type: 'widget', widget: 'TsOutputWidget' } }, + }, +}) diff --git a/src/pages/jsonEditor/jsonEditorStore.ts b/src/pages/jsonEditor/jsonEditorStore.ts new file mode 100644 index 000000000..fdc02c8ca --- /dev/null +++ b/src/pages/jsonEditor/jsonEditorStore.ts @@ -0,0 +1,31 @@ +import { updateEntityData } from '@os/store/createStore' +import { defineCommands } from '@os/engine/defineCommand' +import { ROOT_ID } from '@os/store/types' +import type { NormalizedData } from '@os/store/types' +import type { JsonValue } from '@os/ui/JsonEditor/jsonToNormalized' + +export const VALUE_ID = 'value' + +export const seedJson: JsonValue = { + app: 'aria', + version: 3, + enabled: true, + tags: ['ui', 'treegrid', 'json'], + nested: { deep: { value: null } }, +} + +export const jsonEditorStore: NormalizedData = { + entities: { + [ROOT_ID]: { id: ROOT_ID, data: { type: 'root' } }, + [VALUE_ID]: { id: VALUE_ID, data: { type: 'value', json: seedJson } }, + }, + relationships: { [ROOT_ID]: [VALUE_ID] }, +} + +export const jsonEditorCommands = defineCommands({ + setJson: { + type: 'jsonEditor:setJson' as const, + create: (json: JsonValue) => ({ json }), + handler: (store, { json }) => updateEntityData(store, VALUE_ID, { json }), + }, +}) diff --git a/src/pages/jsonEditor/jsonEditorWidgets.tsx b/src/pages/jsonEditor/jsonEditorWidgets.tsx new file mode 100644 index 000000000..110c8c7f3 --- /dev/null +++ b/src/pages/jsonEditor/jsonEditorWidgets.tsx @@ -0,0 +1,57 @@ +import { useMemo } from 'react' +import type { ReactElement } from 'react' +import { JsonEditor } from '@os/ui/JsonEditor/JsonEditor' +import type { JsonValue } from '@os/ui/JsonEditor/jsonToNormalized' +import { Panel } from '@os/ui/panels/Panel' +import { useJsonEditor } from './jsonEditorContext' +import { VALUE_ID, jsonEditorCommands } from './jsonEditorStore' +import { generateTypeScript, generateZodSchema } from './jsonSchemaGen' + +function useValue(): JsonValue { + const { store } = useJsonEditor() + return (store.entities[VALUE_ID]?.data as { json: JsonValue }).json +} + +export function JsonEditorWidget(): ReactElement { + const { dispatch } = useJsonEditor() + const value = useValue() + return ( + + dispatch(jsonEditorCommands.setJson(next))} + aria-label="JSON editor" + /> + + ) +} + +export function JsonOutputWidget(): ReactElement { + const value = useValue() + const body = useMemo(() => JSON.stringify(value, null, 2), [value]) + return ( + +
{body}
+
+ ) +} + +export function ZodOutputWidget(): ReactElement { + const value = useValue() + const body = useMemo(() => generateZodSchema(value), [value]) + return ( + +
{body}
+
+ ) +} + +export function TsOutputWidget(): ReactElement { + const value = useValue() + const body = useMemo(() => generateTypeScript(value), [value]) + return ( + +
{body}
+
+ ) +} diff --git a/src/pages/jsonEditor/jsonSchemaGen.ts b/src/pages/jsonEditor/jsonSchemaGen.ts new file mode 100644 index 000000000..ada55cc0f --- /dev/null +++ b/src/pages/jsonEditor/jsonSchemaGen.ts @@ -0,0 +1,71 @@ +import type { JsonValue } from '@os/ui/JsonEditor/jsonToNormalized' + +function detectType(v: unknown): 'string' | 'number' | 'boolean' | 'null' | 'object' | 'array' { + if (v === null) return 'null' + if (Array.isArray(v)) return 'array' + return typeof v as 'string' | 'number' | 'boolean' | 'object' +} + +function isIdent(key: string): boolean { + return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) +} + +/** Merge element types of a heterogeneous array into a single schema node. */ +function unifyArray(items: JsonValue[], emit: (v: JsonValue, indent: number) => string, indent: number): string { + if (items.length === 0) return 'z.unknown()' + const parts = Array.from(new Set(items.map((i) => emit(i, indent)))) + if (parts.length === 1) return parts[0]! + return `z.union([${parts.join(', ')}])` +} + +/** Generate a Zod schema string from a JSON value. */ +export function generateZodSchema(value: JsonValue, rootName = 'Root'): string { + const pad = (n: number) => ' '.repeat(n) + function emit(v: JsonValue, indent: number): string { + const t = detectType(v) + if (t === 'string') return 'z.string()' + if (t === 'number') return 'z.number()' + if (t === 'boolean') return 'z.boolean()' + if (t === 'null') return 'z.null()' + if (t === 'array') return `z.array(${unifyArray(v as JsonValue[], emit, indent)})` + const entries = Object.entries(v as Record) + if (entries.length === 0) return 'z.object({})' + const lines = entries.map(([k, cv]) => { + const key = isIdent(k) ? k : JSON.stringify(k) + return `${pad(indent + 1)}${key}: ${emit(cv, indent + 1)},` + }) + return `z.object({\n${lines.join('\n')}\n${pad(indent)}})` + } + const body = emit(value, 0) + return `import { z } from 'zod'\n\nexport const ${rootName}Schema = ${body}\n\nexport type ${rootName} = z.infer\n` +} + +/** Generate a TypeScript interface/type from a JSON value. */ +export function generateTypeScript(value: JsonValue, rootName = 'Root'): string { + const pad = (n: number) => ' '.repeat(n) + function emit(v: JsonValue, indent: number): string { + const t = detectType(v) + if (t === 'string') return 'string' + if (t === 'number') return 'number' + if (t === 'boolean') return 'boolean' + if (t === 'null') return 'null' + if (t === 'array') { + const arr = v as JsonValue[] + if (arr.length === 0) return 'unknown[]' + const parts = Array.from(new Set(arr.map((i) => emit(i, indent)))) + if (parts.length === 1) return `${parts[0]}[]` + return `Array<${parts.join(' | ')}>` + } + const entries = Object.entries(v as Record) + if (entries.length === 0) return 'Record' + const lines = entries.map(([k, cv]) => { + const key = isIdent(k) ? k : JSON.stringify(k) + return `${pad(indent + 1)}${key}: ${emit(cv, indent + 1)}` + }) + return `{\n${lines.join('\n')}\n${pad(indent)}}` + } + const t = detectType(value) + const body = emit(value, 0) + if (t === 'object') return `export interface ${rootName} ${body}\n` + return `export type ${rootName} = ${body}\n` +} From b9d6b28533727ae54e697daf8e9a3353b80429a4 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: Mon, 20 Apr 2026 13:52:47 +0900 Subject: [PATCH 07/39] =?UTF-8?q?feat(ui):=20KeyHintBar=20+=20Kbd/Tooltip/?= =?UTF-8?q?SplitPane=20=EB=8B=A4=EB=93=AC=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit inspector source preview scroll 개선 관련 AppShell 수정 포함 (handoff 문서 첨부). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../handoffInspectorSourcePreviewScroll.md | 49 ++++++++++++++++++ src/AppShell.tsx | 35 +++++++++++-- src/interactive-os/ui/Kbd.tsx | 7 +-- src/interactive-os/ui/KeyHintBar.tsx | 31 +++++++++++ src/interactive-os/ui/SplitPane.css | 37 ++++++++++++-- src/interactive-os/ui/Tooltip.tsx | 1 - src/styles/ax.css | 51 ++++++++++++++++++- src/styles/layout.css | 2 + 8 files changed, 199 insertions(+), 14 deletions(-) create mode 100644 docs/2026/2026-04/2026-04-20/handoffInspectorSourcePreviewScroll.md create mode 100644 src/interactive-os/ui/KeyHintBar.tsx diff --git a/docs/2026/2026-04/2026-04-20/handoffInspectorSourcePreviewScroll.md b/docs/2026/2026-04/2026-04-20/handoffInspectorSourcePreviewScroll.md new file mode 100644 index 000000000..9eef578cd --- /dev/null +++ b/docs/2026/2026-04/2026-04-20/handoffInspectorSourcePreviewScroll.md @@ -0,0 +1,49 @@ +--- +id: handoffInspectorSourcePreviewScroll +type: handoff +slug: handoffInspectorSourcePreviewScroll +title: "Handoff: Inspector SourcePreview 전체 파일 뷰 + 스크롤" +tags: [handoff, devtools, inspector] +created: 2026-04-20 +updated: 2026-04-20 +status: open +summary: "Debug Inspector lock 시 뜨는 SourcePreview를 720×560로 확장하고 파일 전체를 스크롤 가능하게 전환" +--- + +# Handoff: Inspector SourcePreview 전체 파일 뷰 + 스크롤 + +> ⇧⌘D Inspector에서 컴포넌트를 lock하면 뜨는 코드 미리보기가 ±2줄·480×140 고정에 스크롤 불가였던 것을, 720×560 + 파일 전체 + 자동 라인 정렬로 확장. + +## 완료 + +| 커밋 | 내용 | +|------|------| +| `ba317a21` | feat(inspector): SourcePreview lock 시 전체 파일 뷰 + 스크롤 | + +- `src/devtools/inspector/SourcePreview.tsx` + - `PREVIEW_WIDTH 480→720`, `PREVIEW_HEIGHT 140→560` + - `extractSnippet` 제거, 파일 원문 그대로 `CodePreview`에 전달 + - 내부 스크롤 컨테이너 `ref`로 `[data-line="N"]` 찾아 중앙 정렬 + - 외곽 `pointerEvents: 'auto'`로 휠 입력 수용 +- `.claude/hooks/guardOsPatterns.mjs` + - `INSPECTOR_OVERLAY_FILES`에 `SourcePreview` 추가 (기존 `InspectorOverlay`·`MarqueeSelect`와 동일한 overlay 성격) + +## 남은 것 + +### 미완료 +- 없음 — 기능 완결. + +### 이후 +- 기존부터 dirty 상태인 파일들은 이 세션과 무관: `.claude/skills`, `src/interactive-os/ui/Tooltip.tsx`, `src/styles/ax.css`, `src/interactive-os/ui/cells/{EnumCell,SearchableCell}.tsx` (TS2322 AxTone 에러 2건 기존), untracked `guardMockupFidelity.mjs`·`MockupBar.tsx`·`gmailContext.ts` 외 mockup 관련 파일들. 해당 세션에서 마무리 필요. + +## 컨텍스트 + +- **관련 파일**: `src/devtools/inspector/{SourcePreview,InspectorOverlay,ComponentInspector}.tsx`, `src/interactive-os/ui/CodePreview.tsx` (`data-line` 속성 생성자) +- **주의**: + - `InspectorOverlay` 루트가 `pointerEvents: 'none'`이라 SourcePreview에서 명시적으로 `'auto'`를 켜야 휠 이벤트가 잡힌다. + - `fileCache`(module-scope Map)가 파일 원문을 캐싱한다 — 같은 파일 재lock 시 재요청 없음. + - `Mod+O`의 `QuickLookModal` 전체 보기는 그대로 유지. + +## 이어받는 법 + +추가 작업 없음. 세션을 그대로 닫아도 된다. 검증은 `pnpm dev` → ⇧⌘D → 아무 컴포넌트 lock → 박스 스크롤·라인 중앙 정렬 확인. diff --git a/src/AppShell.tsx b/src/AppShell.tsx index 2e6709c55..0855f2801 100644 --- a/src/AppShell.tsx +++ b/src/AppShell.tsx @@ -14,6 +14,9 @@ import { ax } from '@styles/ax' import { defineRouteKey } from '@os/primitives/defineRouteKey' import { useTheme } from './hooks/useTheme' import { ActivityBar } from './ActivityBar' +import { FlatLayout } from '@os/ui/FlatLayout' +import { defineLayout } from '@os/layout' +import { createWidgetRegistry } from '@os/layout/widgetRegistry' import './styles/layers.css' // L0: Layer order declaration (must be first) import './styles/palette.css' // L0: OKLCH color palette @@ -30,6 +33,20 @@ import './pages/showcase/registerMdRenderer' // .md 파일 렌더러 등록 (CS const MOBILE_ROUTES = ['/todo'] +const shellLayoutWithBar = defineLayout({ + entities: { + root: { data: { type: 'split', direction: 'horizontal', sizes: ['auto', 'flex'], resizable: false }, children: ['activitybar', 'content'] }, + activitybar: { data: { type: 'widget', widget: 'ShellActivityBar' } }, + content: { data: { type: 'widget', widget: 'ShellContent' } }, + }, +}) + +const shellLayoutMobile = defineLayout({ + entities: { + root: { data: { type: 'widget', widget: 'ShellContent' } }, + }, +}) + export default function AppShell() { const { theme, toggle: toggleTheme } = useTheme() const { search, pathname } = useLocation() @@ -60,15 +77,23 @@ export default function AppShell() { 'Mod+Shift+I': defineRouteKey('shell:open-inspector', () => openInspectorWindow(), 'Shell'), }), []) + const registry = useMemo(() => createWidgetRegistry({ + ShellActivityBar: () => , + ShellContent: () => ( +
+ +
+ ), + }), [theme, toggleTheme]) + + const layout = isMobileRoute ? shellLayoutMobile : shellLayoutWithBar + return ( -
+
- {!isMobileRoute && } -
- -
+ {children} diff --git a/src/interactive-os/ui/KeyHintBar.tsx b/src/interactive-os/ui/KeyHintBar.tsx new file mode 100644 index 000000000..40838e382 --- /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/SplitPane.css b/src/interactive-os/ui/SplitPane.css index fe2cc2dbb..7d0727fb7 100644 --- a/src/interactive-os/ui/SplitPane.css +++ b/src/interactive-os/ui/SplitPane.css @@ -1,9 +1,18 @@ -@layer component { +@layer state { /* SplitPane — separator는 "투명 gap". island 디자인에 따라 경계선 대신 여백이 분리 언어. - * hover/focus 시각 피드백은 sf-action 기본 규칙(ax hover 토큰)이 이미 소유. */ + * hover/focus 시각 피드백은 sf-action 기본 규칙(ax hover 토큰)이 이미 소유. + * role:'control'의 min-width/min-height(--cs-h) 및 padding을 separator 크기에 맞게 무력화. */ +.split-sep-h, +.split-sep-v { + min-width: 0; + min-height: 0; + padding: 0; + border: none; +} + .split-sep-h { width: var(--space-sm); - border: none; + height: auto; } .split-sep-h::before { @@ -12,9 +21,19 @@ inset: 0 calc(-1 * var(--space-xs)); } +.split-sep-h::after { + content: ''; + position: absolute; + inset: 0; + margin-inline: auto; + width: 1px; + background: var(--border-strong); + pointer-events: none; +} + .split-sep-v { height: var(--space-sm); - border: none; + width: auto; } .split-sep-v::before { @@ -22,4 +41,14 @@ position: absolute; inset: calc(-1 * var(--space-xs)) 0; } + +.split-sep-v::after { + content: ''; + position: absolute; + inset: 0; + margin-block: auto; + height: 1px; + background: var(--border-strong); + pointer-events: none; +} } diff --git a/src/interactive-os/ui/Tooltip.tsx b/src/interactive-os/ui/Tooltip.tsx index 1ad7fd202..e096af026 100644 --- a/src/interactive-os/ui/Tooltip.tsx +++ b/src/interactive-os/ui/Tooltip.tsx @@ -61,7 +61,6 @@ export function Tooltip({ content, placement = 'bottom', children }: TooltipProp <> {cloneElement(child as ReactElement>, { ref: triggerRef, - interestfor: id, 'aria-describedby': id, onMouseEnter: show, onMouseLeave: hide, diff --git a/src/styles/ax.css b/src/styles/ax.css index 61a7917e4..1c0c7ad7f 100644 --- a/src/styles/ax.css +++ b/src/styles/ax.css @@ -37,12 +37,15 @@ * 번들: cursor + border + background + color + 상태 정책 * ════════════════════════════════════════════ */ +/* bg fallback이 transparent이므로 fg fallback도 tone에 종속되면 안 된다. + tone 주입 시: --_fg = --tone-primary-foreground (tn-* 블록) + tone 미주입 시: --text-primary — 배경이 투명이므로 primary 텍스트. */ .sf-action { cursor: pointer; user-select: none; border: none; background: var(--_bg, transparent); - color: var(--_fg, var(--text-on-action)); + color: var(--_fg, var(--text-primary)); transition: color 150ms, background-color 150ms, box-shadow 150ms; } .sf-action:hover { background: var(--_bg-hover, var(--bg-hover)); } @@ -502,6 +505,29 @@ dialog.sf-overlay[open] { background: var(--_bg, var(--surface-sunken)); } +/* ════════════════════════════════════════════ + * Role: Tip — 툴팁 pill의 크기 정체성 + * caption band 가정 (fallback 24/12/3/6 = badge와 동일). surface가 색·shadow, + * role은 padding/font/radius. role.surface CSS가 있어야 ts-caption의 cs 공급이 + * 도달한다 (구독자 없으면 padding 0 — rolePreset.ts 주석 페어링). + * ════════════════════════════════════════════ */ + +/* display/width는 :popover-open에서만 — UA의 + [popover]:not(:popover-open) { display:none }을 덮지 않도록. */ +.rl-tip { + font-size: var(--font-size, 12px); + font-weight: var(--type-caption-weight, 400); + line-height: 1.4; + padding-block: var(--cs-py, 3px); + padding-inline: calc(var(--cs-py, 3px) * var(--pd-ratio, 2)); + border-radius: var(--shape-sm-radius); + white-space: normal; +} +.rl-tip:popover-open { + display: inline-block; + width: max-content; +} + } /* @layer recipe — Recipe, Control Size, Text Style */ @layer state { @@ -703,6 +729,29 @@ dialog.sf-overlay[open] { display: grid; grid-template-columns: subgrid; grid-column: 1 / -1; + /* 내부 셀이 자체 padding/gap을 가지므로 행은 grid row 역할만 — .rl-item의 + padding/gap/border-radius(list-item용)를 grid 맥락에서 reset. */ + padding: 0; + gap: 0; + border-radius: 0; + min-height: 0; + align-items: center; +} +.ly-table [role="row"] > [role="gridcell"], +.ly-table [role="row"] > [role="cell"] { + min-height: var(--cs-h, 28px); + display: flex; + align-items: center; + padding-inline: var(--cs-px, 8px); +} +/* 편집 모드 pill(.sf-input)은 바깥 cell 높이를 꽉 채움 — + AriaEditable 인라인 paddingBlock:0을 flex-center로 상쇄해 텍스트가 수직 중앙. */ +.ly-table [role="row"] > [role="cell"] .sf-input, +.ly-table [role="row"] > [role="gridcell"] .sf-input { + min-height: var(--cs-h, 28px); + display: inline-flex; + align-items: center; + flex: 1; } .ly-table > [role="row"]:not(.ax-interactive [role="row"]), .ly-table > [role="rowgroup"] { diff --git a/src/styles/layout.css b/src/styles/layout.css index 55544d790..7d7b44938 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -13,6 +13,8 @@ .page-content > * { flex: 1; } .page { + display: flex; + flex-direction: row; height: 100svh; background: var(--surface-base); } From ca402475ffa0f46b5f05cbb1ad718d3147ffabc1 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: Mon, 20 Apr 2026 13:52:55 +0900 Subject: [PATCH 08/39] =?UTF-8?q?feat(mockup):=20gmail=20fidelity=20ladder?= =?UTF-8?q?=20=EC=8B=A4=ED=97=98=20(low/mid/hi)=20+=20MockupBar=20+=20guar?= =?UTF-8?q?d=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mockup 스킬의 다단 피델리티 실험. @faker-js/faker 의존성 추가 필요(별도). Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/hooks/guardMockupFidelity.mjs | 143 +++++++ .../2026-04/2026-04-19/axNamingDictionary.md | 361 ++++++++++++++++++ docs/2026/2026-04/2026-04-19/gmailMockup.md | 128 +++++++ src/interactive-os/ui/MockupBar.tsx | 31 ++ .../__mockup__/gmail/DataInspector.module.css | 13 + src/pages/__mockup__/gmail/DataInspector.tsx | 115 ++++++ src/pages/__mockup__/gmail/HifiWidgets.tsx | 243 ++++++++++++ src/pages/__mockup__/gmail/MidfiWidgets.tsx | 170 +++++++++ src/pages/__mockup__/gmail/PageHi.tsx | 31 ++ src/pages/__mockup__/gmail/PageLow.module.css | 10 + src/pages/__mockup__/gmail/PageLow.tsx | 35 ++ src/pages/__mockup__/gmail/PageMid.tsx | 31 ++ .../__mockup__/gmail/WireframeWidgets.tsx | 172 +++++++++ src/pages/__mockup__/gmail/fixtures.ts | 135 +++++++ src/pages/__mockup__/gmail/layout.ts | 22 ++ src/pages/__mockup__/gmail/schema.ts | 55 +++ src/pages/showcase/gmail/gmailContext.ts | 27 ++ 17 files changed, 1722 insertions(+) create mode 100644 .claude/hooks/guardMockupFidelity.mjs create mode 100644 docs/2026/2026-04/2026-04-19/axNamingDictionary.md create mode 100644 docs/2026/2026-04/2026-04-19/gmailMockup.md create mode 100644 src/interactive-os/ui/MockupBar.tsx create mode 100644 src/pages/__mockup__/gmail/DataInspector.module.css create mode 100644 src/pages/__mockup__/gmail/DataInspector.tsx create mode 100644 src/pages/__mockup__/gmail/HifiWidgets.tsx create mode 100644 src/pages/__mockup__/gmail/MidfiWidgets.tsx create mode 100644 src/pages/__mockup__/gmail/PageHi.tsx create mode 100644 src/pages/__mockup__/gmail/PageLow.module.css create mode 100644 src/pages/__mockup__/gmail/PageLow.tsx create mode 100644 src/pages/__mockup__/gmail/PageMid.tsx create mode 100644 src/pages/__mockup__/gmail/WireframeWidgets.tsx create mode 100644 src/pages/__mockup__/gmail/fixtures.ts create mode 100644 src/pages/__mockup__/gmail/layout.ts create mode 100644 src/pages/__mockup__/gmail/schema.ts create mode 100644 src/pages/showcase/gmail/gmailContext.ts diff --git a/.claude/hooks/guardMockupFidelity.mjs b/.claude/hooks/guardMockupFidelity.mjs new file mode 100644 index 000000000..384edd6a7 --- /dev/null +++ b/.claude/hooks/guardMockupFidelity.mjs @@ -0,0 +1,143 @@ +#!/usr/bin/env node + +/** + * PreToolUse:Write|Edit hook — mockup fidelity-tier 규칙 강제 + * + * Phase 3 Low-fi / Phase 4 Mid-fi / Phase 5 Hi-fi 각 tier의 경계를 정적 검사로 차단. + * "fidelity leak" (low-fi에 hi-fi 요소가 들어오거나 반대)을 막는다. + * + * 대상 파일 경로: + * src/pages/__mockup__/{slug}/PageLow.tsx + * src/pages/__mockup__/{slug}/WireframeWidgets.tsx + * src/pages/__mockup__/{slug}/PageMid.tsx + * src/pages/__mockup__/{slug}/MidfiWidgets.tsx + * src/pages/__mockup__/{slug}/PageHi.tsx + * src/pages/__mockup__/{slug}/HifiWidgets.tsx + * src/pages/__mockup__/{slug}/layout.ts + */ + +import { readFileSync } from 'fs' + +function getFullContent(file_path, ti) { + // Write: full content provided. + if (ti.content != null) return ti.content + // Edit: simulate old_string → new_string on current file content, + // so presence/absence checks see the final state, not just the diff fragment. + if (ti.new_string != null && ti.old_string != null) { + try { + const current = readFileSync(file_path, 'utf8') + return ti.replace_all + ? current.split(ti.old_string).join(ti.new_string) + : current.replace(ti.old_string, ti.new_string) + } catch { + return ti.new_string + } + } + return '' +} + +let input = '' +process.stdin.setEncoding('utf8') +process.stdin.on('data', (c) => { input += c }) +process.stdin.on('end', () => { + let payload + try { payload = JSON.parse(input) } catch { process.exit(0) } + const ti = payload?.tool_input ?? {} + const file_path = ti.file_path ?? '' + const body = getFullContent(file_path, ti) + if (!file_path || !body) process.exit(0) + + const violations = [] + + const isMockupPath = /\/src\/pages\/__mockup__\/[^/]+\//.test(file_path) + if (!isMockupPath) process.exit(0) + + const isLowWidgets = /\/WireframeWidgets\.tsx$/.test(file_path) + const isPageLow = /\/PageLow\.tsx$/.test(file_path) + const isMidWidgets = /\/MidfiWidgets\.tsx$/.test(file_path) + const isPageMid = /\/PageMid\.tsx$/.test(file_path) + const isHiWidgets = /\/HifiWidgets\.tsx$/.test(file_path) + const isPageHi = /\/PageHi\.tsx$/.test(file_path) + const isLayout = /\/layout\.ts$/.test(file_path) + const isPageAny = isPageLow || isPageMid || isPageHi + + // ── Shared: all mockup pages ── + if (isPageAny) { + if (/from ['"].*AppShell/.test(body) || /import\s+AppShell/.test(body)) { + violations.push('AppShell import 금지 — mockup 라우트는 shell-clean (router에서 AppShell 바깥에 등록)') + } + if (!/FlatLayout/.test(body)) { + violations.push('FlatLayout import 필수 — mockup은 defineLayout 엔진 기반 렌더') + } + if (!/createWidgetRegistry/.test(body)) { + violations.push('createWidgetRegistry 필수 — widget registry 없이 FlatLayout 비정상 동작') + } + if (/data-theme=['"]light['"]/.test(body)) { + violations.push('data-theme="light" 금지 — 프로젝트 dark 기본 유지 (fidelity와 theme은 독립 축)') + } + } + + // ── Layout (shared across Phase 3-5) ── + if (isLayout) { + if (!/defineLayout/.test(body)) { + violations.push('layout.ts는 defineLayout 사용 필수') + } + } + + // ── Phase 3 Low-fi rules ── + if (isLowWidgets) { + if (!/MockupBar/.test(body)) { + violations.push('MockupBar import 필수 — wireframe은 ax-based MockupBar (role:item + surface:display)만 사용. badge+nbsp 꼼수 금지') + } + if (/tone:\s*['"](accent|danger|success|warning)(?!-)/.test(body)) { + violations.push("tone 강조 금지 — low-fi는 중립. 'accent'/'danger'/'success'/'warning' 사용 시 Phase 5 Hi-fi로 이동") + } + if (/textStyle:\s*['"](page|section|hero)['"]/.test(body)) { + violations.push("textStyle hierarchy 금지 — 'page'/'section'/'hero'는 Phase 5 Hi-fi용. Low-fi는 label/body/caption만") + } + if (/Array\.from\(\{\s*length:/.test(body)) { + violations.push('Array.from({length:N}) 금지 — Phase 1 fixtures(MAILS/THREAD/FOLDERS)를 그대로 순회. Low-fi는 실제 데이터 분포를 반영해야') + } + if (/(starred|unread|important|selected|active|checked|hasAttachment)\s*\?\s*[^:]+:/.test(body)) { + violations.push('상태 분기 (ternary) 금지 — low-fi는 state-uniform. unread/read 같은 조건 분기는 Phase 5 Hi-fi에서') + } + if (/surface:\s*['"]action['"]/.test(body)) { + violations.push("surface:'action' 금지 — action surface는 tone 강조와 쌍 (Phase 5 Hi-fi). Low-fi는 base/ghost/sunken/display만") + } + if (/role:\s*['"]control['"].*?tone:/s.test(body) && /surface:\s*['"]action['"]/.test(body)) { + // already caught above but reinforced + } + } + + // ── Phase 4 Mid-fi rules ── + if (isMidWidgets) { + if (/MockupBar/.test(body)) { + violations.push('MockupBar 금지 — Mid-fi는 실 데이터 텍스트 렌더. MockupBar는 Low-fi 전용') + } + if (/textStyle:\s*['"](page|section|hero)['"]/.test(body)) { + violations.push("strong hierarchy 금지 — textStyle 'page'/'section'/'hero'는 Phase 5 Hi-fi용") + } + if (/(unread|starred)\s*\?\s*['"]display['"]\s*:\s*['"]ghost['"]/.test(body)) { + violations.push('unread/starred 기반 surface 분기 금지 — Phase 5 Hi-fi에서 Hierarchy 결정 후 분기') + } + } + + // ── Phase 5 Hi-fi rules ── + if (isHiWidgets) { + if (/MockupBar/.test(body)) { + violations.push('MockupBar 금지 — Hi-fi는 실 ui/ 부품 사용 (Avatar/Badge/Button 등)') + } + } + + if (violations.length) { + const header = isLowWidgets || isPageLow ? 'Low-fi 규칙' + : isMidWidgets || isPageMid ? 'Mid-fi 규칙' + : isHiWidgets || isPageHi ? 'Hi-fi 규칙' + : isLayout ? 'Layout 규칙' : 'Mockup 규칙' + process.stderr.write(`mockup fidelity ${header} 위반 ${violations.length}건:\n`) + violations.forEach((v, i) => process.stderr.write(` ${i + 1}. ${v}\n`)) + process.stderr.write('\n참고: .claude/skills/mockup/SKILL.md · docs/.../gmailMockup.md\n') + process.exit(2) + } + process.exit(0) +}) diff --git a/docs/2026/2026-04/2026-04-19/axNamingDictionary.md b/docs/2026/2026-04/2026-04-19/axNamingDictionary.md new file mode 100644 index 000000000..8a350e105 --- /dev/null +++ b/docs/2026/2026-04/2026-04-19/axNamingDictionary.md @@ -0,0 +1,361 @@ +--- +id: axNamingDictionary +type: inbox +slug: axNamingDictionary +title: ax 네이밍 딕셔너리 — 전수 조사 +tags: [ax, naming, dictionary, design-system] +created: 2026-04-19 +updated: 2026-04-19 +status: open +layer: styles +summary: ax() 시스템의 모든 축·값·prefix·role×surface 조합을 코드에서 전수 추출한 단일 참조표. SSOT는 src/styles/axPublic.ts + axPrivate.ts + rolePreset.ts. +--- + +# ax 네이밍 딕셔너리 — 전수 조사 + +> **SSOT 파일** +> - `src/styles/axPublic.ts` — Public 12축 타입·AxPublic discriminated union +> - `src/styles/axPrivate.ts` — Private 7축 타입·`AX_PRIVATE_KEYS` +> - `src/styles/rolePreset.ts` — `rolePresetTable` · `textStylePresetTable` +> - `src/styles/ax.ts` — prefix map(`prefixes`) · `ax()` entry +> - `src/styles/axRaw.ts` — `ax.raw()` escape hatch + `PRIVATE_PREFIXES` + +## 1. 축 × Prefix 표 (19축) + +### Public (12축 — `ax()`에 직접 전달 가능) + +| 키 | prefix | 타입 | +|---|---|---| +| `role` | `rl` | `AxRole` | +| `surface` | `sf` | `AxSurface` | +| `tone` | `tn` | `AxTone` | +| `textStyle` | `ts` | `AxTextStyle` | +| `content` | `ct` | `AxContent` | +| `interactive` | `ia` | `AxInteractive` | +| `layout` | `ly` | `AxLayout` | +| `placement` | `pl` | `AxPlacement` | +| `width` | `w` | `AxWidth` | +| `flex` | `fx` | `AxFlex` | +| `clamp` | `cl` | `AxClamp` | +| `aspect` | `ar` | `AxAspect` | + +### Private (7축 — `ax.raw()` 또는 `rolePreset` 경유만 도달) + +| 키 | prefix | 타입 | +|---|---|---| +| `padding` | `pd` | `AxPadding` | +| `gap` | `g` | `AxGap` | +| `shape` | `sh` | `AxShape` | +| `border` | `bd` | `AxBorder` | +| `icon` | `ic` | `AxIcon` | +| `square` | `sq` | `AxSquare` | +| `motion` | `mo` | `AxMotion` | + +### @removed (과거 존재 → 현재 폐기) + +| 키 | 폐기 사유 (commit refs: §1 #4~#6, 2026-04-19 ax-textstyle-ssot-prd) | +|---|---| +| `scroll` | `AxLayout`의 `scroll`/`scroll-x`/`clip`로 흡수 | +| `cs` | `textStyle` 4-tuple(font-size·cs-h·cs-py·cs-px)이 SSOT | +| `text` | surface→text pairing을 CSS layer가 자동 파생 (Material on-*) | +| `weight` | surface→text pairing CSS가 SSOT | +| `opacity` | CSS layer 자동 파생 | +| `state` | Zone(surface+material+elevation) cascade가 결정 | + +## 2. 값 전수 enum (Public) + +### `AxRole` (10 브랜치) + +| 값 | 설명 | surface 필수? | +|---|---|---| +| `control` | 인터랙티브 컨트롤 (버튼·입력·탭) | ✅ (strict) | +| `control-group` | 컨트롤 묶음 컨테이너 (패널·툴바·그룹) | ❌ (silent) | +| `item` | 리스트/트리/탭 행 | ❌ (silent) | +| `cell` | grid 칸 컨테이너 + 내부 control 묶음 | ✅ (strict) | +| `badge` | 뱃지 | ✅ (strict) | +| `utility` | default 브랜치 (role 키 생략 시) — 레이아웃/타이포 전용 | ❌ | +| `tip` | 툴팁/오버레이 보조 표면 | ✅ (strict) | +| `metric` | 숫자 강조 표시 (신규) | ✅ (strict) | +| `signal` | 시스템→사용자 알림 (신규) | ✅ (strict) | +| `placeholder` | 로딩/Skeleton (신규) | ❌ (optional) | + +> **strictRoles** = `{ control, badge, tip, cell, metric, signal }` — surface 지정 + preset all-miss 시 `resolveRolePreset` throw (현재 Phase 1-a G-5 임시 warn). + +### `AxSurface` (role-별 subset 잠금, 10개 union) + +| role | 허용 surface | +|---|---| +| `control` | `action` · `ghost` · `input` · `placeholder` | +| `control-group` | `sunken` · `base` · `raised` · `overlay` · `ghost` | +| `item` | `ghost` · `display` | +| `cell` | `display` · `ghost` · `input` | +| `badge` | `display` · `ghost` · `overlay` · `placeholder` | +| `tip` | `inverted` · `overlay` | +| `metric` | `display` · `ghost` · `sunken` | +| `signal` | `display` · `overlay` · `ghost` | +| `placeholder` | `sunken` · `ghost` · `display` | + +> 전체 surface 어휘 union: `action · ghost · input · placeholder · display · overlay · inverted · sunken · base · raised` + +### `AxTone` (10값, 5색 × 2강도) + +``` +accent · danger · success · warning · neutral +accent-dim · danger-dim · success-dim · warning-dim · neutral-dim +``` + +### `AxTextStyle` (9값) + +``` +hero · display · page · section · label · body · caption · code · overline +``` + +### `AxContent` (4값) + +``` +text · code · bubble · icon +``` + +### `AxInteractive` (6값) + +``` +item · tab · check · cell · input · button +``` + +> 인터랙티브 아이템은 이 중 하나를 필수 선언. `surface: 'ghost'`는 독립 버튼/컨트롤 한정. + +### `AxLayout` (18값) + +``` +row · center · bar · spread · stack +scroll · scroll-x · clip +fill · row-fill · wrap +grid-2 · grid-3 · grid-4 · grid-5 · grid-7 · table +self-start · self-end · self-center +``` + +### `AxPlacement` (18값) + +``` +above · below · bottom · bottom-center · center +top-start · top-end · viewport · sticky +anchor-below · anchor-below-start · anchor-above · anchor-end · anchor-start +relative +float-top-start · float-top-center · float-bottom-center · float-bottom +``` + +### `AxWidth` (8값) + +``` +full · auto · fit · sm · md · lg · xl · prose +``` + +### `AxFlex` (3값) + +``` +none · auto · 1 +``` + +### `AxClamp` (5값) + +``` +1 · 2 · 3 · 4 · pre +``` + +### `AxAspect` (3값) + +``` +1 · video · card +``` + +## 3. 값 전수 enum (Private) + +### `AxPadding` (6값) + +``` +none · xs · sm · md · lg · xl +``` + +### `AxGap` (6값) + +``` +xs · sm · md · lg · xl · 2xl +``` + +### `AxShape` (9값) + +``` +none · 2xs · xs · sm · md · lg · xl · pill · island +``` + +> `island` = 큰 radius + overflow clip 번들. `surface:'raised'`와 페어링 시 "sunken 속 떠오른 섬" 시멘틱 (단순 radius 스케일 아님). + +### `AxBorder` (9값, full 5 + side 4) + +``` +# full +subtle · default · strong · dashed · ring +# side +bottom · top · start · end +``` + +### `AxIcon` (4값) + +``` +xs · sm · md · lg +``` + +### `AxSquare` (6값) + +``` +xs · sm · md · lg · xl · 2xl +``` + +### `AxMotion` (9값) + +``` +pulse · spin · fade-in · slide-up +fade-slide-in · slide-in · scale-in · blink · shimmer +``` + +## 4. `rolePresetTable` 전수 (cascade seed) + +**키 형식**: `role` · `role.surface` · `role.surface.interactive` · `role.surface.content` + +### control.* + +| key | 주입 Private | +|---|---| +| `control.action` | `{ shape: 'md', gap: 'xs' }` | +| `control.action.text` | `{}` | +| `control.action.icon` | `{}` | +| `control.action.button` | `{ gap: 'sm' }` | +| `control.ghost` | `{ shape: 'md' }` | +| `control.ghost.icon` | `{}` | +| `control.ghost.text` | `{ shape: 'sm' }` | +| `control.ghost.tab` | `{ shape: 'sm' }` | +| `control.input` | `{ shape: 'sm', border: 'default' }` | +| `control.input.text` | `{}` | +| `control.input.input` | `{ shape: 'md' }` | +| `control.placeholder` | `{ shape: 'md', motion: 'spin' }` | + +### control-group.* + +| key | 주입 Private | +|---|---| +| `control-group.overlay` | `{ gap: 'xs', shape: 'xl' }` | +| `control-group.raised` | `{ gap: 'xs', shape: 'island' }` | +| `control-group.sunken` | `{ gap: 'sm' }` | + +### item.* + +| key | 주입 Private | +|---|---| +| `item.placeholder` | `{ gap: 'sm', motion: 'shimmer' }` | + +### cell.* + +| key | 주입 Private | +|---|---| +| `cell.display` | `{ gap: 'xs' }` | +| `cell.ghost` | `{}` | +| `cell.input` | `{ shape: 'sm', border: 'default' }` | + +### badge.* + +| key | 주입 Private | +|---|---| +| `badge.display` | `{ shape: 'pill' }` | +| `badge.ghost` | `{}` | +| `badge.overlay` | `{ shape: 'md' }` | +| `badge.placeholder` | `{ shape: 'pill', motion: 'pulse' }` | + +### tip.* + +| key | 주입 Private | +|---|---| +| `tip.inverted` | `{ shape: 'sm', motion: 'fade-slide-in' }` | +| `tip.overlay` | `{ shape: 'sm', motion: 'fade-slide-in' }` | + +### metric.* + +| key | 주입 Private | +|---|---| +| `metric.display` | `{ shape: 'sm' }` | +| `metric.display.text` | `{}` | +| `metric.display.bubble` | `{ shape: 'md' }` | +| `metric.ghost` | `{}` | +| `metric.sunken` | `{ shape: 'sm' }` | + +### signal.* + +| key | 주입 Private | +|---|---| +| `signal.display` | `{ shape: 'md' }` | +| `signal.display.button` | `{ shape: 'md' }` | +| `signal.overlay` | `{ shape: 'md', motion: 'fade-slide-in' }` | +| `signal.ghost` | `{}` | + +### placeholder.* + +| key | 주입 Private | +|---|---| +| `placeholder.sunken` | `{ shape: 'sm', motion: 'shimmer' }` | +| `placeholder.ghost` | `{ motion: 'pulse' }` | +| `placeholder.display` | `{ shape: 'sm', motion: 'shimmer' }` | + +## 5. `textStylePresetTable` 전수 + +모든 9개 textStyle(`hero` · `display` · `page` · `section` · `label` · `body` · `caption` · `code` · `overline`) 엔트리는 현재 `{}`. surface→text pairing은 CSS layer(§4c)가 SSOT이므로 textStyle은 Private 주입 없음. 향후 textStyle-별 padding 등 등록 여지로만 존재. + +## 6. 호출 경로 분기 + +``` +input: Axes (Public discriminated union) + │ + ├─ ax(axes) ──► 1) Private 키 오염 검사 (현 Phase 1-a G-5: warn) + │ 2) resolveRolePreset(role, surface, content, interactive) + │ └─ cascade: role → role.surface → role.surface.interactive + │ → role.surface.content + │ └─ all-miss + strictRole + surface → warn (장차 throw) + │ 3) resolveTextStylePreset(textStyle) — 현재 전부 {} + │ 4) merge: textPreset ← rolePreset ← input (구체 override) + │ 5) className 합성: prefix-value 공백 구분 + │ + └─ ax.raw(privateOnly) ──► Private 7축 직접 주입 (유일 경로) + └─ non-private 키 입력 시 dev throw +``` + +## 7. 합성 규칙 요약 + +- **override 순서**: `textStylePreset` (일반) → `rolePresetTable` hit (구체) → `input` (Public 명시) +- **className 포맷**: `{prefix}-{value}` 공백 구분 단일 문자열 +- **Public/Private 키 교집합 공집합** 불변식 (이름 충돌 금지) +- **`ax.raw()`는 Private만** — Public 입력 시 dev throw +- **`role` 키 부재** → `utility` 브랜치로 default brand (1,701 role-less 호출 보호) + +## 8. 참조 체인 (변경 시 동반 수정 지점) + +``` +axPublic.ts (타입 SSOT) + ├─► ax.ts prefixes (Public 12 엔트리) + ├─► rolePreset.ts RolePresetKey 템플릿 + └─► ui/ AriaComponentProps 타입 + +axPrivate.ts (타입 SSOT + AX_PRIVATE_KEYS) + ├─► ax.ts PRIVATE_KEY_SET (런타임 가드) + ├─► axRaw.ts PRIVATE_PREFIXES (Private 7 엔트리) + ├─► guardOsPatterns.mjs (정적 검사) + └─► scanOsViolations.mjs (감사 스캐너) + +rolePresetTable (조합 SSOT) + └─► "조합 변경은 이 파일 수정만으로 완결" (§1 불변식 #4) +``` + +## 9. 주의 사항 + +- **Public 축 신설 전에** subset 확장 · 테마 override · 기본값 주입 시도 (`feedback_axis_minimum_via_subset_expansion`) +- **Public 축에 원리 종속축 해치 금지** — 의도축(role/surface/cs)만, override는 `ax.raw()` 단일 해치 (`feedback_public_axis_no_hatch`) +- **축/값 이름은 디자인-중립** — 미감 지시어 금지 (`feedback_naming_design_neutral`) +- **Private 키 ui/pages 직접 import 금지** — `guardCssAxes` 정적 검사 +- **Phase 1-a G-5**: Bundle D/E 마이그레이션 중 throw → warn 완화 상태. Bundle E 완료 후 throw 재승격 예정 (§1 #7, #9) diff --git a/docs/2026/2026-04/2026-04-19/gmailMockup.md b/docs/2026/2026-04/2026-04-19/gmailMockup.md new file mode 100644 index 000000000..28fa8a0ba --- /dev/null +++ b/docs/2026/2026-04/2026-04-19/gmailMockup.md @@ -0,0 +1,128 @@ +--- +id: gmailMockup +type: decision +slug: gmailMockup +title: Gmail — Mockup +tags: [mockup, ux, fidelity-ladder, gmail] +created: 2026-04-19 +updated: 2026-04-19 +status: open +layer: design +phase: 5 +--- + +# Gmail Mockup + +Reference: http://localhost:5173/showcase/gmail (existing 14-defect implementation) +Goal: Climb the fidelity ladder (Data → Importance → Low-fi → Mid-fi → Hi-fi) so defects are caught at cheap fidelity tiers before implementation. + +## Phase 1 — Data Inventory + +- [x] `src/pages/__mockup__/gmail/schema.ts` — MailEntry / ThreadMessage / AttachmentEntry / FolderEntry +- [x] `src/pages/__mockup__/gmail/fixtures.ts` — 18 mail rows (3 edge-crafted + 15 faker seed=42), 11 folders, 3 thread messages, 2 attachments +- [x] Edge cases: `edge-max` (99ch subject, 3 labels), `edge-loaded` (busy row), `edge-min` (3-char sender) +- [x] State fixtures: populated / empty / loading (8 skeleton) / error message +- [x] `DataInspector.tsx` at `/__mockup__/gmail/data` +- [x] Screenshot: `screenshots/mockup-gmail-data.png` +- [ ] User approval + +### LLM self-review (Phase 1 screenshot) + +- **Observed measured ranges**: + - from: 3–22 ch (typical 14) + - subject: 2–99 ch (typical 43) + - preview: 2–170 ch (typical 106) + - labels: 0–3 chips, each 4–10 ch + - unread: 7/18 · starred: 10/18 · hasAttachment: 9/18 +- **Defects in DataInspector itself** (not the future Gmail UI): + - Example column heavily truncated ("E..", "A..", "1..") because flex:'1' share is too narrow at the current viewport. Data is still captured in fixtures, but Phase 2+ should not repeat this mistake. + - AppShell icon rail leaks into the mockup route. Future Phase 3–5 screens should register routes outside AppShell or apply a shell-clean fidelity theme. + +## Phase 2 — Importance Matrix + +| Field | 1st | 2nd | 3rd | hidden on list | +|---|---|---|---|---| +| from | ● | | | | +| subject | | ● | | | +| unread | | ● | | (state modifier) | +| preview | | | ● | | +| date | | | ● | | +| hasAttachment | | | ● | | +| labels | | | ● | | +| starred | | | ● | | +| important | | | ● | | +| id | | | | ● | +| email | | | | ● (detail only) | + +Reasons: 1st = `from` (fastest triage signal, Gmail/Superhuman/Missive de facto). 2nd = `subject` + `unread` (unread is orthogonal state elevating subject). 3rd = metadata. + +User approved: yes + +## Phase 3 — Low-fi Wireframe + +- [x] `Wireframe.tsx` + `Wireframe.module.css` (grayscale filter + monospace) +- [x] Route `/__mockup__/gmail/low` registered **outside AppShell** (shell-clean) +- [x] Screenshot: `screenshots/mockup-gmail-low.png` +- [x] LLM self-review (2 defects found + fixed in same turn) +- [ ] User approval + +### LLM self-review (Phase 3) + +**Initial defects found and fixed:** +- TopBar cluster rail-stuck left → fixed: search wrapped in `flex:'1' layout:'center'` → middle-aligned +- 3-pane body not filling viewport width → fixed: added `width: 'full'` to page + body row + +**Remaining structural observations (not defects, acceptance notes):** +- Vertical viewport-fill is unachievable with current ax axes (no height axis, no `100vh`). Wireframe renders at natural height; area below is empty black. Phase 4+ inherit the same limit unless we accept module.css `height: 100vh` as an explicit last-mile exception or extend ax with a `size` axis. +- Shell-clean route works: no activity-bar rail leaking into the mockup. +- Boxes show intended layout: TopBar (7 slots with middle search), Sidebar (MAILBOX + LABELS groups with unread counts), MailList (tabs + toolbar + 12 rows + pagination), Detail (toolbar + subject + from + thread collapse + body + attachments + actions). + +### Phase 3 findings to carry forward + +- Field widths in list row: sender `width:'sm'` fits 14ch comfortably; subject gets no fixed width and will compete with preview at mid-fi. **Decision at Phase 4**: subject should be `width:'md'` or explicit flex share so preview doesn't eat its space. +- Chip is single placeholder; actual data has 0–3 chips per row. **Check at Phase 4** whether chips overflow horizontally at the narrowest list width. +- Detail subject box is currently equal-weight with other boxes. **Phase 5 will size it via `textStyle: 'page'`** (decision carried from Phase 2 matrix). + +## Phase 4 — Mid-fi + +- Screenshot: `screenshots/mockup-gmail-mid.png` +- Real fixtures rendered (18 mails, 11 folders, 3 thread, 2 attachments) +- Findings: text contrast weak, edge-max subject compresses row, date wraps vertically → carried to Phase 5 + +## Phase 5 — Hi-fi + +- Screenshot: `screenshots/mockup-gmail-hi.png` +- Full ax axis applied per Importance Matrix +- Remaining defects: read-row text muted too faintly, detail toolbar fades mid-row, Reply-all/Forward ghost nearly invisible → Visual Contract below + +## Visual Contract + +### List row +- **sender-no-truncation**: `.list-row` sender span width='sm' flex='none' → sender up to 14ch typical renders without ellipsis +- **unread-contrast**: unread row surface='display', read row surface='ghost' → Δ background luminance ≥ WCAG 4.5:1 +- **chip-flex-none**: `Badge` elements in list row must have computed flex-shrink = 0 +- **chip-fits-content**: chip width = content width at 2–10 char labels, no truncation +- **date-fixed-width**: date span width='sm' flex='none', no line wrap +- **star-visible-filled**: `` when starred=true → computed opacity > 0.7 + +### Hierarchy (monotonic) +- fontSize(textStyle='page') > fontSize('label') ≥ fontSize('body') > fontSize('caption') +- caption color luminance < body color luminance (dim tone enforced) + +### Detail +- **subject-page-style**: detail subject uses textStyle='page' +- **attachments-cell**: each attachment wrapped in role='cell' surface='display' +- **reply-primary**: Reply uses surface='action' tone='accent'; Reply all/Forward use surface='ghost' +- **toolbar-uniform**: all 6 toolbar actions (Archive/Delete/Spam/Move/Labels/Snooze) have identical textStyle='caption' with equal contrast — no fade pattern + +### TopBar +- **search-centered**: search bar centered between logo cluster and actions cluster +- **search-width-lg**: search input container width='lg' + +### Sidebar +- **compose-fab**: Compose uses surface='action' tone='accent', full-width bar +- **unread-badge**: folder with unreadCount uses Badge tone='accent-dim' +- **selected-folder**: current folder uses surface='display' (distinct from ghost default) + +### Theme +- **light-theme-contrast**: body text on light theme must meet WCAG 4.5:1 (this run shows fail → theme token fix required before /do) 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/pages/__mockup__/gmail/DataInspector.module.css b/src/pages/__mockup__/gmail/DataInspector.module.css new file mode 100644 index 000000000..9a6f61ee7 --- /dev/null +++ b/src/pages/__mockup__/gmail/DataInspector.module.css @@ -0,0 +1,13 @@ +.table { + border-collapse: collapse; + width: 100%; +} + +.th { + text-align: left; + border-bottom: 2px solid var(--color-border-strong, #333); +} + +.tbody { + line-height: 1.8; +} diff --git a/src/pages/__mockup__/gmail/DataInspector.tsx b/src/pages/__mockup__/gmail/DataInspector.tsx new file mode 100644 index 000000000..5c2a0c735 --- /dev/null +++ b/src/pages/__mockup__/gmail/DataInspector.tsx @@ -0,0 +1,115 @@ +// DataInspector — Phase 1 artifact. +// Renders schema + fixtures as readable sections so user and LLM can look at +// the data inventory at a single glance before any UI design. + +import { ax } from '@styles/ax' +import { MAILS, FOLDERS, THREAD, ATTACHMENTS, LOADING_ROW_COUNT, ERROR_MESSAGE } from './fixtures' +import type { MailEntry } from './schema' + +function measure(values: string[]): { min: number; typical: number; max: number } { + const lens = values.map((v) => v.length).sort((a, b) => a - b) + return { + min: lens[0] ?? 0, + typical: lens[Math.floor(lens.length / 2)] ?? 0, + max: lens[lens.length - 1] ?? 0, + } +} + +function Row({ field, range, example }: { field: string; range: string; example: string }) { + return ( +
+ {field} + {range} + {example} +
+ ) +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ) +} + +export default function DataInspector() { + const fromRange = measure(MAILS.map((m) => m.from)) + const subjectRange = measure(MAILS.map((m) => m.subject)) + const previewRange = measure(MAILS.map((m) => m.preview)) + const labelCounts = MAILS.map((m) => m.labels.length) + const labelRange = { min: Math.min(...labelCounts), max: Math.max(...labelCounts) } + const labelChipLen = measure(MAILS.flatMap((m) => m.labels)) + + const unreadCount = MAILS.filter((m) => m.unread).length + const starredCount = MAILS.filter((m) => m.starred).length + const attachCount = MAILS.filter((m) => m.hasAttachment).length + + const example = MAILS[0] as MailEntry + + return ( +
+
+

Gmail mockup — Data Inventory

+

+ Phase 1 of /mockup. Every field that appears on screen, with measured ranges and example values. This is the data contract for Phases 2–5. +

+
+ +
+ + + + + + + + + + + + +
+ +
+
+ edge-max — Subject {MAILS[0]?.subject.length}ch, 3 labels, starred+unread+attach. Tests truncation and chip overflow. +
+
+ edge-loaded — Mid-range subject with 2 labels, all booleans on. Tests normal "busy" row. +
+
+ edge-min — 3-char sender, 2-char subject, no labels. Tests skeletal row min-height. +
+
+ +
+ + {FOLDERS.map((f) => ( + + ))} +
+ +
+ + {THREAD.map((m) => ( + + ))} +
+ +
+ {ATTACHMENTS.map((a) => ( +
{a.name} · {a.size} · {a.kind}
+ ))} +
+ +
+
populated — {MAILS.length} rows
+
empty — 0 rows
+
loading — {LOADING_ROW_COUNT} skeleton rows
+
error — message: "{ERROR_MESSAGE}"
+
+
+ ) +} diff --git a/src/pages/__mockup__/gmail/HifiWidgets.tsx b/src/pages/__mockup__/gmail/HifiWidgets.tsx new file mode 100644 index 000000000..6d1f45972 --- /dev/null +++ b/src/pages/__mockup__/gmail/HifiWidgets.tsx @@ -0,0 +1,243 @@ +// Phase 5 — Hi-fi widgets. Full ax axis applied: role/surface/textStyle/tone/interactive/content. +// Hierarchy from Phase 2 Importance Matrix: +// 1st gaze: sender → textStyle 'label' + width fixed +// 2nd gaze: subject + unread → unread row surface 'display' + textStyle 'label' +// 3rd gaze: preview/date/chip/attach → textStyle 'caption' + tone dim + +import { ax } from '@styles/ax' +import { Avatar } from '@os/ui/Avatar' +import { Badge } from '@os/ui/Badge' +import { FOLDERS, MAILS, THREAD, ATTACHMENTS } from './fixtures' +import type { MailEntry } from './schema' +import { + Menu, Mail, Search, Grid, Bell, Settings as SettingsIcon, + Pencil, Star, Paperclip, Square, + ChevronLeft, ChevronRight, ChevronDown, RotateCw, MoreHorizontal, +} from 'lucide-react' + +function FolderRow({ label, count, selected }: { label: string; count: number | null; selected?: boolean }) { + return ( +
+ {label} + {count != null && ( + {count} + )} +
+ ) +} + +function MailRow({ mail }: { mail: MailEntry }) { + return ( +
+ + + + {mail.from} + {mail.subject} + {mail.labels.map((l) => ( + {l} + ))} + — {mail.preview} + {mail.hasAttachment && } + {mail.date} +
+ ) +} + +export function TopBarHifi() { + return ( +
+
+ +
+
+ + Gmail +
+
+
+ + Search mail +
+
+
+
+ +
+
+ +
+
+ +
+ +
+
+ ) +} + +export function SidebarHifi() { + const systemFolders = FOLDERS.filter((f) => f.kind === 'system') + const labelFolders = FOLDERS.filter((f) => f.kind === 'label') + return ( +
+
+ + Compose +
+
Mailbox
+ {systemFolders.map((f) => ( + + ))} +
Labels
+ {labelFolders.map((f) => ( + + ))} +
+ ) +} + +export function MailListHifi() { + return ( +
+ {/* tabs */} +
+
Primary
+
Social
+
Promotions
+
+ {/* toolbar */} +
+
+
+ +
+
+ +
+
+ +
+
+
+ 1–{MAILS.length} of {MAILS.length} +
+ +
+
+ +
+
+
+ {/* rows */} +
+ {MAILS.map((m) => )} +
+
+ ) +} + +export function MailDetailHifi() { + const latest = THREAD[THREAD.length - 1]! + const earlierCount = THREAD.length - 1 + const subjectMail = MAILS[0]! + return ( +
+ {/* detail toolbar — ghost icon buttons */} +
+
+ {['Archive', 'Delete', 'Spam', 'Move', 'Labels', 'Snooze'].map((label) => ( +
{label}
+ ))} +
+
+
+ +
+
+ +
+
+
+ {/* subject header */} +
+ {subjectMail.subject} + + {subjectMail.labels.map((l) => ( + {l} + ))} +
+ {/* from row */} +
+ +
+
+ {latest.from} + <{latest.email}> +
+
+ to {latest.to} + +
+
+ {latest.date} +
+ {/* thread collapsed */} + {earlierCount > 0 && ( +
+ + + {earlierCount} earlier message{earlierCount === 1 ? '' : 's'} + +
+ )} + {/* body */} +
+ {latest.body.split('\n').map((line, i) => ( +

{line || '\u00A0'}

+ ))} +
+ {/* attachments */} + {ATTACHMENTS.length > 0 && ( +
+
{ATTACHMENTS.length} attachments
+
+ {ATTACHMENTS.map((a) => ( +
+ + {a.name} + {a.size} +
+ ))} +
+
+ )} + {/* actions */} +
+
Reply
+
Reply all
+
Forward
+
+
+ ) +} diff --git a/src/pages/__mockup__/gmail/MidfiWidgets.tsx b/src/pages/__mockup__/gmail/MidfiWidgets.tsx new file mode 100644 index 000000000..db20833de --- /dev/null +++ b/src/pages/__mockup__/gmail/MidfiWidgets.tsx @@ -0,0 +1,170 @@ +// Phase 4 widgets — Mid-fi. Real fixture data, basic typography, NO hierarchy yet. + +import { ax } from '@styles/ax' +import { Avatar } from '@os/ui/Avatar' +import { Badge } from '@os/ui/Badge' +import { FOLDERS, MAILS, THREAD, ATTACHMENTS } from './fixtures' +import type { MailEntry } from './schema' +import { + Menu, Mail, Search, Grid, Bell, Settings as SettingsIcon, + Pencil, Star, Paperclip, Square, + ChevronLeft, ChevronRight, ChevronDown, RotateCw, MoreHorizontal, +} from 'lucide-react' + +function FolderRow({ label, count }: { label: string; count: number | null }) { + return ( +
+ {label} + {count != null && {count}} +
+ ) +} + +function MailRow({ mail }: { mail: MailEntry }) { + return ( +
+ + + + {mail.from} + {mail.subject} + {mail.labels.map((l) => {l})} + — {mail.preview} + {mail.hasAttachment && } + {mail.date} +
+ ) +} + +export function TopBarMidfi() { + return ( +
+ + + Gmail +
+
+ + Search mail +
+
+ + + + +
+ ) +} + +export function SidebarMidfi() { + const systemFolders = FOLDERS.filter((f) => f.kind === 'system') + const labelFolders = FOLDERS.filter((f) => f.kind === 'label') + return ( +
+
+ + Compose +
+
Mailbox
+ {systemFolders.map((f) => )} +
Labels
+ {labelFolders.map((f) => )} +
+ ) +} + +export function MailListMidfi() { + return ( +
+
+ Primary + Social + Promotions +
+
+
+ + + +
+
+ 1–{MAILS.length} of {MAILS.length} + + +
+
+
+ {MAILS.map((m) => )} +
+
+ ) +} + +export function MailDetailMidfi() { + const latest = THREAD[THREAD.length - 1]! + const earlierCount = THREAD.length - 1 + const subjectMail = MAILS[0]! + return ( +
+
+ Archive + Delete + Spam + Move + Labels + Snooze + Forward all + Print +
+
+ {subjectMail.subject} + + {subjectMail.labels.map((l) => {l})} +
+
+ +
+
+ {latest.from} + <{latest.email}> +
+
+ to {latest.to} + +
+
+ {latest.date} +
+ {earlierCount > 0 && ( +
+ + {earlierCount} earlier message{earlierCount === 1 ? '' : 's'} +
+ )} +
+ {latest.body.split('\n').map((line, i) => ( +

{line || '\u00A0'}

+ ))} +
+ {ATTACHMENTS.length > 0 && ( +
+
{ATTACHMENTS.length} attachments
+
+ {ATTACHMENTS.map((a) => ( +
+ + {a.name} + {a.size} +
+ ))} +
+
+ )} +
+
Reply
+
Reply all
+
Forward
+
+
+ ) +} diff --git a/src/pages/__mockup__/gmail/PageHi.tsx b/src/pages/__mockup__/gmail/PageHi.tsx new file mode 100644 index 000000000..5224707fa --- /dev/null +++ b/src/pages/__mockup__/gmail/PageHi.tsx @@ -0,0 +1,31 @@ +import { ax } from '@styles/ax' +import { FlatLayout } from '@os/ui/FlatLayout' +import { createWidgetRegistry } from '@os/layout/widgetRegistry' +import { gmailMockupLayout } from './layout' +import { + TopBarHifi, + SidebarHifi, + MailListHifi, + MailDetailHifi, +} from './HifiWidgets' + +const registry = createWidgetRegistry({ + TopBar: TopBarHifi, + Sidebar: SidebarHifi, + MailList: MailListHifi, + MailDetail: MailDetailHifi, +}) + +export default function PageHi() { + return ( +
+ {}} + aria-label="Gmail mockup — hi fidelity" + /> +
+ ) +} diff --git a/src/pages/__mockup__/gmail/PageLow.module.css b/src/pages/__mockup__/gmail/PageLow.module.css new file mode 100644 index 000000000..08d9f994d --- /dev/null +++ b/src/pages/__mockup__/gmail/PageLow.module.css @@ -0,0 +1,10 @@ +/* Low-fi fidelity theme — only properties that have no ax axis. */ + +.page { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.regionLabel { + text-transform: uppercase; + letter-spacing: 0.08em; +} diff --git a/src/pages/__mockup__/gmail/PageLow.tsx b/src/pages/__mockup__/gmail/PageLow.tsx new file mode 100644 index 000000000..7ca1f106b --- /dev/null +++ b/src/pages/__mockup__/gmail/PageLow.tsx @@ -0,0 +1,35 @@ +// Phase 3 page — Low-fi grayscale wireframe. +// Uses FlatLayout + shared definePage (3-pane split). Grayscale filter at page root. + +import { ax } from '@styles/ax' +import { FlatLayout } from '@os/ui/FlatLayout' +import { createWidgetRegistry } from '@os/layout/widgetRegistry' +import styles from './PageLow.module.css' +import { gmailMockupLayout } from './layout' +import { + TopBarWireframe, + SidebarWireframe, + MailListWireframe, + MailDetailWireframe, +} from './WireframeWidgets' + +const registry = createWidgetRegistry({ + TopBar: TopBarWireframe, + Sidebar: SidebarWireframe, + MailList: MailListWireframe, + MailDetail: MailDetailWireframe, +}) + +export default function PageLow() { + return ( +
+ {}} + aria-label="Gmail mockup — low fidelity" + /> +
+ ) +} diff --git a/src/pages/__mockup__/gmail/PageMid.tsx b/src/pages/__mockup__/gmail/PageMid.tsx new file mode 100644 index 000000000..2882a28fe --- /dev/null +++ b/src/pages/__mockup__/gmail/PageMid.tsx @@ -0,0 +1,31 @@ +import { ax } from '@styles/ax' +import { FlatLayout } from '@os/ui/FlatLayout' +import { createWidgetRegistry } from '@os/layout/widgetRegistry' +import { gmailMockupLayout } from './layout' +import { + TopBarMidfi, + SidebarMidfi, + MailListMidfi, + MailDetailMidfi, +} from './MidfiWidgets' + +const registry = createWidgetRegistry({ + TopBar: TopBarMidfi, + Sidebar: SidebarMidfi, + MailList: MailListMidfi, + MailDetail: MailDetailMidfi, +}) + +export default function PageMid() { + return ( +
+ {}} + aria-label="Gmail mockup — mid fidelity" + /> +
+ ) +} diff --git a/src/pages/__mockup__/gmail/WireframeWidgets.tsx b/src/pages/__mockup__/gmail/WireframeWidgets.tsx new file mode 100644 index 000000000..60fc6b77e --- /dev/null +++ b/src/pages/__mockup__/gmail/WireframeWidgets.tsx @@ -0,0 +1,172 @@ +// Phase 3 widgets — Low-fi using ax-based MockupBar primitive. + +import { ax } from '@styles/ax' +import type { AxWidth } from '@styles/ax' +import { MockupBar } from '@os/ui/MockupBar' +import { FOLDERS, MAILS, THREAD, ATTACHMENTS } from './fixtures' +import type { MailEntry } from './schema' +import { + Menu, Mail, Search, Grid, Bell, Settings as SettingsIcon, + Pencil, Star, Paperclip, Square, +} from 'lucide-react' + +function senderWidth(len: number): AxWidth { + if (len < 10) return 'sm' + if (len < 16) return 'md' + return 'lg' +} + +function subjectWidth(len: number): AxWidth { + if (len < 15) return 'sm' + if (len < 40) return 'md' + if (len < 70) return 'lg' + return 'xl' +} + +function previewWidth(len: number): AxWidth { + if (len < 40) return 'sm' + if (len < 90) return 'md' + if (len < 130) return 'lg' + return 'xl' +} + +function RegionLabel({ children }: { children: React.ReactNode }) { + return
{children}
+} + +function FolderRow({ label, count }: { label: string; count: number | null }) { + return ( +
+ {label} + {count != null && {count}} +
+ ) +} + +function MailRow({ mail }: { mail: MailEntry }) { + return ( +
+ + + + + + {mail.labels.map((_, i) => )} +
+ +
+ {mail.hasAttachment && } + +
+ ) +} + +export function TopBarWireframe() { + return ( +
+ + + Gmail +
+ +
+ + + + + +
+ ) +} + +export function SidebarWireframe() { + const systemFolders = FOLDERS.filter((f) => f.kind === 'system') + const labelFolders = FOLDERS.filter((f) => f.kind === 'label') + return ( +
+
+ + Compose +
+ Mailbox + {systemFolders.map((f) => )} + Labels + {labelFolders.map((f) => )} +
+ ) +} + +export function MailListWireframe() { + return ( +
+
+ Primary + Social + Promotions +
+
+
+ + + +
+ 1–{MAILS.length} of {MAILS.length} +
+
+ {MAILS.map((m) => )} +
+
+ +
+
+ ) +} + +export function MailDetailWireframe() { + const latest = THREAD[THREAD.length - 1]! + const bodyLines = Math.min(3, Math.max(1, Math.ceil(latest.body.length / 80))) + const bodyWidths: AxWidth[] = ['xl', 'lg', 'md'] + return ( +
+
+ {['Archive', 'Delete', 'Spam', 'Move', 'Labels', 'Snooze', 'Forward'].map((label) => ( + {label} + ))} +
+
+ + + + +
+
+ +
+ +
+ +
+ {THREAD.length > 1 && ( +
+ {THREAD.length - 1} earlier messages +
+ )} +
+ {Array.from({ length: bodyLines }, (_, i) => ( + + ))} +
+ {ATTACHMENTS.length > 0 && ( +
+ + {ATTACHMENTS.map((a) => )} +
+ )} +
+ Reply + Reply all + Forward +
+
+ ) +} diff --git a/src/pages/__mockup__/gmail/fixtures.ts b/src/pages/__mockup__/gmail/fixtures.ts new file mode 100644 index 000000000..07e2fd4bf --- /dev/null +++ b/src/pages/__mockup__/gmail/fixtures.ts @@ -0,0 +1,135 @@ +import { faker } from '@faker-js/faker' +import type { MailEntry, ThreadMessage, AttachmentEntry, FolderEntry } from './schema' + +faker.seed(42) + +const LABEL_POOL = ['Work', 'Important', 'Personal', 'Priority-1', 'Draft', 'Team', 'Travel', 'Finance'] + +// ── Hand-crafted edge cases (3) ── +// These test boundary conditions that random faker output might miss. + +const EDGE_MIN: MailEntry = { + id: 'edge-min', + from: 'Ada', // 3ch — shorter than stated minimum + email: 'a@x.io', + subject: 'Hi', // 2ch + preview: 'ok', // 2ch + date: 'Apr 14', + starred: false, + unread: false, + hasAttachment: false, + labels: [], + important: false, +} + +const EDGE_MAX: MailEntry = { + id: 'edge-max', + from: 'Alexandra Chen-Goodwin', // 23ch — longer than stated max + email: 'alexandra.chen-goodwin@verylongcompanyname.co', + subject: 'Q2 Sprint Planning: OKR alignment, platform migration, and WCAG 2.2 AA accessibility audit findings', // 105ch + preview: 'Hi team, attached is the full proposal covering Q2 OKRs, the platform migration plan from v2 to v3, and our WCAG 2.2 AA audit with 47 findings across the product surface.', // 166ch + date: '12:43 PM', + starred: true, + unread: true, + hasAttachment: true, + labels: ['Work', 'Important', 'Priority-1'], // 3 chips, one at max length + important: true, +} + +const EDGE_LOADED: MailEntry = { + id: 'edge-loaded', + from: 'Bob Martinez', + email: 'bob@example.com', + subject: 'Design review feedback', + preview: 'Great work on the dashboard. A few notes on color and spacing.', + date: 'Apr 13', + starred: true, + unread: true, + hasAttachment: true, + labels: ['Work', 'Team'], + important: true, +} + +// ── Bulk via faker (15) ── +function makeRandomMail(i: number): MailEntry { + const firstName = faker.person.firstName() + const lastName = faker.person.lastName() + const from = `${firstName} ${lastName}` + return { + id: `m${i}`, + from, + email: faker.internet.email({ firstName, lastName }).toLowerCase(), + subject: faker.lorem.sentence({ min: 3, max: 10 }).replace(/\.$/, ''), + preview: faker.lorem.sentence({ min: 8, max: 18 }), + date: i < 8 ? faker.date.recent({ days: 7 }).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : faker.date.recent({ days: 1 }).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }), + starred: faker.datatype.boolean(0.3), + unread: faker.datatype.boolean(0.45), + hasAttachment: faker.datatype.boolean(0.35), + labels: faker.helpers.arrayElements(LABEL_POOL, { min: 0, max: 3 }), + important: faker.datatype.boolean(0.2), + } +} + +export const MAILS: MailEntry[] = [ + EDGE_MAX, + EDGE_LOADED, + ...Array.from({ length: 15 }, (_, i) => makeRandomMail(i)), + EDGE_MIN, // place at end — tests min-height behavior for empty-looking row +] + +// ── State fixtures ── + +export const MAILS_EMPTY: MailEntry[] = [] +export const MAILS_LOADING: MailEntry[] = [] // UI renders skeleton; data is irrelevant +export const LOADING_ROW_COUNT = 8 +export const ERROR_MESSAGE = "Can't load mail. Check your connection and retry." + +// ── Folders (sidebar) ── + +export const FOLDERS: FolderEntry[] = [ + { id: 'inbox', label: 'Inbox', unreadCount: 12, kind: 'system' }, + { id: 'starred', label: 'Starred', unreadCount: null, kind: 'system' }, + { id: 'snoozed', label: 'Snoozed', unreadCount: 2, kind: 'system' }, + { id: 'sent', label: 'Sent', unreadCount: null, kind: 'system' }, + { id: 'drafts', label: 'Drafts', unreadCount: 3, kind: 'system' }, + { id: 'all', label: 'All Mail', unreadCount: null, kind: 'system' }, + { id: 'spam', label: 'Spam', unreadCount: null, kind: 'system' }, + { id: 'trash', label: 'Trash', unreadCount: null, kind: 'system' }, + { id: 'work', label: 'Work', unreadCount: 5, kind: 'label' }, + { id: 'personal', label: 'Personal', unreadCount: null, kind: 'label' }, + { id: 'important', label: 'Important', unreadCount: 8, kind: 'label' }, +] + +// ── Thread (detail) ── + +export const THREAD: ThreadMessage[] = [ + { + id: 't1', + from: 'Alice Chen', + email: 'alice.chen@example.com', + to: 'Me, Bob', + date: 'April 14, 2026 at 10:23 AM', + body: `Hi team,\n\nI've drafted the Q2 sprint goals. Please review before tomorrow's sync.\n\n- API v3 migration\n- Dashboard launch\n- P95 latency under 100ms\n- WCAG 2.2 AA fixes`, + }, + { + id: 't2', + from: 'Bob Martinez', + email: 'bob@example.com', + to: 'Alice, Me', + date: 'April 14, 2026 at 11:02 AM', + body: '+1 on all points. Can we also add a bundle-size goal (-15%)?', + }, + { + id: 't3', + from: 'Me', + email: 'me@example.com', + to: 'Alice, Bob', + date: 'April 14, 2026 at 11:47 AM', + body: 'Agreed. See you at 2 PM.', + }, +] + +export const ATTACHMENTS: AttachmentEntry[] = [ + { id: 'a1', name: 'Q2-sprint-board.pdf', size: '2.4 MB', kind: 'pdf' }, + { id: 'a2', name: 'okr-draft-v2.docx', size: '186 KB', kind: 'doc' }, +] diff --git a/src/pages/__mockup__/gmail/layout.ts b/src/pages/__mockup__/gmail/layout.ts new file mode 100644 index 000000000..48696be2f --- /dev/null +++ b/src/pages/__mockup__/gmail/layout.ts @@ -0,0 +1,22 @@ +// Shared layout for Phase 3–5 of the gmail mockup. +// The split structure (topbar + 3-pane) is decided ONCE at Phase 3 and reused +// by Mid-fi and Hi-fi. Only the widget registry changes per fidelity. + +import { defineLayout } from '@os/layout/flatLayout' + +export const gmailMockupLayout = defineLayout({ + entities: { + root: { + data: { type: 'split', direction: 'vertical', sizes: [0.06, 'flex'], resizable: false }, + children: ['topbar', 'workspace'], + }, + topbar: { data: { type: 'widget', widget: 'TopBar' } }, + workspace: { + data: { type: 'split', direction: 'horizontal', sizes: [0.18, 0.35, 'flex'], resizable: true }, + children: ['sidebar', 'mail-list', 'mail-detail'], + }, + sidebar: { data: { type: 'widget', widget: 'Sidebar' } }, + 'mail-list': { data: { type: 'widget', widget: 'MailList' } }, + 'mail-detail': { data: { type: 'widget', widget: 'MailDetail' } }, + }, +}) diff --git a/src/pages/__mockup__/gmail/schema.ts b/src/pages/__mockup__/gmail/schema.ts new file mode 100644 index 000000000..d7eddc9b2 --- /dev/null +++ b/src/pages/__mockup__/gmail/schema.ts @@ -0,0 +1,55 @@ +// Gmail list/detail mockup — entity schema +// +// Ranges (measured from real Gmail-style corp inbox): +// from: 6–20 chars (typical 12) +// email: always present, domain varies +// subject: 10–80 chars (typical 30) +// preview: 30–120 chars, single-line clamp in list +// date: "Apr 14" or "12:43 PM" — always 7 chars +// labels: 0–3 chips, each 2–10 chars +// attachment: bool +// starred: bool +// unread: bool +// important: bool + +export interface MailEntry { + id: string + from: string + email: string + subject: string + preview: string + date: string + starred: boolean + unread: boolean + hasAttachment: boolean + labels: string[] + important: boolean +} + +// Thread message — for detail view (not list) +export interface ThreadMessage { + id: string + from: string + email: string + to: string + date: string + body: string +} + +export interface AttachmentEntry { + id: string + name: string + size: string + kind: 'pdf' | 'doc' | 'xls' | 'img' | 'zip' +} + +// Folder (sidebar nav) +export interface FolderEntry { + id: string + label: string + unreadCount: number | null // null = no badge + kind: 'system' | 'label' // system = Inbox/Sent/Spam; label = user-defined +} + +// List-level state fixtures +export type ListState = 'populated' | 'empty' | 'loading' | 'error' diff --git a/src/pages/showcase/gmail/gmailContext.ts b/src/pages/showcase/gmail/gmailContext.ts new file mode 100644 index 000000000..a5583cefe --- /dev/null +++ b/src/pages/showcase/gmail/gmailContext.ts @@ -0,0 +1,27 @@ +import { createDomainContext } from '@os/layout' + +export type GmailCategory = 'primary' | 'social' | 'promotions' + +export interface GmailContextValue { + selectedFolderId: string + selectedMailId: string | null + selectedMailIds: ReadonlySet + activeCategory: GmailCategory + page: number + composeOpen: boolean + commands: { + selectFolder: (id: string) => void + selectMail: (id: string) => void + toggleSelect: (id: string) => void + selectAll: (ids: string[]) => void + clearSelection: () => void + setCategory: (c: GmailCategory) => void + setPage: (n: number) => void + openCompose: () => void + closeCompose: () => void + gotoPrev: () => void + gotoNext: () => void + } +} + +export const [GmailProvider, useGmail] = createDomainContext('Gmail') From c3f8b80545109557921dd359bb829b6f6ff22abf 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: Mon, 20 Apr 2026 15:10:56 +0900 Subject: [PATCH 09/39] =?UTF-8?q?docs(prd):=20studio=20+=20cmux=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20PRD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - studioPrd: /a2ui + /playground → /studio (FlatLayout SSOT, A2UI 경계 어댑터) - cmuxPrd: /chat + /chat/entities + /cmux/preview → /cmux (URL query 뷰 분기, 분할 기본 fill=chat) Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/2026/2026-04/2026-04-20/cmuxPrd.md | 349 ++++++++++++++ docs/2026/2026-04/2026-04-20/studioPrd.md | 532 ++++++++++++++++++++++ 2 files changed, 881 insertions(+) create mode 100644 docs/2026/2026-04/2026-04-20/cmuxPrd.md create mode 100644 docs/2026/2026-04/2026-04-20/studioPrd.md diff --git a/docs/2026/2026-04/2026-04-20/cmuxPrd.md b/docs/2026/2026-04/2026-04-20/cmuxPrd.md new file mode 100644 index 000000000..e7d25c087 --- /dev/null +++ b/docs/2026/2026-04/2026-04-20/cmuxPrd.md @@ -0,0 +1,349 @@ +--- +name: cmuxPrd +type: prd +layer: pages +project: cmux +status: draft +date: 2026-04-20 +tags: [cmux, chat, flatlayout, integration] +--- + +# cmux 통합 — PRD + +> **Discussion**: routes doubt 세션 2026-04-20 (대화) +> **산출물 유형**: 페이지 통합 + 기본 fill 정책 변경 +> **규모 추정**: 신규 1, 수정 6, 재사용 다수, 삭제 2 + +## §0 요구사항 (from doubt) + +- 해결책 ⑪: `/chat` + `/chat/entities` + `/cmux/preview` → **`/cmux` 단일 라우트**로 통합. **모든 분할 패널의 기본 fill = chat UI (SurfaceLeaf)**. +- 제약 ⑦: + - 기존 websocket chat 기능 손실 없음 (PageAgentChat이 본체) + - preview 시나리오 시뮬레이션(`?scenario=`) + entities inspector 기능 유지 (URL 쿼리로 뷰 전환) + - cmux layout 자체(sidebar + tabgroup)는 이미 FlatLayout 기반 → 구조 변경 최소 +- 보유 자산 ⑧: + - `PageAgentChat` — cmux 삼계층(Workspace/Surface/SplitPane) 이미 FlatLayout로 구현 + - `SurfaceLeafWidget` — tab content widget, 이미 chat pane 렌더 + - `CmuxPreviewScenarios`, `cmuxPreviewWidgets` — 선언 → 픽셀 시뮬 인프라 + - `PageChatEntities` — schema/live/fixtures/commands TreeGrid inspector + - `layoutCommands.splitHere` — 분할 시 새 pane 생성 API + +## §1 책임 분해 + +| # | 책임 | 파일 경로 | 레이어 | 기존/신규 | 의존 | +|---|------|----------|-------|----------|------| +| 1 | cmux 기본 레이아웃 (sidebar + tabgroup) | `src/pages/chat/PageAgentChat.tsx`의 `chatBaseLayout` | pages | 재사용 | — | +| 2 | 분할 시 기본 content = chat 정책 | `src/interactive-os/layout/layoutCommands.ts` (`splitHere`) | layout | 수정 | — | +| 3 | preview 시나리오 loader (URL `?preview=`) | `src/pages/cmux/cmuxPreviewLoader.ts` | pages | 신규(from cmux-preview/) | — | +| 4 | entities inspector 패널 widget | `src/pages/cmux/EntitiesInspectorWidget.tsx` | pages | 수정(from PageChatEntities) | — | +| 5 | cmux 페이지 컴포넌트 (URL 기반 view 분기) | `src/pages/cmux/PageCmux.tsx` | pages | 수정(from PageAgentChat) | 1, 3, 4 | +| 6 | router 갱신 — `/cmux` 단일 + 구 라우트 redirect | `src/router.tsx` | app | 수정 | 5 | +| 7 | ActivityBar — `/cmux` 단일 항목 | `src/ActivityBar.tsx` | app | 수정 | 6 | +| 8 | 삭제 — `pages/cmux-preview/` (loader로 이동 후) | `src/pages/cmux-preview/**` | pages | 삭제 | 3 | +| 9 | 삭제 — `pages/chat/` 폴더명 `pages/cmux/`로 rename + PageChatEntities 제거 | `src/pages/chat/` → `src/pages/cmux/` | pages | 이동 | 5 | + +### 탐색 증거 + +- `Read("pages/chat/PageAgentChat.tsx")` → 이미 FlatLayout + cmux 구조 완비. "기본 fill = chat"의 75%는 이미 SurfaceLeafWidget의 기본 contentType='chat'로 성립. +- `Read("pages/cmux-preview/PageCmuxPreview.tsx")` → 시나리오 기반 FlatLayout provider wrapper, 10~20줄 수준. 흡수 비용 낮음. +- `Read("pages/chat/PageChatEntities.tsx")` → SplitPane + TreeGrid 4개로 조립. widget 전환 가능. +- `Grep("splitHere\|splitTab")` → `interactive-os/layout/layoutCommands.ts`에서 분할 로직 소유 (§1.2 수정 지점). +- `CATALOG.md`: cmux preview/entities inspector widget 없음 → §1.3, §1.4 신규/수정 정당. + +**완성도**: 🟢 + +## §2 Contract + +### `src/pages/cmux/cmuxPreviewLoader.ts` (신규, from cmux-preview) + +```ts +import type { NormalizedData } from '@os/store/types' +import type { CmuxPreviewContext } from '../cmux-preview/cmuxPreviewContext' + +export interface CmuxPreviewScenario { + id: string + label: string + page: NormalizedData + context: CmuxPreviewContext +} + +/** URL `?preview=` 파싱. id 없거나 잘못되면 null. */ +export function parsePreviewQuery(search: string): string | null + +/** id → scenario. 없으면 null. */ +export function getScenario(id: string | null): CmuxPreviewScenario | null +``` + +### `src/pages/cmux/EntitiesInspectorWidget.tsx` (수정 from PageChatEntities) + +```tsx +/** + * Entities inspector — widget 형태로 FlatLayout tab 안에서 렌더. + * PageChatEntities.tsx의 SplitPane+TreeGrid 4개 조립을 그대로 widget으로 포장. + * URL `?view=entities`일 때 cmux canvas의 tab content로 교체된다. + */ +export function EntitiesInspectorWidget(): JSX.Element +``` + +### `src/pages/cmux/PageCmux.tsx` (수정 from PageAgentChat) + +```tsx +/** + * cmux 통합 페이지. + * - 기본: chat workspace (기존 PageAgentChat 동작) + * - ?preview=: FlatLayout data/context를 scenario로 교체 (시뮬 모드) + * - ?view=entities: 초기 tab의 content widget을 EntitiesInspector로 교체 + * + * 세 모드 모두 동일 cmux 뼈대(sidebar + tabgroup) 사용. + */ +export default function PageCmux(): JSX.Element +``` + +### `src/interactive-os/layout/layoutCommands.ts` (수정) + +```ts +/** + * splitHere: 분할 시 새로 생기는 tab의 기본 contentType/widget 정책. + * @change — 이전: 빈 placeholder / 이후: contentType='chat', widget='SurfaceLeaf' + * @invariant 기본값은 registry에 SurfaceLeaf가 등록된 경우에만 적용. 없으면 빈 placeholder로 폴백. + */ +export function splitHere(ctx: LayoutCommandCtx, axis: 'horizontal' | 'vertical'): void +``` + +**완성도**: 🟢 + +## §3 WHY + +1. **/chat이 이미 cmux 그 자체.** `PageAgentChat`의 `chatBaseLayout`은 FlatLayout으로 cmux 삼계층을 완성했다. `/cmux/preview`는 그 구조의 시뮬 버전, `/chat/entities`는 그 store의 inspector 버전. **본체-실험-inspector라는 3 역할이 같은 앱의 뷰 모드일 뿐이다.** +2. **기본 fill = chat은 cmux의 정체성.** 사용자가 Mod+D로 분할했을 때 비어있는 placeholder가 뜨면 "다음 뭐 꽂지"를 고민하게 된다. 분할 = 새 대화 시작이라는 cmux 본연의 UX에 맞추려면 기본 fill이 chat이어야 한다. +3. **URL query로 뷰 분기**는 pages 파일 수를 최소화하면서도 각 모드의 북마크/공유를 유지한다. PageCmuxPreview의 `?scenario=` 패턴을 확장해 `?preview=` / `?view=entities`로 통일. + +## §4 HOW + +```mermaid +flowchart TD + U[/cmux URL] --> Q{query parse} + Q -->|?preview=x| P[load scenario x] + Q -->|?view=entities| E[tab content = EntitiesInspector] + Q -->|none| C[default chat layout] + P --> FL[FlatLayout] + E --> FL + C --> FL + FL --> SB[sidebar widget] + FL --> TG[tabgroup] + TG -->|default contentType=chat| SL[SurfaceLeaf widget = ChatPane] + TG -.split.-> TG2[new tab contentType=chat] +``` + +## §5 WHAT (의존 순서) + +### W1. splitHere 기본값 변경 (§1.2) + +**의존**: — +**파일**: `src/interactive-os/layout/layoutCommands.ts` + +분할 시 새로 생성되는 tab의 `contentType`/`contentRef` 기본값을 `'chat'` / `''`로, content widget을 `'SurfaceLeaf'`로 설정. registry에 `SurfaceLeaf`가 없을 때만 빈 placeholder로 폴백. + +```ts +const DEFAULT_SPLIT_CONTENT = { contentType: 'chat', widget: 'SurfaceLeaf' } as const + +function hasDefaultWidget(registry: WidgetRegistry | undefined): boolean { + return !!registry?.has?.(DEFAULT_SPLIT_CONTENT.widget) +} + +export function splitHere(ctx: LayoutCommandCtx, axis: 'horizontal' | 'vertical'): void { + // ... 기존 분할 로직 ... + const newTab = hasDefaultWidget(ctx.registry) + ? { type: 'tab', label: 'Chat', contentType: 'chat', contentRef: '' } + : { type: 'tab', label: 'Untitled', contentType: 'widget', contentRef: '' } + // ... insert newTab ... +} +``` + +**검증**: vitest — `splitHere({registry: {has: () => true}}, 'horizontal')` → 새 tab의 `contentType === 'chat'`. registry 없을 때 `'widget'`. + +### W2. cmuxPreviewLoader (§1.3) + +**의존**: — +**파일**: `src/pages/cmux/cmuxPreviewLoader.ts` + +```ts +import { getScenario as getRaw } from '../cmux-preview/cmuxPreviewScenarios' + +export function parsePreviewQuery(search: string): string | null { + return new URLSearchParams(search).get('preview') +} + +export function getScenario(id: string | null) { + return id ? getRaw(id) : null +} + +// 이 파일 확정 후 cmux-preview/ 내부 파일들을 pages/cmux/로 이동 +// (cmuxPreviewScenarios.ts, cmuxPreviewWidgets.tsx, cmuxPreviewContext.ts) +``` + +**검증**: `parsePreviewQuery('?preview=split')` → `'split'`. `getScenario(null)` → `null`. + +### W3. EntitiesInspectorWidget (§1.4) + +**의존**: — +**파일**: `src/pages/cmux/EntitiesInspectorWidget.tsx` + +PageChatEntities.tsx의 JSX를 그대로 widget 함수로 포장. export 방식만 변경. + +**검증**: screen test — `/cmux?view=entities` → Schema/Live/Commands TreeGrid 렌더. + +### W4. PageCmux (§1.5) + +**의존**: W1, W2, W3 +**파일**: `src/pages/cmux/PageCmux.tsx` + +```tsx +import { useMemo } from 'react' +import { useLocation } from 'react-router-dom' +import { FlatLayout } from '@os/ui/FlatLayout' +import { defineLayout } from '@os/layout/flatLayout' +import { createWidgetRegistry } from '@os/layout/widgetRegistry' +import { useActiveSession, useChatSessions } from './chatStore' +import { ChatProvider } from './chatContext' +import { WorkspaceSidebarWidget, SurfaceLeafWidget } from './chatWidgets' +import { ChatKeybindingsWidget } from './chatKeybindings' +import { parsePreviewQuery, getScenario } from './cmuxPreviewLoader' +import { EntitiesInspectorWidget } from './EntitiesInspectorWidget' +import { cmuxPreviewWidgets } from './cmuxPreviewWidgets' +import { CmuxPreviewProvider } from './cmuxPreviewContext' +import './PageAgentChat.css' + +const defaultCmuxLayout = defineLayout({ /* 기존 chatBaseLayout 그대로 */ }) + +function makeEntitiesLayout() { + return defineLayout({ + entities: { + ...defaultCmuxLayout.entities, + 't1-body': { data: { type: 'widget', widget: 'EntitiesInspector' } }, + }, + }) +} + +const cmuxWidgets = createWidgetRegistry({ + WorkspaceSidebar: WorkspaceSidebarWidget, + SurfaceLeaf: SurfaceLeafWidget, + EntitiesInspector: EntitiesInspectorWidget, +}) + +export default function PageCmux() { + const { search } = useLocation() + const previewId = parsePreviewQuery(search) + const view = new URLSearchParams(search).get('view') + + // Preview mode: scenario 기반 (chat store 무시) + if (previewId) { + const scenario = getScenario(previewId) + if (!scenario) return
Unknown scenario: {previewId}
+ return ( + + + + ) + } + + // Default / entities mode: 실제 chat store + const sessions = useChatSessions() + const activeSession = useActiveSession() + const chatCtx = useMemo(() => ({ + sessions, activeSessionId: activeSession?.id ?? null, + modifiedFiles: [], workspaces: [{ id: 'ws-1', label: 'Claude', status: 'idle' as const, unreadCount: 0 }], + activeWorkspaceId: 'ws-1', + }), [sessions, activeSession]) + + const layout = view === 'entities' ? makeEntitiesLayout() : defaultCmuxLayout + + return ( + + + + + + ) +} +``` + +**검증**: +- `/cmux` → 기존 `/chat` 동작 동일 +- `/cmux?view=entities` → 초기 tab이 EntitiesInspector +- `/cmux?preview=split` → scenario 렌더 +- Mod+D 분할 시 새 tab이 chat (SurfaceLeaf) + +### W5. router + redirect (§1.6) + +**의존**: W4 +**파일**: `src/router.tsx` + +```ts +{ path: '/cmux', lazy: () => import('./pages/cmux/PageCmux').then(m => ({ Component: m.default })) }, +// redirect for backward compat +{ path: '/chat', element: }, +{ path: '/chat/entities', element: }, +{ path: '/cmux/preview', element: }, +// /cmux/preview?scenario=x → /cmux?preview=x 는 redirect loader에서 처리하거나 수동 링크 갱신 +``` + +**검증**: 수동 — 구 URL 방문 시 redirect 작동. + +### W6. ActivityBar (§1.7) + +**의존**: W5 +**파일**: `src/ActivityBar.tsx` + +```ts +// 'chat' 항목의 path를 '/cmux'로 변경. id는 'cmux'로 rename 권장(navPaths 일치 유지). +{ id: 'cmux', label: 'cmux', icon: MessageSquare, path: '/cmux' }, +// 제거: cmux-preview (보조/진행중 섹션) +``` + +**검증**: 네비 클릭 → `/cmux` 진입. + +### W7. 파일 이동/삭제 (§1.8, §1.9) + +**의존**: W4, W5, W6 +**파일**: `git mv` + `rm -rf` + +1. `git mv src/pages/chat src/pages/cmux` +2. `git mv src/pages/cmux-preview/cmuxPreview*.ts{,x} src/pages/cmux/` +3. `rm src/pages/cmux-preview/PageCmuxPreview.tsx` → 디렉토리 빔 → `rmdir` +4. `rm src/pages/cmux/PageAgentChat.tsx` (PageCmux가 대체) +5. `rm src/pages/cmux/PageChatEntities.tsx` (EntitiesInspectorWidget이 대체) +6. PageAgentChat.css → `PageCmux.css`로 rename +7. 모든 `@/pages/chat/`, `@/pages/cmux-preview/` import 경로 일괄 치환 + +**검증**: `Grep("pages/chat|pages/cmux-preview")` 0건. typecheck pass. dev server에서 `/cmux`, `/cmux?view=entities`, `/cmux?preview=split` 모두 정상. + +## §6 원칙 감시자 결과 + +- ✅ 레이어 의존 순서: layout(W1) ← pages(W2~W4). 역방향 없음. +- ✅ 있는 걸로 먼저: chatBaseLayout, SurfaceLeafWidget, cmuxPreviewScenarios, PageChatEntities JSX 모두 재사용. +- ✅ 파일명 규칙: Page*, *Widget, *Loader — pages 관례 준수. +- ✅ FlatLayout 단일 레이아웃 엔진 사용, SplitPane 직접 조립 없음 (단 W3 이관 시 기존 SplitPane 사용은 위젯 내부라 수용 가능; 추후 FlatLayout sub-layout으로 리팩토링 후속). +- ⚠️ W1은 기존 sight unseen 구현을 확인해야 안전 — `layoutCommands.ts`의 실제 구조 확인 후 착수. +- ⚠️ W4 `useChatSessions`/`useActiveSession`을 preview 분기 이전에 호출하지 않도록 조건부 hook 순서 주의 (React rules of hooks). 필요 시 별도 컴포넌트로 split. + +--- + +**전체 완성도**: 🟢 (W1, W4의 주의점 해소 후 착수) + +## 착수 순서 요약 + +1. W1 `splitHere` 기본값 변경 (+ unit test) +2. W2 `cmuxPreviewLoader` 신설 +3. W3 `EntitiesInspectorWidget` 작성 (PageChatEntities 이관) +4. W4 `PageCmux` 작성 — hook 순서 안전하게 분기 +5. W7 파일 이동/삭제 (`git mv`) +6. W5 router redirect +7. W6 ActivityBar 갱신 +8. dev server 수동 3모드 검증 + typecheck + 커밋 + +## studio PRD와의 관계 + +- studio = **선언적 UI 런타임의 쇼케이스** (조립/스트리밍 example) +- cmux = **선언적 UI 런타임의 프로덕션 앱** (실사용 chat workspace) +- 둘 다 FlatLayout을 SSOT로 공유 → `useLayoutStream`(studio §1.2) 등의 primitives는 cmux에서도 재사용 가능 (예: AI가 대화 중 UI를 스트리밍으로 내려보내는 시나리오). diff --git a/docs/2026/2026-04/2026-04-20/studioPrd.md b/docs/2026/2026-04/2026-04-20/studioPrd.md new file mode 100644 index 000000000..9773ff1c1 --- /dev/null +++ b/docs/2026/2026-04/2026-04-20/studioPrd.md @@ -0,0 +1,532 @@ +--- +name: studioPrd +type: prd +layer: pages +project: studio +status: draft +date: 2026-04-20 +tags: [studio, flatlayout, a2ui, playground, streaming] +--- + +# Studio 통합 — PRD + +> **Discussion**: [routes doubt 세션 2026-04-20 — conversation, no separate discuss md] +> **산출물 유형**: 페이지 통합 + 경계 어댑터 정리 +> **규모 추정**: 신규 2, 수정 4, 재사용 3, 삭제 3 + +## §0 요구사항 (from doubt) + +- 해결책 ⑪: `/a2ui` + `/playground` → `/studio` 단일 라우트. **FlatLayout이 선언적 UI 런타임**이며, A2UI 스트리밍은 studio의 **example 카테고리 중 하나**로 편입한다. +- 제약 ⑦: + - 내부 데이터 SSOT는 `NormalizedData` (FlatLayout) 유일 + - A2UIPayload는 **경계 어댑터**로만 존재 (외부 Google A2UI v0.9 envelope 호환) + - 기존 기능(스트리밍 시뮬레이션, preset 카탈로그, JSON 에디터) 손실 없음 +- 보유 자산 ⑧: + - `a2uiToNormalized` 어댑터 이미 존재 (`ui/a2uiAdapter.ts`) + - `FlatLayout` + `flatLayoutRegistry` + `layoutCommands` (store patch API) + - `PagePlayground` = FlatLayout 기반 canvas + tabgroup + picker 이미 구현 + - `A2UISurface` — v0.9 components를 normalized로 변환 후 렌더 (내부에서 adapter 호출) + +## §1 책임 분해 + +| # | 책임 | 파일 경로 | 레이어 | 기존/신규 | 의존 | +|---|------|----------|-------|----------|------| +| 1 | A2UI envelope → NormalizedData 변환 | `src/interactive-os/ui/a2uiAdapter.ts` | ui | 재사용 | — | +| 2 | component-by-component 스트리밍 주입 hook | `src/interactive-os/primitives/useLayoutStream.ts` | primitives | 신규 | 1 | +| 3 | A2UI preset 카탈로그 (studio example 데이터) | `src/pages/studio/studioA2UIPresets.ts` | pages | 수정(from `pages/a2ui/a2uiPresets.ts`) | — | +| 4 | studio example 카탈로그 (layout 프리셋 + A2UI 스트리밍 프리셋 통합) | `src/pages/studio/studioExamples.ts` | pages | 신규 | 3 | +| 5 | studio 초기 레이아웃 (playground canvas + example sidebar) | `src/pages/studio/studioLayout.ts` | pages | 수정(from `playgroundDefaults.ts`) | 4 | +| 6 | studio 전용 widgets (ExampleSidebar, StreamControl, JsonInspector) | `src/pages/studio/studioWidgets.tsx` | pages | 신규 | 2, 4 | +| 7 | studio 페이지 컴포넌트 | `src/pages/studio/PageStudio.tsx` | pages | 수정(from `PagePlayground.tsx`) | 5, 6 | +| 8 | router 갱신 — `/studio` 추가, `/a2ui`·`/playground` 제거 | `src/router.tsx` | app | 수정 | 7 | +| 9 | ActivityBar 갱신 — `/studio` 단일 항목 | `src/ActivityBar.tsx` | app | 수정 | 8 | +| 10 | 삭제 — `pages/a2ui/` 폴더 전체 | `src/pages/a2ui/**` | pages | 삭제 | 3, 8 | +| 11 | 삭제 — `pages/playground/` 폴더 전체 (이전 후) | `src/pages/playground/**` | pages | 삭제 | 5, 6, 7 | +| 12 | A2UISurface 정리 판정 | `src/interactive-os/ui/A2UISurface.tsx` | ui | 유지(재사용) | 1 | + +### 탐색 증거 + +- `Glob("src/pages/a2ui/*")` → `PageA2UI.tsx`, `a2uiPresets.ts`, `PageA2UI.module.css` (3 파일) +- `Glob("src/pages/playground/*")` → 9 파일 (Page + defaults + widgets + keybindings + catalog + tools) +- `Glob("src/interactive-os/ui/A2UI*")` → `A2UISurface.tsx`, `A2UISurface.demo.tsx`, `a2uiAdapter.ts`, `a2uiProtocol.ts`, `a2uiFunctions.ts` +- `Read("a2uiAdapter.ts")` → `a2uiToNormalized(payload: A2UIPayload): NormalizedData` 이미 구현. α의 데이터 통합은 80% 완성 상태. +- `Read("PagePlayground.tsx")` → FlatLayout + flatLayoutRegistry + localStorage persistence 이미 구현. +- `Read("PageA2UI.tsx")` → `useComponentStream` hook이 컴포넌트 내부에 있어 재사용 불가 → primitives로 승격 필요(§1.2). +- `CATALOG.md`: FlatLayout 스트리밍 관련 primitives 없음 → `useLayoutStream` 신규 정당. + +**완성도**: 🟢 + +## §2 Contract + +### `src/interactive-os/primitives/useLayoutStream.ts` (신규) + +```ts +import type { NormalizedData } from '@os/store/types' + +export interface LayoutStreamState { + streaming: boolean + /** 0..100 */ + progress: number + /** 스트리밍 중 누적된 부분 상태. 완료/미시작 시 null. */ + partialData: NormalizedData | null +} + +export interface LayoutStreamControls { + start: (full: NormalizedData, order: string[]) => void + stop: () => void +} + +/** + * NormalizedData를 node 단위로 증분 주입하는 스트리밍 시뮬레이터. + * + * @param onUpdate 매 tick마다 부분 상태 콜백 (store.applyPatch 연결 지점) + * @param tickMs 기본 150 + jitter 200 + * @invariant order의 모든 id는 full.entities에 존재해야 함 + * @invariant stop() 호출 후에는 timer 잔존 없음 + */ +export function useLayoutStream( + onUpdate: (partial: NormalizedData) => void, + tickMs?: { base: number; jitter: number } +): LayoutStreamState & LayoutStreamControls +``` + +### `src/pages/studio/studioExamples.ts` (신규) + +```ts +import type { NormalizedData } from '@os/store/types' +import type { A2UIv09Envelope } from './studioA2UIPresets' + +export type StudioExampleKind = 'layout' | 'a2ui-stream' + +export interface StudioExample { + id: string + kind: StudioExampleKind + label: string + category: string + /** layout: 즉시 적용 스냅샷. a2ui-stream: envelope (스트리밍 변환). */ + data: NormalizedData | A2UIv09Envelope +} + +export const STUDIO_EXAMPLES: StudioExample[] = [ /* ... */ ] + +/** category 별 그룹핑. 시작 시 ExampleSidebar에서 소비. */ +export function groupExamples(xs: StudioExample[]): Record +``` + +### `src/pages/studio/studioWidgets.tsx` (신규) + +```tsx +import type { WidgetRegistry } from '@os/layout/widgetRegistry' + +/** + * studio 전용 widget registry. playgroundWidgets를 상속 확장한다. + * - ExampleSidebar: STUDIO_EXAMPLES를 listbox로 렌더, 선택 시 canvas에 적용/스트리밍 + * - StreamControl: 현재 선택된 a2ui example에 대해 Simulate/Stop 버튼 + * - JsonInspector: 현재 canvas의 NormalizedData를 read-only JSON으로 표시 + */ +export const studioWidgets: WidgetRegistry +``` + +### `src/pages/studio/studioLayout.ts` (수정 from playgroundDefaults) + +```ts +import { defineLayout } from '@os/layout/flatLayout' + +export const STUDIO_CANVAS_ID = 'studio-canvas' + +/** + * playground 초기 레이아웃 + 왼쪽 ExampleSidebar + 우측 상단 StreamControl. + * - root: split horizontal [sidebar, canvas] + * - sidebar: ExampleSidebar widget + * - canvas: tabgroup (기존 playground 구조 그대로) + */ +export const STUDIO_INITIAL: NormalizedData +``` + +### `src/pages/studio/PageStudio.tsx` (수정 from PagePlayground) + +```tsx +/** + * Studio — 선언적 FlatLayout 런타임의 조립/스트리밍 스튜디오. + * - 사용자 조립: cmux 분할 단축키 (Mod+D, Mod+Shift+D, Mod+T, Mod+W) + * - AI 스트리밍: A2UI envelope example 선택 → useLayoutStream → canvas에 증분 주입 + */ +export default function PageStudio(): JSX.Element +``` + +**완성도**: 🟢 + +## §3 WHY + +1. **FlatLayout이 이미 충분한 선언적 UI 런타임.** A2UI에만 별도 표면(`A2UISurface`) + 별도 페이지(`PageA2UI`)를 두는 건 축의 중복. 데이터 경로(`a2uiToNormalized`)는 이미 존재하므로 표면만 통합하면 본질이 드러난다. +2. **"사람 조립 vs AI 스트리밍"은 주체의 차이일 뿐 데이터 모델은 동일해야 한다.** 두 입력이 같은 NormalizedData로 수렴하면 혼합 시나리오(사람이 조립하다가 AI가 patch를 흘리는 경우)가 자연스럽게 표현 가능. +3. **책임 분해의 정당성**: §1.2 `useLayoutStream`이 현재 `PageA2UI` 안에 박혀있는 `useComponentStream`을 승격한 것. 이 승격이 없으면 studio 밖에서도 스트리밍이 필요할 때(예: chat 모듈의 Gen UI 블록) 중복 구현이 생긴다. + +## §4 HOW + +```mermaid +flowchart TD + U[User] -->|조립: Mod+D 분할| FL[FlatLayout canvas] + E[ExampleSidebar] -->|layout 선택| FL + E -->|a2ui-stream 선택| ADP[a2uiToNormalized] + ADP --> LS[useLayoutStream] + LS -->|tick| PATCH[NormalizedData patch] + PATCH --> FL + FL --> R[rendered widgets] +``` + +핵심: **모든 경로가 NormalizedData로 수렴**. A2UI는 `ADP` 어댑터로 경계 변환된 뒤 동일 경로로 진입. + +## §5 WHAT (의존 순서) + +### W1. useLayoutStream (§1.2) + +**의존**: a2uiAdapter (재사용) +**파일**: `src/interactive-os/primitives/useLayoutStream.ts` + +```ts +import { useState, useRef, useCallback, useEffect } from 'react' +import type { NormalizedData } from '@os/store/types' + +export interface LayoutStreamState { + streaming: boolean + progress: number + partialData: NormalizedData | null +} + +export function useLayoutStream( + onUpdate: (partial: NormalizedData) => void, + tickMs: { base: number; jitter: number } = { base: 150, jitter: 200 } +) { + const [state, setState] = useState({ streaming: false, progress: 0, partialData: null }) + const timerRef = useRef | null>(null) + const fullRef = useRef(null) + const orderRef = useRef([]) + const idxRef = useRef(0) + + const stop = useCallback(() => { + if (timerRef.current) clearTimeout(timerRef.current) + timerRef.current = null + setState(s => ({ ...s, streaming: false })) + }, []) + + const start = useCallback((full: NormalizedData, order: string[]) => { + stop() + fullRef.current = full + orderRef.current = order + idxRef.current = 0 + const seed: NormalizedData = { entities: {}, relationships: {} } + setState({ streaming: true, progress: 0, partialData: seed }) + onUpdate(seed) + + const tick = () => { + const full = fullRef.current! + const order = orderRef.current + const i = idxRef.current++ + const id = order[i] + const entities = { ...state.partialData!.entities, [id]: full.entities[id] } + const partial: NormalizedData = { entities, relationships: full.relationships } + const progress = Math.round(((i + 1) / order.length) * 100) + + onUpdate(partial) + + if (i + 1 < order.length) { + setState({ streaming: true, progress, partialData: partial }) + timerRef.current = setTimeout(tick, tickMs.base + Math.random() * tickMs.jitter) + } else { + setState({ streaming: false, progress: 100, partialData: partial }) + timerRef.current = null + } + } + + timerRef.current = setTimeout(tick, tickMs.base + Math.random() * tickMs.jitter) + }, [onUpdate, stop, tickMs.base, tickMs.jitter]) + + useEffect(() => () => stop(), [stop]) + + return { ...state, start, stop } +} +``` + +**검증**: vitest unit — `order=['a','b','c']` 주입 후 tick 3회 → `partialData.entities`에 a/b/c 순차 추가. `stop()` 후 `timerRef.current === null`. + +### W2. studioA2UIPresets (§1.3, rename) + +**의존**: — +**파일**: `src/pages/studio/studioA2UIPresets.ts` + +```ts +// pages/a2ui/a2uiPresets.ts 를 이동. export name 유지. +export type { A2UIv09Envelope } from './a2uiEnvelope' +export { categories } from './a2uiPresets' +``` + +**검증**: `git mv src/pages/a2ui/a2uiPresets.ts src/pages/studio/studioA2UIPresets.ts` + import 경로 치환. grep으로 모든 import 경로 갱신 확인. + +### W3. studioExamples (§1.4) + +**의존**: W2 +**파일**: `src/pages/studio/studioExamples.ts` + +```ts +import type { NormalizedData } from '@os/store/types' +import { categories, type A2UIv09Envelope } from './studioA2UIPresets' + +export type StudioExampleKind = 'layout' | 'a2ui-stream' + +export interface StudioExample { + id: string + kind: StudioExampleKind + label: string + category: string + data: NormalizedData | A2UIv09Envelope +} + +const a2uiExamples: StudioExample[] = categories.flatMap(cat => + Object.entries(cat.presets).map(([name, envelope]) => ({ + id: `a2ui-${cat.label}-${name}`, + kind: 'a2ui-stream' as const, + label: name, + category: `A2UI · ${cat.label}`, + data: envelope, + })) +) + +// 기존 playground layout preset (PLAYGROUND_INITIAL)도 하나의 example로 편입 +// 필요 시 layoutPresets.ts 신설해 여러 layout 추가 +export const STUDIO_EXAMPLES: StudioExample[] = [...a2uiExamples] + +export function groupExamples(xs: StudioExample[]): Record { + const out: Record = {} + for (const x of xs) (out[x.category] ??= []).push(x) + return out +} +``` + +**검증**: `STUDIO_EXAMPLES.length > 0` 및 각 항목의 `category` 유일성. vitest 1건. + +### W4. studioLayout (§1.5, from playgroundDefaults) + +**의존**: W3 +**파일**: `src/pages/studio/studioLayout.ts` + +```ts +import { defineLayout } from '@os/layout/flatLayout' +import { FOCUS_STATE_ID } from '@os/layout/layoutCommands' + +export const STUDIO_CANVAS_ID = 'studio-canvas' +export const PICKER_STATE_ID = '__picker' + +export const STUDIO_INITIAL = defineLayout({ + entities: { + root: { data: { type: 'split', direction: 'horizontal', sizes: ['240px', 'flex'], resizable: true }, children: ['sidebar', 'canvas'] }, + sidebar: { data: { type: 'widget', widget: 'ExampleSidebar' } }, + canvas: { data: { type: 'tabgroup', activeTabId: 't1' }, children: ['t1'] }, + t1: { data: { type: 'tab', label: 'Untitled', contentType: 'widget', contentRef: '' }, children: ['t1-body'] }, + 't1-body': { data: { type: 'widget', widget: 'PlaygroundSurface' } }, + 'stream-ctrl':{ data: { type: 'floating', anchor: 'float-top-end' }, children: ['stream-ctrl-w'] }, + 'stream-ctrl-w':{ data: { type: 'widget', widget: 'StreamControl' } }, + [FOCUS_STATE_ID]: { data: { type: 'state', focusedTabgroupId: 'canvas', focusedTabId: 't1' } }, + [PICKER_STATE_ID]: { data: { type: 'state', targetTabId: null } }, + }, +}) +``` + +**검증**: 브라우저 수동 — `/studio` 진입 시 sidebar + canvas 2분할 + 우측 상단 StreamControl 플로팅. + +### W5. studioWidgets (§1.6) + +**의존**: W1, W3, W4 +**파일**: `src/pages/studio/studioWidgets.tsx` + +```tsx +import { useCallback } from 'react' +import { createWidgetRegistry } from '@os/layout/widgetRegistry' +import { playgroundWidgets } from '../playground/playgroundWidgets' +import { ListBox } from '@os/ui/ListBox' +import { Button } from '@os/ui/Button' +import { ax } from '@styles/ax' +import { useLayoutStream } from '@os/primitives/useLayoutStream' +import { STUDIO_EXAMPLES, groupExamples, type StudioExample } from './studioExamples' +import { a2uiToNormalized } from '@os/ui/a2uiAdapter' +import { getFlatLayoutActions } from '@os/primitives/flatLayoutRegistry' +import { STUDIO_CANVAS_ID } from './studioLayout' + +function applyExample(ex: StudioExample) { + const actions = getFlatLayoutActions(STUDIO_CANVAS_ID) + if (!actions) return + if (ex.kind === 'layout') { + actions.setStore(ex.data as never) + } else { + const normalized = a2uiToNormalized({ components: (ex.data as any).updateComponents.components }) + actions.setStore(normalized) + } +} + +function ExampleSidebar() { + const groups = groupExamples(STUDIO_EXAMPLES) + return ( + + ) +} + +function StreamControl() { + /* useLayoutStream + 현재 선택된 a2ui example 추적 — studio context state로 관리 */ + return
+} + +export const studioWidgets = createWidgetRegistry({ + ...playgroundWidgets, + ExampleSidebar, + StreamControl, +}) +``` + +**검증**: `/studio`에서 sidebar 항목 클릭 → canvas 교체. a2ui-stream 항목은 StreamControl 활성화. + +### W6. PageStudio (§1.7) + +**의존**: W4, W5 +**파일**: `src/pages/studio/PageStudio.tsx` + +```tsx +// @useState-hatch +import { useEffect, useState } from 'react' +import { FlatLayout } from '@os/ui/FlatLayout' +import type { NormalizedData } from '@os/schema' +import { getFlatLayoutActions, subscribeFlatLayoutRegistry } from '@os/primitives/flatLayoutRegistry' +import { STUDIO_INITIAL, STUDIO_CANVAS_ID } from './studioLayout' +import { studioWidgets } from './studioWidgets' +import { PlaygroundKeybindingsWidget } from '../playground/playgroundKeybindings' +import { PickerRootWidget } from '../playground/playgroundWidgets' + +const STUDIO_LAYOUT_KEY = 'studio-layout' + +function load(): NormalizedData { + try { + const raw = localStorage.getItem(STUDIO_LAYOUT_KEY) + if (raw) { + const parsed = JSON.parse(raw) as NormalizedData + if (parsed?.entities && parsed?.relationships) return parsed + } + } catch { /* ignore */ } + return STUDIO_INITIAL +} + +export default function PageStudio() { + const [initialData] = useState(load) + + useEffect(() => { + let innerUnsub: (() => void) | null = null + let timer: ReturnType | null = null + const persist = () => { + const actions = getFlatLayoutActions(STUDIO_CANVAS_ID) + if (!actions) return + try { localStorage.setItem(STUDIO_LAYOUT_KEY, JSON.stringify(actions.getStore())) } + catch { /* quota */ } + } + const attach = () => { + if (innerUnsub) return + const actions = getFlatLayoutActions(STUDIO_CANVAS_ID) + if (!actions) return + innerUnsub = actions.subscribe(() => { + if (timer) return + timer = setTimeout(() => { timer = null; persist() }, 500) + }) + } + const unsubRegistry = subscribeFlatLayoutRegistry(attach) + attach() + return () => { unsubRegistry(); innerUnsub?.(); if (timer) clearTimeout(timer) } + }, []) + + return ( + + + + + ) +} +``` + +**검증**: screen test — `/studio` 진입 → ExampleSidebar 렌더 + canvas tabgroup 렌더 + Mod+D 분할 작동. + +### W7. router + ActivityBar (§1.8, §1.9) + +**의존**: W6 +**파일**: `src/router.tsx`, `src/ActivityBar.tsx` + +```ts +// router.tsx +{ path: '/studio', lazy: () => import('./pages/studio/PageStudio').then(m => ({ Component: m.default })) }, +// 제거: /a2ui, /playground +``` + +```ts +// ActivityBar.tsx — appNavItems +{ id: 'studio', label: 'Studio', icon: FlaskConical, path: '/studio' }, +// 제거: playground, a2ui +``` + +**검증**: dev server 수동 — `/studio` 정상 로드 + `/a2ui`, `/playground`은 `/` redirect. + +### W8. 삭제 (§1.10, §1.11) + +**의존**: W7 +**파일**: `src/pages/a2ui/`, `src/pages/playground/` (단 playground는 studio가 import하는 widgets/keybindings는 studio로 이동 후 삭제) + +순서: +1. `pages/playground/playgroundWidgets.tsx`, `playgroundKeybindings.tsx`, `playgroundCatalog.ts`, `parseFlatLayoutBlocks.ts`, `layoutTools.ts`, `playgroundChatWidgets.tsx`, `playgroundChatWidgets.module.css` → `pages/studio/`로 `git mv` +2. `pages/playground/PagePlayground.tsx`, `playgroundDefaults.ts` → 삭제 (PageStudio와 studioLayout이 대체) +3. `pages/a2ui/PageA2UI.tsx`, `PageA2UI.module.css` → 삭제 +4. `pages/a2ui/a2uiPresets.ts` → W2에서 이미 이동됨 + +**검증**: `Grep("pages/playground|pages/a2ui")` 0건. typecheck pass. + +### W9. A2UISurface 판정 (§1.12) + +**의존**: W8 +**조사 포인트**: `A2UISurface.tsx`는 내부에서 `a2uiToNormalized`를 호출한 뒤 어떤 표면으로 렌더하는가? +- 만약 **FlatLayout 내부 widget으로 감싸기만** 하면 → 폐기 후 `studioExamples.applyExample`에서 직접 `a2uiToNormalized` 호출 +- 만약 **별도 렌더 로직이 있다면** → 유지하되 studio에서는 사용하지 않음 (/chat 등 다른 소비자 있는지 확인) + +**검증**: `Grep("A2UISurface")` → 사용처 열거. 사용처가 `PageA2UI`뿐이면 삭제. 아니면 유지. + +**완성도**: 🟢 (단 W9는 조사 후 확정) + +## §6 원칙 감시자 결과 + +- ✅ 레이어 의존 순서: primitives(W1) ← pages(W3~W7). 역방향 없음. +- ✅ 있는 걸로 먼저: `a2uiAdapter`, `FlatLayout`, `flatLayoutRegistry`, `playgroundWidgets` 모두 재사용. +- ✅ 파일명 규칙: `use*`, `Page*`, `*Widgets.tsx`, `*Layout.ts`, `*Examples.ts` — pages 네이밍 관례 준수. +- ✅ ax() 사용, style={} 없음. +- ⚠️ W5 `applyExample`의 `as any` 타입 캐스트 1건 — envelope → payload 변환 지점. W2에서 envelope 타입 정리 시 제거 가능. +- ⚠️ W9 A2UISurface 판정은 조사 후 확정 — Placeholder 수준 아님, 실행 시 Grep 1회로 해결. + +--- + +**전체 완성도**: 🟢 (W9 조사 후 착수 가능) + +## 착수 순서 요약 + +1. W1 `useLayoutStream` primitives 신설 +2. W2 presets 이동 (`git mv`) +3. W3 `studioExamples` 작성 +4. W4 `studioLayout` 작성 +5. W5 `studioWidgets` 작성 +6. W6 `PageStudio` 작성 +7. W9 A2UISurface 사용처 조사 → 폐기 여부 확정 +8. W7 router + ActivityBar 갱신 +9. W8 기존 폴더 삭제 (`/a2ui`, `/playground`) +10. dev server 수동 검증 + typecheck + 커밋 From 2570914875d3822851deb183ac09d4d9d86de7cd 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: Mon, 20 Apr 2026 15:19:20 +0900 Subject: [PATCH 10/39] =?UTF-8?q?feat(studio):=20/a2ui=20+=20/playground?= =?UTF-8?q?=20=E2=86=92=20/studio=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useLayoutStream primitives 신설 (스트리밍 시뮬 승격) - studioExamples로 A2UI preset 편입 - FlatLayout SSOT, A2UIPayload는 경계 어댑터 - /a2ui, /playground 라우트 제거, ActivityBar 갱신 PRD: docs/2026/2026-04/2026-04-20/studioPrd.md Co-Authored-By: Claude Opus 4.7 (1M context) --- src/ActivityBar.tsx | 5 +- .../primitives/useLayoutStream.ts | 92 ++++++++ src/pages/a2ui/PageA2UI.module.css | 5 - src/pages/a2ui/PageA2UI.tsx | 221 ------------------ src/pages/playground/PagePlayground.tsx | 70 ------ src/pages/studio/PageStudio.tsx | 70 ++++++ .../{playground => studio}/layoutTools.ts | 2 +- .../parseFlatLayoutBlocks.ts | 0 .../playgroundCatalog.ts | 0 .../playgroundChatWidgets.module.css | 0 .../playgroundChatWidgets.tsx | 2 +- .../playgroundKeybindings.tsx | 2 +- .../playgroundWidgets.tsx | 2 +- .../studioA2UIPresets.ts} | 0 src/pages/studio/studioContext.tsx | 37 +++ src/pages/studio/studioExamples.ts | 34 +++ .../studioLayout.ts} | 22 +- src/pages/studio/studioWidgets.tsx | 150 ++++++++++++ src/router.tsx | 3 +- 19 files changed, 402 insertions(+), 315 deletions(-) create mode 100644 src/interactive-os/primitives/useLayoutStream.ts delete mode 100644 src/pages/a2ui/PageA2UI.module.css delete mode 100644 src/pages/a2ui/PageA2UI.tsx delete mode 100644 src/pages/playground/PagePlayground.tsx create mode 100644 src/pages/studio/PageStudio.tsx rename src/pages/{playground => studio}/layoutTools.ts (99%) rename src/pages/{playground => studio}/parseFlatLayoutBlocks.ts (100%) rename src/pages/{playground => studio}/playgroundCatalog.ts (100%) rename src/pages/{playground => studio}/playgroundChatWidgets.module.css (100%) rename src/pages/{playground => studio}/playgroundChatWidgets.tsx (98%) rename src/pages/{playground => studio}/playgroundKeybindings.tsx (98%) rename src/pages/{playground => studio}/playgroundWidgets.tsx (99%) rename src/pages/{a2ui/a2uiPresets.ts => studio/studioA2UIPresets.ts} (100%) create mode 100644 src/pages/studio/studioContext.tsx create mode 100644 src/pages/studio/studioExamples.ts rename src/pages/{playground/playgroundDefaults.ts => studio/studioLayout.ts} (60%) create mode 100644 src/pages/studio/studioWidgets.tsx diff --git a/src/ActivityBar.tsx b/src/ActivityBar.tsx index 6283c97b6..43c84a4e1 100644 --- a/src/ActivityBar.tsx +++ b/src/ActivityBar.tsx @@ -2,7 +2,7 @@ import { useCallback, useMemo, type HTMLAttributes } from 'react' import { useLocation, useNavigate } from 'react-router-dom' import { Sun, Moon, Layers, Presentation, Component, FolderCode, Palette, ShieldAlert, Languages, - MessageSquare, BookText, Play, Cable, PenLine, Kanban, GitBranch, + MessageSquare, BookText, Play, PenLine, Kanban, GitBranch, Mail, ListTree, Boxes, Braces, FileStack, TerminalSquare, BookMarked, Ruler, Compass, ListChecks, FlaskConical, } from 'lucide-react' @@ -80,13 +80,12 @@ const appNavItems: NavItem[] = [ { id: 'incident', label: 'Incident', icon: ShieldAlert, path: '/incident' }, { id: 'theme-creator', label: 'Theme', icon: Palette, path: '/internals/theme' }, { id: 'stories', label: 'Stories', icon: BookMarked, path: '/stories' }, - { id: 'playground', label: 'Playground', icon: FlaskConical, path: '/playground' }, + { id: 'studio', label: 'Studio', icon: FlaskConical, path: '/studio' }, { id: 'todo', label: 'Todo', icon: ListChecks, path: '/todo' }, { id: 'cmux-preview', label: 'cmux Preview', icon: TerminalSquare, path: '/cmux/preview' }, { id: 'keyline-test', label: 'Keyline Test', icon: Ruler, path: '/test/keyline' }, // --- 미완성 / 데모 미생성 --- { id: 'replay', label: 'Replay', icon: Play, path: '/replay' }, - { id: 'a2ui', label: 'A2UI', icon: Cable, path: '/a2ui' }, { id: 'writer', label: 'Writer', icon: PenLine, path: '/writer' }, ] diff --git a/src/interactive-os/primitives/useLayoutStream.ts b/src/interactive-os/primitives/useLayoutStream.ts new file mode 100644 index 000000000..c640f4edb --- /dev/null +++ b/src/interactive-os/primitives/useLayoutStream.ts @@ -0,0 +1,92 @@ +// useLayoutStream — NormalizedData를 node 단위로 증분 주입하는 스트리밍 시뮬레이터. +// PageA2UI의 useComponentStream을 primitives로 승격. A2UI 뿐 아니라 FlatLayout 스트리밍 전반에 재사용. +// @useState-hatch — 내부 ref로 tick 상태 관리. +import { useState, useRef, useCallback, useEffect } from 'react' +import type { NormalizedData } from '@os/store/types' + +export interface LayoutStreamState { + streaming: boolean + /** 0..100 */ + progress: number + /** 스트리밍 중 누적된 부분 상태. 완료/미시작 시 null. */ + partialData: NormalizedData | null +} + +export interface LayoutStreamControls { + start: (full: NormalizedData, order: string[]) => void + stop: () => void +} + +export interface LayoutStreamTick { + base: number + jitter: number +} + +/** + * NormalizedData를 node 단위로 증분 주입. + * + * @param onUpdate 매 tick마다 부분 상태 콜백 (store.applyPatch 연결 지점) + * @param tickMs 기본 {base: 150, jitter: 200} + * @invariant order의 모든 id는 full.entities에 존재해야 함 + * @invariant stop() 호출 후에는 timer 잔존 없음 + */ +export function useLayoutStream( + onUpdate: (partial: NormalizedData) => void, + tickMs: LayoutStreamTick = { base: 150, jitter: 200 }, +): LayoutStreamState & LayoutStreamControls { + const [state, setState] = useState({ streaming: false, progress: 0, partialData: null }) + const timerRef = useRef | null>(null) + const fullRef = useRef(null) + const orderRef = useRef([]) + const idxRef = useRef(0) + const accumRef = useRef(null) + + const stop = useCallback(() => { + if (timerRef.current) clearTimeout(timerRef.current) + timerRef.current = null + setState(s => ({ ...s, streaming: false })) + }, []) + + const start = useCallback((full: NormalizedData, order: string[]) => { + if (timerRef.current) clearTimeout(timerRef.current) + timerRef.current = null + fullRef.current = full + orderRef.current = order + idxRef.current = 0 + const seed: NormalizedData = { entities: {}, relationships: {}, ...(full.slots ? { slots: {} } : {}) } + accumRef.current = seed + setState({ streaming: true, progress: 0, partialData: seed }) + onUpdate(seed) + + const tick = () => { + const f = fullRef.current + const ord = orderRef.current + const acc = accumRef.current + if (!f || !acc) return + const i = idxRef.current++ + const id = ord[i] + const next: NormalizedData = { + entities: { ...acc.entities, [id]: f.entities[id] }, + relationships: f.relationships, + ...(f.slots ? { slots: f.slots } : {}), + } + accumRef.current = next + const progress = Math.round(((i + 1) / ord.length) * 100) + onUpdate(next) + + if (i + 1 < ord.length) { + setState({ streaming: true, progress, partialData: next }) + timerRef.current = setTimeout(tick, tickMs.base + Math.random() * tickMs.jitter) + } else { + setState({ streaming: false, progress: 100, partialData: next }) + timerRef.current = null + } + } + + timerRef.current = setTimeout(tick, tickMs.base + Math.random() * tickMs.jitter) + }, [onUpdate, tickMs.base, tickMs.jitter]) + + useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); timerRef.current = null }, []) + + return { ...state, start, stop } +} diff --git a/src/pages/a2ui/PageA2UI.module.css b/src/pages/a2ui/PageA2UI.module.css deleted file mode 100644 index e0c25a318..000000000 --- a/src/pages/a2ui/PageA2UI.module.css +++ /dev/null @@ -1,5 +0,0 @@ -.editor { - resize: none; - border: none; - outline: none; -} diff --git a/src/pages/a2ui/PageA2UI.tsx b/src/pages/a2ui/PageA2UI.tsx deleted file mode 100644 index 5a7dab2c0..000000000 --- a/src/pages/a2ui/PageA2UI.tsx +++ /dev/null @@ -1,221 +0,0 @@ -// @useState-hatch -import { useState, useMemo, useRef, useCallback } from 'react' -import { ax } from '@styles/ax' -import { Button } from '@os/ui/Button' -import { A2UISurface } from '@os/ui/A2UISurface' -import type { A2UIPayload } from '@os/ui/a2uiAdapter' -import { categories } from './a2uiPresets' -import type { A2UIv09Envelope } from './a2uiPresets' -import styles from './PageA2UI.module.css' - -// ── Envelope → A2UIPayload adapter ── - -function envelopeToPayload(json: string): { payload: A2UIPayload | null; error: string | null } { - try { - const parsed = JSON.parse(json) - const components = parsed?.updateComponents?.components ?? parsed?.components - if (!Array.isArray(components)) { - return { payload: null, error: 'Missing components array' } - } - const dataModel = parsed?.dataModel ?? parsed?.updateComponents?.dataModel - return { payload: { components, ...(dataModel ? { dataModel } : {}) }, error: null } - } catch (e) { - return { payload: null, error: (e as Error).message } - } -} - -// ── Stream simulation (component-by-component) — @useState-hatch ── - -interface StreamState { - streaming: boolean - progress: number - partialPayload: A2UIPayload | null -} - -function useComponentStream(onJsonUpdate: (text: string) => void) { - // @useState-hatch - const [state, setState] = useState({ streaming: false, progress: 0, partialPayload: null }) - const timerRef = useRef | null>(null) - const componentsRef = useRef([]) - const indexRef = useRef(0) - - const stop = useCallback(() => { - if (timerRef.current) clearTimeout(timerRef.current) - timerRef.current = null - setState(s => ({ ...s, streaming: false })) - }, []) - - const start = useCallback((envelope: A2UIv09Envelope) => { - stop() - const allComponents = envelope.updateComponents.components - componentsRef.current = [] - indexRef.current = 0 - setState({ streaming: true, progress: 0, partialPayload: null }) - onJsonUpdate('') - - function tick() { - const comp = allComponents[indexRef.current] - indexRef.current++ - componentsRef.current = [...componentsRef.current, comp] - - const partialEnvelope: A2UIv09Envelope = { - ...envelope, - updateComponents: { ...envelope.updateComponents, components: componentsRef.current }, - } - const progress = Math.round((indexRef.current / allComponents.length) * 100) - onJsonUpdate(JSON.stringify(partialEnvelope, null, 2)) - - const partialPayload: A2UIPayload = { - components: componentsRef.current, - ...(envelope.dataModel ? { dataModel: envelope.dataModel } : {}), - } - - if (indexRef.current < allComponents.length) { - setState({ streaming: true, progress, partialPayload }) - timerRef.current = setTimeout(tick, 150 + Math.random() * 200) - } else { - setState({ streaming: false, progress: 100, partialPayload }) - } - } - - timerRef.current = setTimeout(tick, 300) - }, [onJsonUpdate, stop]) - - return { ...state, start, stop } -} - -// ── Page ── - -export default function PageA2UI() { - // @useState-hatch - const [catIndex, setCatIndex] = useState(0) - const [presetKey, setPresetKey] = useState(() => Object.keys(categories[0].presets)[0]) - const [jsonText, setJsonText] = useState(() => JSON.stringify(Object.values(categories[0].presets)[0], null, 2)) - - const { payload: editPayload, error } = useMemo(() => envelopeToPayload(jsonText), [jsonText]) - - const setJsonTextCb = useCallback((text: string) => setJsonText(text), []) - const { streaming, progress, partialPayload, start: startStream, stop: stopStream } = useComponentStream(setJsonTextCb) - - const payload = streaming ? partialPayload : editPayload - const cat = categories[catIndex] - const presetNames = Object.keys(cat.presets) - - function selectCategory(i: number) { - if (streaming) stopStream() - setCatIndex(i) - const firstKey = Object.keys(categories[i].presets)[0] - setPresetKey(firstKey) - setJsonText(JSON.stringify(categories[i].presets[firstKey], null, 2)) - } - - function selectPreset(key: string) { - if (streaming) stopStream() - setPresetKey(key) - setJsonText(JSON.stringify(cat.presets[key], null, 2)) - } - - function simulateStream() { - startStream(cat.presets[presetKey]) - } - - return ( -
- {/* Header */} -
-

A2UI Playground

-

- Google A2UI v0.9 — 15 components, keyboard/ARIA accessible, streaming progressive render -

-
- - {/* Category tabs */} -
- {categories.map((c, i) => ( - - ))} -
- - {/* Preset selector within category + stream button */} -
- {presetNames.map((name) => ( - - ))} - - {streaming ? ( - - ) : ( - - )} -
- - {/* Split pane: editor | preview */} -
- {/* JSON Editor */} -
-
- A2UI v0.9 Envelope - {streaming && ( - - Streaming... {progress}% - - )} - {!streaming && error && ( - - {error} - - )} -
-