diff --git a/docs/2026/2026-04/2026-04-21/explainVisionMarketplaceEcosystem.md b/docs/2026/2026-04/2026-04-21/explainVisionMarketplaceEcosystem.md new file mode 100644 index 000000000..8111a0b6e --- /dev/null +++ b/docs/2026/2026-04/2026-04-21/explainVisionMarketplaceEcosystem.md @@ -0,0 +1,103 @@ +--- +type: explain +tags: [explain, vision, marketplace, defineFeature, defineApp, ecosystem, roadmap] +date: 2026-04-21 +--- + +# Vision — 인지 인터페이스의 창작자 경제 + +> 작성일: 2026-04-21 +> 맥락: 기존 vision.md(엔진 관점)에 **마켓 레이어**를 한 층 더 얹어, 어제 도입된 `defineFeature`/`defineApp`을 생태계의 원시(原始) 단위로 재해석한다. + +> - 변하지 않는 것(키보드·ARIA)은 **엔진이 보장**, 장르(slack·CMS·kanban)는 **baseline이 제공**, 모듈은 **누구나 만들어 팔고 조립**한다 +> - 잘 팔린 모듈은 장르 baseline으로 승격되어 지식이 역류한다 — "쌓이지 대체되지 않는다"의 구체적 메커니즘 +> - 어제 PR #7의 `defineFeature`가 마켓 층의 첫 원시 단위. 지금 4-17~21의 모든 작업은 "팔 만한 물건이 실제로 팔 만하다"를 경화하는 공급자 사전공사 +> - **즉답: 지향점은 vibe-coding SaaS가 아니라, 공급·수요·표준화가 한 루프로 도는 창작자 경제다** + +## 1. 3층 변화 속도 모델 + +마켓이 성립하려면 "아래 층은 안 바뀐다"는 보장이 필요하다. 키보드 QWERTY가 100년 유지된 덕에 키캡 시장이 도는 것과 같다. + +``` +┌─────────────────────────────────────────────────────────────┐ +│ ③ MODULE (빠르게 변함, 개인·회사가 만듦) │ ← 마켓 거래 단위 +│ "이 Finder의 Sort 모듈" · "이 CMS의 번역 패널" │ +│ = defineFeature 한 단위 │ +│ ↕ 조립 │ +├─────────────────────────────────────────────────────────────┤ +│ ② GENRE (천천히 변함, 합의되면 유지) │ ← 장르 템플릿 +│ chat · CMS · kanban · file-explorer · spreadsheet │ +│ = defineApp baseline │ +│ ↕ 구현 │ +├─────────────────────────────────────────────────────────────┤ +│ ① PRIMITIVE (안 변함, 인류 합의) │ ← 엔진 보장 +│ 키보드 · ARIA 7축 · undo · focus · selection │ +│ = Axes + Patterns + Plugins │ +└─────────────────────────────────────────────────────────────┘ +``` + +## 2. 선순환 루프 + +```mermaid +flowchart TD + A[① 엔진 성숙
키보드·ARIA·undo 공짜] --> B[② 장르 baseline 공개
Finder · Chat · CMS] + B --> C[③ LLM + 개인이
내 니즈의 모듈 vibe-coding] + C --> D[④ 마켓 등록·판매
defineFeature 단위] + D --> E[⑤ 다른 사용자가
baseline + 모듈 조립] + E --> F[⑥ 조립된 앱에서
또 이런 거 수요 발생] + F --> C + D -.잘 팔리는 모듈.-> G[⑦ 장르 baseline에 흡수] + G --> B +``` + +⑦이 핵심이다. Slack의 threads가 모든 채팅앱 기본이 된 것처럼, 잘 팔린 모듈은 장르의 기본값으로 승격되어 ③→②→①로 지식이 역류한다. + +## 3. 마켓 성립 조건 — 현재 진척도 + +| 조건 | 왜 필요 | 상태 | +|---|---|---| +| A. 모듈 단위 정의 | 팔 물건이 정의돼야 거래 | 🟢 `defineFeature`/`defineApp` 어제 도입 | +| B. 조립/해제 런타임 | 사서 바로 끼울 수 있어야 | 🟡 Settings 체크박스 UX 증명. keymap 실런타임 통합 미완 | +| C. 모듈 간 호환 표준 | 다른 사람 모듈끼리 안 싸워야 | 🟡 7 기여 타입 선언. 런타임 소비 일부만 | +| D. 모듈 생성 비용 0 | 공급이 생기려면 | 🔴 A2UI 선언 파이프라인 PoC(4-19)·LLM DX 풀세트 아직 | +| E. 디자인 일관성 자동 | 제각각이면 상품성 X | 🟡 ax() 24축 경화 집중(4-17~19) | +| F. 장르 baseline 카탈로그 | 어느 장르부터 열 것인가 | 🟡 Finder 완성. chat/CMS/kanban 쇼케이스 단계 | +| G. 배포·버저닝·샌드박싱 | 실제 상업 거래 | 🔴 | +| H. 수익 분배·권리 | 선순환의 금전적 동기 | 🔴 | + +지금 4-17~21 PRD의 대부분이 **A~E**에 모여 있다. 마켓 인프라(G·H)를 열기 전에 "공급을 0원에 찍어내는 엔진"을 먼저 경화하는 순서. 공급 없는 마켓은 안 돈다. + +## 4. 최종 지향점 (수정판) + +기존 vision의 ⑦(vibe-coding SaaS)은 **혼자 쓰는 도구**였다. 마켓 층을 받으면 한 칸 더 올라간다: + +> **⑧' 인지 인터페이스의 창작자 경제** — +> 엔진이 "변하지 않는 것"을 보장하고, +> 장르가 "이미 합의된 것"을 제공하고, +> 누구나 "자기 필요"를 모듈로 만들어 팔고, +> 누구나 사서 조립하며, +> 잘 팔린 모듈은 장르 기본값으로 승격되어 다음 세대가 그 위에서 다시 시작하는, +> **공급·수요·표준화가 한 루프로 돌아가는 생태계** + +이 지향점에서 기존 축들을 재해석하면: + +- **FE의 가치 재정의**: 조작 장인 → 블록 설계자 → 블록 판매자/구매자 +- **vibe-coding SaaS**: 혼자 쓰는 도구 ❌ → 공급자·수요자가 만나는 시장 ⭕ +- **A2UI protocol**: LLM용 입력 형식 → **유통 가능한 디지털 상품의 표준 규격** (JSON이라 샌드박싱·버저닝·diff·라이선스 부착 가능) +- **ARIA 준수**: 접근성 규제 대응 → **상품 품질 보증 라벨** (Unity "URP 호환"처럼) +- **렌더러 독립**: 기술적 확장 → **한 번 만든 모듈이 웹·앱·TUI에서 다 팔림** (market size × renderer count) + +## 5. 루프를 닫으려면 비어 있는 것 + +A~E(팔 만한 물건) 다음 단계로 빠져 있는 것: + +1. **모듈 manifest**: author, dependencies, compatible versions — `defineFeature`에 메타 필드 +2. **Registry/배포**: npm(공개) vs Shopify(큐레이션) vs Figma(둘 다) 중 결정 +3. **샌드박싱**: 남의 모듈이 내 데이터를 못 건드리게 — Plugin 권한 축 신설 가능성 +4. **버저닝·호환성**: ①은 안 변해야 성립하지만 ②③은 변함 — semver + A2UI schema version +5. **발견·큐레이션 UX**: 마켓 자체가 interactive-os로 만든 앱이어야 함 (메타 dogfooding) +6. **수익 구조**: 무료/유료/구독/수수료 — Primitive 단의 게이팅 훅 필요 여부 + +## Insight — 마켓은 공급이 없으면 안 돌아간다 + +네 비전은 기존 수직 로드맵의 **지붕이 아니라 그 위에 한 층 더 있는 마켓 층**이다. 그리고 어제 `defineFeature`가 들어가면서 이 마켓 층의 **첫 원시 단위가 이미 정의**됐다. 지금 당장 마켓을 열지는 않더라도, "잘 팔릴 만한 물건을 먼저 만들 수 있는 도구"부터 경화하는 게 지금 4-17~21 전 작업이 수렴하는 이유로 해석된다. 공급을 0원에 찍어내는 엔진부터 닫는 것이 순서다. 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