Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions docs/2026/2026-04/2026-04-21/explainVisionMarketplaceEcosystem.md
Original file line number Diff line number Diff line change
@@ -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[① 엔진 성숙<br/>키보드·ARIA·undo 공짜] --> B[② 장르 baseline 공개<br/>Finder · Chat · CMS]
B --> C[③ LLM + 개인이<br/>내 니즈의 모듈 vibe-coding]
C --> D[④ 마켓 등록·판매<br/>defineFeature 단위]
D --> E[⑤ 다른 사용자가<br/>baseline + 모듈 조립]
E --> F[⑥ 조립된 앱에서<br/>또 이런 거 수요 발생]
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원에 찍어내는 엔진부터 닫는 것이 순서다.
15 changes: 15 additions & 0 deletions src/interactive-os/engine/createCommandEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = []

Expand Down Expand Up @@ -204,6 +212,7 @@ export function createCommandEngine(
}
if (store !== prev) {
onStoreChange(store)
notifyStoreSubscribers()
}
_lastResult = { ok: true, store }
}
Expand Down Expand Up @@ -257,7 +266,9 @@ export function createCommandEngine(
},
getStore,
syncStore: (newStore: NormalizedData) => {
if (newStore === store) return
store = newStore
notifyStoreSubscribers()
},
inspect,
setInspectKeyMap: (desc: Record<string, import('./types').KeyMapEntry>) => { inspectKeyMap = desc },
Expand All @@ -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++
Expand Down
34 changes: 34 additions & 0 deletions src/interactive-os/engine/shallow.ts
Original file line number Diff line number Diff line change
@@ -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<T>(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<string, unknown>)[key], (b as Record<string, unknown>)[key])) return false
}
return true
}
90 changes: 90 additions & 0 deletions src/interactive-os/engine/subscribeStore.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
5 changes: 5 additions & 0 deletions src/interactive-os/engine/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
36 changes: 36 additions & 0 deletions src/interactive-os/engine/useEngineStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/with-selector'

Check failure on line 1 in src/interactive-os/engine/useEngineStore.ts

View workflow job for this annotation

GitHub Actions / build

Cannot find module 'use-sync-external-store/with-selector' or its corresponding type declarations.
import type { NormalizedData } from '../store/types'
import type { CommandEngine } from './types'

const identity = <T>(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<T>(
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)
}
1 change: 1 addition & 0 deletions src/interactive-os/primitives/useAriaZone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
}

Expand Down
1 change: 1 addition & 0 deletions src/interactive-os/primitives/useControlledAria.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading