Recipes

Phaser 게임 엔진용 SolidJS 에디터 호스트, .pen 디자인 스펙에 맞춰 제작

README 링크에서 발견 · today

Games & SimulationsSoftware & DevOps

씬 캔버스, 도킹, 엄격한 TS를 갖춘 독립형 SolidJS 워크벤치

What they did

작성자가 codogma-engine repo에서 티켓 COD-80을 완료했습니다: `src/editor/` 아래에 독립형 SolidJS + TypeScript/TSX Editor Host를 두어 레거시 Colibri 에디터 UI를 대체하면서 기존 백엔드 및 데이터 계약은 그대로 유지했습니다. 루프백 `127.0.0.1:5173`에 Vite + SolidJS + Tailwind CSS 4 앱을 스캐폴딩하고, 상대 경로 `/editor/*` 요청을 엄격한 Origin 허용 목록과 함께 `127.0.0.1:3000`의 기존 Node 백엔드로 프록시했습니다. 워크벤치는 정식 `src/editor/codogma-editor.pen` 아트보드(Dark/Light 및 Compact)를 기준으로 구축했습니다: 메뉴, 툴바, Scene/Game 뷰포트, Hierarchy, Inspector, Project/Assets, Animation, Components, Console, Godot SVG 아이콘, 시맨틱 디자인 토큰, 그리고 영속되는 반응형 레이아웃입니다. 스플리터와 리사이즈, Console 도킹/언도킹, Hierarchy와 Inspector를 Bottom Dock으로 이동하고 Undock 버튼을 통한 홈 슬롯 복원, 포인터 드래그 탭 재정렬, 새로고침 간 상태 영속을 구현했습니다. 포커스/커맨드 모델이 W/E/R/Q와 F2/Delete를 Scene/Hierarchy 컨텍스트로 범위를 한정해 Inspector 입력이 단축키를 삼키지 않도록 했고, Command Palette는 Tab을 트랩하고 포커스를 복원합니다. Scene Canvas는 Phaser 기반으로 로컬 프로젝트 런타임, 선택, Inspector 및 Undo/Redo와 동기화되는 Move/Rotate/Scale 제스처를 갖춥니다. `.scene` 파일의 열기/저장/재열기, Image/Text/Container 추가/제거, transform/label/Text 편집, dirty 상태 확인, 오래된 비동기 응답 가드가 추가되었습니다. Game View는 시리얼라이저를 중복하지 않고 기존에 생성된 `index.html`을 iframe에 로드합니다. 프로덕션 Node 서버는 `src/editor/dist/assets/`의 해시된 Vite 에셋을 `/editor/assets/*`에서 제공합니다. Sprite Sheet 디스크립터를 Phaser 그리드 계산 및 프레임 범위와 정렬했고, 세 개의 도메인 모듈에서 잘못된 템플릿 리터럴을 수정했으며, `minimatch` 감사 차단 항목을 루트 override를 통해 11.0.3으로 고정했습니다. 다섯 개 도메인 모델(Scene, Animation, Prefab, User Components, Sprite Asset) 모두 JS와 `.d.mts` 사이드카에서 실제 TypeScript로 전환했으며, `allowJs: false`와 `src/editor/tsconfig.json`의 전체 ADR-0002 strict 프로파일을 적용했습니다. 테스트는 TypeScript + Vitest(6개 파일)와 격리된 백엔드 픽스처 런처를 사용하는 Playwright 인수 사양입니다. 문서 업데이트: spec, ADR-0002/0005, 마이그레이션 계획, 코드 품질 기준선, 에이전트 지침, 변경 로그.

How it went

CI Fast run #475이 두 작업(`fast-checks` 및 `editor-host`) 모두 HEAD `8417a66`에서 통과했습니다. 호스트는 전체 ADR-0002 엄격 타입체크와 Vite 프로덕션 빌드를 통과했고, Vitest는 6개 파일, 36개 통과, 0개 실패를 보고했으며, Chromium Playwright 인수 테스트는 9개 통과를 보고했습니다. `fast-checks`는 또한 루트/에디터 빌드, 회귀 스위트, 서버/보안 스모크 테스트, 미니 서비스 보안, 로컬라이제이션 인수 테스트, MCP 패키지 테스트를 통과했습니다. 이 슬라이스는 의도적으로 범위가 한정되어 있습니다. 도킹은 Console과 Hierarchy/Inspector 하단 도크 이동 및 탭 재정렬을 다루며, 범용 플로팅 윈도우나 자유 분할 영역은 포함하지 않습니다. Scene Canvas는 작성용 프리뷰이며 완전한 Prefab/컴포넌트 런타임 재구성이 아니고, `.scene`을 편집해도 `game.js`가 재생성되지 않습니다. 애니메이션은 기본 Phaser JSON 클립/프레임 작업만 다루며, 타임라인 트랙, 커브, 키프레임, 이벤트, 상태 그래프는 보류됩니다. User Components와 Prefab은 기본 모델만 있으며, 스크립트 라이프사이클/코드 생성, 중첩 Prefab 오버라이드, 완전한 동등성은 별도 슬라이스입니다. Sprite/Spine/Tilemap/Physics/FX/임포터 워크플로, 세션 인식 MCP SSE 라우팅, 프로덕션 컷오버, 롤백 윈도우, Colibri 제거는 범위 밖입니다.

Worth knowing

Host 패키지는 여전히 `npm install --no-audit --no-fund`로 설치되며, 생성된 lockfile은 워크플로 아티팩트로 업로드됩니다. `npm ci`로 전환하기 전에 `src/editor/package-lock.json`을 동기화하여 커밋해야 하며, 게시물에서는 해당 확인이 완료되었다고 주장하지 않습니다. TypeScript 전용 정책은 전면적인 재작성이 아니라 마이그레이션 경계입니다. 기존 `server/*.mjs`, 레거시 `source/editor/`, 생성/벤더 코드, 저장소 수준의 스모크/보안 스크립트는 그대로 유지되며, 새로운 기능 로직은 문서화된 예외와 함께 TS로 작성해야 합니다. 레거시 Colibri는 `feat/cod-80-solidjs-editor-host-20261101` 브랜치에서 `dev`로의 별도 컷오버가 있을 때까지 계속 사용할 수 있습니다. 감사 임계값은 완화되지 않았지만, 해당 핀은 업스트림 수정이 아닌 오버라이드입니다.

Wiring

작업 순서: (1) `src/editor/`를 자체 `tsconfig.json`(`allowJs: false`, `strict`, `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, `noImplicitOverride`, `noImplicitReturns`, `noFallthroughCasesInSwitch`, `forceConsistentCasingInFileNames`, `noUnusedLocals`, `noUnusedParameters`)을 갖춘 Vite + SolidJS + Tailwind 4 패키지로 스캐폴딩한다. (2) Vite 개발 서버를 `127.0.0.1:5173`에 구성하고 `/editor/*`를 `127.0.0.1:3000`으로 프록시하며 Origin 허용 목록을 설정한다. (3) `.pen` 아트보드에 맞춰 워크벤치 컴포넌트를 구축하고 디자인 토큰과 레이아웃 지속성을 연결한다. (4) 포커스/커맨드 모델을 컨텍스트 범위 키 핸들러로 구현하여 편집 가능한 입력이 옵트아웃되도록 하고, Command Palette를 포커스 트래핑 다이얼로그로 구현한다. (5) Phaser Scene Canvas를 로컬 프로젝트 런타임에 마운트하고 선택/제스처를 Inspector 상태 및 Undo/Redo 스택에 바인딩한다. (6) 기존 백엔드 계약을 통해 `.scene` 열기/저장/재열기를 연결하고, 변경 상태 확인과 오래된 응답에 대한 요청 시퀀스 가드를 추가한다. (7) Game View를 iframe을 통해 생성된 `index.html`을 가리키도록 한다. (8) 프로덕션 Node 서버가 `src/editor/dist/assets/*`를 `/editor/assets/*`에서 제공하도록 업데이트한다. (9) 다섯 개의 도메인 모델을 TS로 변환하고 `.d.mts` 사이드카를 삭제한다. (10) Vitest 설정과 6개의 테스트 파일, Playwright 설정 및 인수 스펙, 격리된 백엔드 픽스처 런처를 추가한다. (11) 루트 override를 통해 `minimatch`를 11.0.3으로 고정하고 lockfile을 재생성한다. (12) 타입체크, Vite 빌드, Vitest, Playwright를 실행하는 CI 작업 `fast-checks`와 `editor-host`를 추가한다.

Try it yourself

I'll build this out systematically. Given the scope, I'll lay out the full structure and implement each piece.

## 1. Project scaffolding

**`src/editor/package.json`**

```json
{
  "name": "codogma-editor",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "preview": "vite preview",
    "test": "vitest run",
    "test:watch": "vitest",
    "test:e2e": "playwright test",
    "typecheck": "tsc -b --noEmit"
  },
  "dependencies": {
    "phaser": "^3.80.1",
    "solid-js": "^1.9.3"
  },
  "devDependencies": {
    "@playwright/test": "^1.48.2",
    "@tailwindcss/vite": "^4.0.0-beta.3",
    "@testing-library/jest-dom": "^6.6.3",
    "@testing-library/user-event": "^14.5.2",
    "@types/node": "^22.9.0",
    "jsdom": "^25.0.1",
    "solid-js": "^1.9.3",
    "tailwindcss": "^4.0.0-beta.3",
    "typescript": "^5.6.3",
    "vite": "^5.4.10",
    "vite-plugin-solid": "^2.10.2",
    "vitest": "^2.1.4"
  }
}
```

**`src/editor/vite.config.ts`**

```ts
import { defineConfig } from 'vite';
import solid from 'vite-plugin-solid';
import tailwindcss from '@tailwindcss/vite';

const ALLOWED_ORIGINS = new Set([
  'http://127.0.0.1:5173',
  'http://localhost:5173',
]);

export default defineConfig({
  plugins: [solid(), tailwindcss()],
  server: {
    host: '127.0.0.1',
    port: 5173,
    strictPort: true,
    proxy: {
      '/editor': {
        target: 'http://127.0.0.1:3000',
        changeOrigin: false,
        configure: (proxy) => {
          proxy.on('proxyReq', (proxyReq, req) => {
            const origin = req.headers.origin;
            if (origin && !ALLOWED_ORIGINS.has(origin)) {
              proxyReq.destroy(new Error(`Origin not allowed: ${origin}`));
            }
          });
        },
      },
    },
  },
  build: {
    outDir: 'dist',
    assetsDir: 'assets',
    sourcemap: true,
  },
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./test/setup.ts'],
    include: ['test/**/*.test.{ts,tsx}'],
  },
});
```

**`src/editor/tsconfig.json`** — full ADR-0002 strict profile:

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "jsx": "preserve",
    "jsxImportSource": "solid-js",
    "types": ["vite/client", "vitest/globals", "node"],

    "allowJs": false,
    "checkJs": false,

    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "strictBindCallApply": true,
    "strictPropertyInitialization": true,
    "noImplicitThis": true,
    "useUnknownInCatchVariables": true,
    "alwaysStrict": true,
    "exactOptionalPropertyTypes": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "noPropertyAccessFromIndexSignature": true,
    "noFallthroughCasesInSwitch": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "forceConsistentCasingInFileNames": true,
    "isolatedModules": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true,
    "resolveJsonModule": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "noEmit": true
  },
  "include": ["src", "test", "e2e", "vite.config.ts", "playwright.config.ts"]
}
```

**`src/editor/index.html`**

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Codogma Editor</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
```

## 2. Domain models (real TypeScript)

**`src/editor/src/domain/scene.ts`**

```ts
export type NodeId = string;

export type Transform = {
  x: number;
  y: number;
  rotation: number;
  scaleX: number;
  scaleY: number;
};

export type ImageNode = {
  kind: 'image';
  id: NodeId;
  name: string;
  transform: Transform;
  assetId: string;
  visible: boolean;
};

export type TextNode = {
  kind: 'text';
  id: NodeId;
  name: string;
  transform: Transform;
  text: string;
  fontSize: number;
  color: string;
  visible: boolean;
};

export type ContainerNode = {
  kind: 'container';
  id: NodeId;
  name: string;
  transform: Transform;
  children: SceneNode[];
  visible: boolean;
};

export type SceneNode = ImageNode | TextNode | ContainerNode;

export type Scene = {
  version: 1;
  id: string;
  name: string;
  root: ContainerNode;
};

export const isContainer = (n: SceneNode): n is ContainerNode => n.kind === 'container';
export const isImage = (n: SceneNode): n is ImageNode => n.kind === 'image';
export const isText = (n: SceneNode): n is TextNode => n.kind === 'text';

export const identityTransform = (): Transform => ({
  x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1,
});

export const createNodeId = (): NodeId =>
  `n_${Math.random().toString(36).slice(2, 10)}`;

export const createEmptyScene = (name = 'Untitled'): Scene => ({
  version: 1,
  id: `scene_${Math.random().toString(36).slice(2, 10)}`,
  name,
  root: {
    kind: 'container',
    id: 'root',
    name: 'Root',
    transform: identityTransform(),
    children: [],
    visible: true,
  },
});
```

**`src/editor/src/domain/animation.ts`**

```ts
import type { NodeId } from './scene';

export type Easing = 'linear' | 'easeIn' | 'easeOut' | 'easeInOut';

export type Keyframe = {
  time: number;
  value: number;
};

export type Track = {
  nodeId: NodeId;
  property: 'x' | 'y' | 'rotation' | 'scaleX' | 'scaleY' | 'opacity';
  keyframes: Keyframe[];
};

export type AnimationClip = {
  id: string;
  name: string;
  duration: number;
  loop: boolean;
  easing: Easing;
  tracks: Track[];
};

export type AnimationSet = {
  version: 1;
  clips: AnimationClip[];
};

export const createEmptyAnimationSet = (): AnimationSet => ({
  version: 1,
  clips: [],
});

export const createClip = (name: string, duration = 1000): AnimationClip => ({
  id: `clip_${Math.random().toString(36).slice(2, 10)}`,
  name,
  duration,
  loop: false,
  easing: 'linear',
  tracks: [],
});
```

**`src/editor/src/domain/prefab.ts`**

```ts
import type { SceneNode } from './scene';

export type Prefab = {
  id: string;
  name: string;
  template: SceneNode;
  overrides: Record<string, unknown>;
};

export type PrefabLibrary = {
  version: 1;
  prefabs: Prefab[];
};

export const createEmptyPrefabLibrary = (): PrefabLibrary => ({
  version: 1,
  prefabs: [],
});
```

**`src/editor/src/domain/components.ts`**

```ts
export type ComponentFieldType = 'number' | 'string' | 'boolean' | 'color';

export type ComponentField = {
  name: string;
  type: ComponentFieldType;
  default: number | string | boolean;
};

export type UserComponent = {
  id: string;
  name: string;
  fields: ComponentField[];
  script: string;
};

export type ComponentRegistry = {
  version: 1;
  components: UserComponent[];
};

export const createEmptyComponentRegistry = (): ComponentRegistry => ({
  version: 1,
  components: [],
});
```

**`src/editor/src/domain/spriteAsset.ts`**

```ts
export type SpriteAsset = {
  id: string;
  name: string;
  path: string;
  width: number;
  height: number;
  frameWidth: number;
  frameHeight: number;
  frameCount: number;
};

export type AssetLibrary = {
  version: 1;
  sprites: SpriteAsset[];
};

export const createEmptyAssetLibrary = (): AssetLibrary => ({
  version: 1,
  sprites: [],
});
```

## 3. Design tokens

**`src/editor/src/styles/tokens.css`**

```css
@import "tailwindcss";

@theme {
  --color-surface-0: #0b0d10;
  --color-surface-1: #12151a;
  --color-surface-2: #1a1f26;
  --color-surface-3: #232a33;
  --color-border: #2c343f;
  --color-border-strong: #3a4452;
  --color-text-primary: #e6ebf2;
  --color-text-secondary: #9aa6b5;
  --color-text-muted: #5f6b7a;
  --color-accent: #4f9dff;
  --color-accent-hover: #6fb0ff;
  --color-danger: #ff5c5c;
  --color-success: #4ade80;
  --color-warning: #fbbf24;

  --radius-sm: 4px;
  --radius-md: 6px;
  --radius-lg: 10px;

  --font-sans: "Inter", system-ui, sans-serif;
  --font-mono: "JetBrains Mono", ui-monospace, monospace;
}

html, body, #root {
  height: 100%;
  margin: 0;
  background: var(--color-surface-0);
  color: var(--color-text-primary);
  font-family: var(--font-sans);
}
```

## 4. State stores

**`src/editor/src/state/layout.ts`** — persisted responsive layout

```ts
import { createSignal, createEffect } from 'solid-js';

export type PanelId = 'hierarchy' | 'inspector' | 'project' | 'animation' | 'components' | 'console';

export type PanelSlot = 'left' | 'right' | 'bottom' | 'bottom-dock';

export type LayoutState = {
  leftWidth: number;
  rightWidth: number;
  bottomHeight: number;
  slots: Record<PanelId, PanelSlot>;
  homeSlots: Record<PanelId, PanelSlot>;
  tabOrder: Record<PanelSlot, PanelId[]>;
  consoleDocked: boolean;
};

const STORAGE_KEY = 'codogma.layout.v1';

const defaultLayout = (): LayoutState => ({
  leftWidth: 260,
  rightWidth: 320,
  bottomHeight: 220,
  slots: {
    hierarchy: 'left',
    inspector: 'right',
    project: 'left',
    animation: 'bottom',
    components: 'bottom',
    console: 'bottom',
  },
  homeSlots: {
    hierarchy: 'left',
    inspector: 'right',
    project: 'left',
    animation: 'bottom',
    components: 'bottom',
    console: 'bottom',
  },
  tabOrder: {
    left: ['hierarchy', 'project'],
    right: ['inspector'],
    bottom: ['animation', 'components', 'console'],
    'bottom-dock': [],
  },
  consoleDocked: true,
});

const load = (): LayoutState => {
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    if (!raw) return defaultLayout();
    const parsed = JSON.parse(raw) as Partial<LayoutState>;
    return { ...defaultLayout(), ...parsed };
  } catch {
    return defaultLayout();
  }
};

export const createLayoutStore = () => {
  const [layout, setLayout] = createSignal<LayoutState>(load());

  createEffect(() => {
    try {
      localStorage.setItem(STORAGE_KEY, JSON.stringify(layout()));
    } catch {
      /* ignore quota */
    }
  });

  const movePanel = (panel: PanelId, slot: PanelSlot) => {
    setLayout((prev) => {
      const next: LayoutState = {
        ...prev,
        slots: { ...prev.slots, [panel]: slot },
        tabOrder: {
          left: prev.tabOrder.left.filter((p) => p !== panel),
          right: prev.tabOrder.right.filter((p) => p !== panel),
          bottom: prev.tabOrder.bottom.filter((p) => p !== panel),
          'bottom-dock': prev.tabOrder['bottom-dock'].filter((p) => p !== panel),
        },
      };
      next.tabOrder[slot] = [...next.tabOrder[slot], panel];
      return next;
    });
  };

  const restoreHome = (panel: PanelId) => {
    const home = layout().homeSlots[panel];
    movePanel(panel, home);
  };

  const reorderTabs = (slot: PanelSlot, from: number, to: number) => {
    setLayout((prev) => {
      const order = [...prev.tabOrder[slot]];
      const [moved] = order.splice(from, 1);
      if (moved === undefined) return prev;
      order.splice(to, 0, moved);
      return { ...prev, tabOrder: { ...prev.tabOrder, [slot]: order } };
    });
  };

  const setConsoleDocked = (docked: boolean) => {
    setLayout((prev) => ({ ...prev, consoleDocked: docked }));
    movePanel('console', docked ? 'bottom' : 'bottom-dock');
  };

  const setSize = (key: 'leftWidth' | 'rightWidth' | 'bottomHeight', value: number) => {
    setLayout((prev) => ({ ...prev, [key]: value }));
  };

  return { layout, movePanel, restoreHome, reorderTabs, setConsoleDocked, setSize };
};

export type LayoutStore = ReturnType<typeof createLayoutStore>;
```

**`src/editor/src/state/scene.ts`** — scene + undo/redo + dirty

```ts
import { createSignal, createMemo } from 'solid-js';
import type { Scene, SceneNode, NodeId, Transform } from '../domain/scene';
import { createEmptyScene, isContainer } from '../domain/scene';

export type Selection = NodeId | null;

type HistoryEntry = { scene: Scene; label: string };

const clone = <T,>(v: T): T => structuredClone(v);

export const createSceneStore = () => {
  const [scene, setSceneRaw] = createSignal<Scene>(createEmptyScene());
  const [selection, setSelection] = createSignal<Selection>(null);
  const [dirty, setDirty] = createSignal(false);
  const [undoStack, setUndoStack] = createSignal<HistoryEntry[]>([]);
  const [redoStack, setRedoStack] = createSignal<HistoryEntry[]>([]);
  const [filePath, setFilePath] = createSignal<string | null>(null);

  const commit = (label: string, mutator: (s: Scene) => Scene) => {
    const prev = scene();
    setUndoStack((s) => [...s, { scene: clone(prev), label }]);
    setRedoStack([]);
    setSceneRaw(mutator(clone(prev)));
    setDirty(true);
  };

  const replaceScene = (next: Scene, path: string | null) => {
    setSceneRaw(next);
    setFilePath(path);
    setDirty(false);
    setUndoStack([]);
    setRedoStack([]);
    setSelection(null);
  };

  const undo = () => {
    const stack = undoStack();
    const entry = stack[stack.length - 1];
    if (!entry) return;
    setRedoStack((r) => [...r, { scene: clone(scene()), label: entry.label }]);
    setUndoStack(stack.slice(0, -1));
    setSceneRaw(entry.scene);
    setDirty(true);
  };

  const redo = () => {
    const stack = redoStack();
    const entry = stack[stack.length - 1];
    if (!entry) return;
    setUndoStack((u) => [...u, { scene: clone(scene()), label: entry.label }]);
    setRedoStack(stack.slice(0, -1));
    setSceneRaw(entry.scene);
    setDirty(true);
  };

  const findNode = (root: SceneNode, id: NodeId): SceneNode | null => {
    if (root.id === id) return root;
    if (isContainer(root)) {
      for (const child of root.children) {
        const found = findNode(child, id);
        if (found) return found;
      }
    }
    return null;
  };

  const selectedNode = createMemo(() => {
    const id = selection();
    if (!id) return null;
    return findNode(scene().root, id);
  });

  const updateTransform = (id: NodeId, patch: Partial<Transform>) => {
    commit('transform', (s) => {
      const walk = (n: SceneNode): SceneNode => {
        if (n.id === id) return { ...n, transform: { ...n.transform, ...patch } };
        if (isContainer(n)) return { ...n, children: n.children.map(walk) };
        return n;
      };
      return { ...s, root: walk(s.root) as typeof s.root };
    });
  };

  const updateNode = (id: NodeId, patch: Partial<SceneNode>) => {
    commit('update', (s) => {
      const walk = (n: SceneNode): SceneNode => {
        if (n.id === id) return { ...n, ...patch } as SceneNode;
        if (isContainer(n)) return { ...n, children: n.children.map(walk) };
        return n;
      };
      return { ...s, root: walk(s.root) as typeof s.root };
    });
  };

  const addNode = (parentId: NodeId, node: SceneNode) => {
    commit('add', (s) => {
      const walk = (n: SceneNode): SceneNode => {
        if (n.id === parentId && isContainer(n)) {
          return { ...n, children: [...n.children, node] };
        }
        if (isContainer(n)) return { ...n, children: n.children.map(walk) };
        return n;
      };
      return { ...s, root: walk(s.root) as typeof s.root };
    });
    setSelection(node.id);
  };

  const removeNode = (id: NodeId) => {
    commit('remove', (s) => {
      const walk = (n: SceneNode): SceneNode => {
        if (isContainer(n)) {
          return { ...n, children: n.children.filter((c) => c.id !== id).map(walk) };
        }
        return n;
      };
      return { ...s, root: walk(s.root) as typeof s.root };
    });
    if (selection() === id) setSelection(null);
  };

  return {
    scene, setSceneRaw, selection, setSelection, selectedNode,
    dirty, setDirty, filePath, setFilePath,
    undo, redo, canUndo: () => undoStack().length > 0, canRedo: () => redoStack().length > 0,
    updateTransform, updateNode, addNode, removeNode, replaceScene, commit,
  };
};

export type SceneStore = ReturnType<typeof createSceneStore>;
```

## 5. Focus / command model

**`src/editor/src/state/focus.ts`**

```ts
import { createSignal } from 'solid-js';

export type FocusContext = 'scene' | 'hierarchy' | 'inspector' | 'palette' | 'none';

export const createFocusStore = () => {
  const [context, setContext] = createSignal<FocusContext>('none');
  const [paletteOpen, setPaletteOpen] = createSignal(false);
  const [paletteReturnFocus, setPaletteReturnFocus] = createSignal<HTMLElement | null>(null);

  const openPalette = () => {
    setPaletteReturnFocus(document.activeElement as HTMLElement | null);
    setPaletteOpen(true);
  };

  const closePalette = () => {
    setPaletteOpen(false);
    const el = paletteReturnFocus();
    if (el && typeof el.focus === 'function') el.focus();
  };

  return { context, setContext, paletteOpen, openPalette, closePalette };
};

export type FocusStore = ReturnType<typeof createFocusStore>;
```

**`src/editor/src/commands/registry.ts`**

```ts
import type { FocusContext } from '../state/focus';

export type Command = {
  id: string;
  label: string;
  shortcut?: string;
  contexts: FocusContext[];
  run: () => void;
};

export const createCommandRegistry = () => {
  const commands = new Map<string, Command>();
  const register = (cmd: Command) => { commands.set(cmd.id, cmd); };
  const all = () => Array.from(commands.values());
  const run = (id: string, ctx: FocusContext) => {
    const cmd = commands.get(id);
    if (!cmd) return false;
    if (!cmd.contexts.includes(ctx)) return false;
    cmd.run();
    return true;
  };
  return { register, all, run };
};

export type CommandRegistry = ReturnType<typeof createCommandRegistry>;
```

**`src/editor/src/commands/keymap.ts`**

```ts
import type { FocusContext } from '../state/focus';

export type KeyBinding = {
  key: string;
  contexts: FocusContext[];
  commandId: string;
};

export const KEYMAP: KeyBinding[] = [
  { key: 'w', contexts: ['scene', 'hierarchy'], commandId: 'tool.move' },
  { key: 'e', contexts: ['scene', 'hierarchy'], commandId: 'tool.rotate' },
  { key: 'r', contexts: ['scene', 'hierarchy'], commandId: 'tool.scale' },
  { key: 'q', contexts: ['scene', 'hierarchy'], commandId: 'tool.select' },
  { key: 'F2', contexts: ['scene', 'hierarchy'], commandId: 'node.rename' },
  { key: 'Delete', contexts: ['scene', 'hierarchy'], commandId: 'node.delete' },
];

export const resolveBinding = (key: string, ctx: FocusContext): string | null => {
  const b = KEYMAP.find((k) => k.key === key && k.contexts.includes(ctx));
  return b ? b.commandId : null;
};
```

## 6. Phaser Scene Canvas

**`src/editor/src/canvas/SceneCanvas.tsx`**

```tsx
import { onMount, onCleanup, createEffect } from 'solid-js';
import Phaser from 'phaser';
import type { SceneStore } from '../state/scene';
import type { SceneNode } from '../domain/scene';
import { isContainer } from '../domain/scene';

export type ToolMode = 'select' | 'move' | 'rotate' | 'scale';

type Props = {
  store: SceneStore;
  tool: () => ToolMode;
  onSelect: (id: string | null) => void;
};

const NODE_COLORS: Record<string, number> = {
  image: 0x4f9dff,
  text: 0xfbbf24,
  container: 0x4ade80,
};

export const SceneCanvas = (props: Props) => {
  let host!: HTMLDivElement;
  let game: Phaser.Game | null = null;
  let scene: Phaser.Scene | null = null;
  const sprites = new Map<string, Phaser.GameObjects.Container>();

  onMount(() => {
    const config: Phaser.Types.Core.GameConfig = {
      type: Phaser.AUTO,
      parent: host,
      backgroundColor: '#12151a',
      width: host.clientWidth || 800,
      height: host.clientHeight || 600,
      scene: {
        create() {
          scene = this;
          this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
            const hit = this.input.hitTestPointer(pointer);
            const first = hit[0];
            if (first && first.getData('nodeId')) {
              props.onSelect(first.getData('nodeId') as string);
            } else {
              props.onSelect(null);
            }
          });
          renderScene();
        },
      },
    };
    game = new Phaser.Game(config);

    const ro = new ResizeObserver(() => {
      if (game) game.scale.resize(host.clientWidth, host.clientHeight);
    });
    ro.observe(host);

    onCleanup(() => {
      ro.disconnect();
      game?.destroy(true);
      game = null;
      scene = null;
    });
  });

  const renderScene = () => {
    if (!scene) return;
    for (const s of sprites.values()) s.destroy();
    sprites.clear();

    const walk = (node: SceneNode) => {
      const container = scene!.add.container(node.transform.x, node.transform.y);
      container.setRotation(Phaser.Math.DegToRad(node.transform.rotation));
      container.setScale(node.transform.scaleX, node.transform.scaleY);
      container.setData('nodeId', node.id);

      const color = NODE_COLORS[node.kind] ?? 0xffffff;
      const rect = scene!.add.rectangle(0, 0, 80, 60, color, 0.25);
      rect.setStrokeStyle(2, color);
      rect.setInteractive();
      rect.setData('nodeId', node.id);
      container.add(rect);

      if (node.kind === 'text') {
        const label = scene!.add.text(0, 0, node.text, {
          fontSize: `${node.fontSize}px`,
          color: node.color,
        }).setOrigin(0.5);
        container.add(label);
      }

      sprites.set(node.id, container);

      if (isContainer(node)) node.children.forEach(walk);
    };
    walk(props.store.scene().root);
  };

  createEffect(() => {
    props.store.scene();
    renderScene();
  });

  createEffect(() => {
    const sel = props.store.selection();
    for (const [id, c] of sprites) {
      const rect = c.list[0] as Phaser.GameObjects.Rectangle | undefined;
      if (!rect) continue;
      if (id === sel) rect.setStrokeStyle(3, 0xffffff);
      else rect.setStrokeStyle(2, NODE_COLORS[c.getData('kind') as string] ?? 0x4f9dff);
    }
  });

  return <div ref={host} class="h-full w-full" data-testid="scene-canvas" />;
};
```

## 7. Workbench shell

**`src/editor/src/App.tsx`**

```tsx
import { createSignal, Show, For, onMount, onCleanup } from 'solid-js';
import { createSceneStore } from './state/scene';
import { createLayoutStore, type PanelId, type PanelSlot } from './state/layout';
import { createFocusStore } from './state/focus';
import { createCommandRegistry } from './commands/registry';
import { resolveBinding } from './commands/keymap';
import { SceneCanvas, type ToolMode } from './canvas/SceneCanvas';
import { MenuBar } from './ui/MenuBar';
import { Toolbar } from './ui/Toolbar';
import { HierarchyPanel } from './ui/HierarchyPanel';
import { InspectorPanel } from './ui/InspectorPanel';
import { ProjectPanel } from './ui/ProjectPanel';
import { AnimationPanel } from './ui/AnimationPanel';
import { ComponentsPanel } from './ui/ComponentsPanel';
import { ConsolePanel } from './ui/ConsolePanel';
import { CommandPalette } from './ui/CommandPalette';
import { Splitter } from './ui/Splitter';
import { GameView } from './ui/GameView';
import { TabStrip } from './ui/TabStrip';
import { sceneIO } from './io/sceneIO';

const PANEL_COMPONENTS: Record<PanelId, (props: { store: ReturnType<typeof createSceneStore> }) => JSX.Element> = {
  hierarchy: HierarchyPanel,
  inspector: InspectorPanel,
  project: ProjectPanel,
  animation: AnimationPanel,
  components: ComponentsPanel,
  console: ConsolePanel,
};

export const App = () => {
  const store = createSceneStore();
  const layout = createLayoutStore();
  const focus = createFocusStore();
  const commands = createCommandRegistry();
  const [tool, setTool] = createSignal<ToolMode>('select');
  const [view, setView] = createSignal<'scene' | 'game'>('scene');

  commands.register({ id: 'tool.move', label: 'Move Tool', shortcut: 'W', contexts: ['scene', 'hierarchy'], run: () => setTool('move') });
  commands.register({ id: 'tool.rotate', label: 'Rotate Tool', shortcut: 'E', contexts: ['scene', 'hierarchy'], run: () => setTool('rotate') });
  commands.register({ id: 'tool.scale', label: 'Scale Tool', shortcut: 'R', contexts: ['scene', 'hierarchy'], run: () => setTool('scale') });
  commands.register({ id: 'tool.select', label: 'Select Tool', shortcut: 'Q', contexts: ['scene', 'hierarchy'], run: () => setTool('select') });
  commands.register({ id: 'node.delete', label: 'Delete Node', shortcut: 'Delete', contexts: ['scene', 'hierarchy'], run: () => {
    const sel = store.selection();
    if (sel && sel !== 'root') store.removeNode(sel);
  }});
  commands.register({ id: 'node.rename', label: 'Rename Node', shortcut: 'F2', contexts: ['scene', 'hierarchy'], run: () => {
    const el = document.querySelector<HTMLInputElement>('[data-rename-input]');
    el?.focus();
    el?.select();
  }});
  commands.register({ id: 'edit.undo', label: 'Undo', shortcut: 'Ctrl+Z', contexts: ['scene', 'hierarchy', 'inspector'], run: () => store.undo() });
  commands.register({ id: 'edit.redo', label: 'Redo', shortcut: 'Ctrl+Shift+Z', contexts: ['scene', 'hierarchy', 'inspector'], run: () => store.redo() });
  commands.register({ id: 'file.open', label: 'Open Scene', contexts: ['scene', 'hierarchy', 'inspector', 'none'], run: () => void sceneIO.open(store) });
  commands.register({ id: 'file.save', label: 'Save Scene', contexts: ['scene', 'hierarchy', 'inspector', 'none'], run: () => void sceneIO.save(store) });

  const onKeyDown = (e: KeyboardEvent) => {
    if (focus.paletteOpen()) return;
    const ctx = focus.context();
    if (e.key === 'k' && (e.ctrlKey || e.metaKey)) {
      e.preventDefault();
      focus.openPalette();
      return;
    }
    if (e.key === 'z' && (e.ctrlKey || e.metaKey)) {
      e.preventDefault();
      if (e.shiftKey) store.redo(); else store.undo();
      return;
    }
    const cmdId = resolveBinding(e.key, ctx);
    if (cmdId) {
      e.preventDefault();
      commands.run(cmdId, ctx);
    }
  };

  onMount(() => window.addEventListener('keydown', onKeyDown));
  onCleanup(() => window.removeEventListener('keydown', onKeyDown));

  const renderSlot = (slot: PanelSlot) => (
    <For each={layout.layout().tabOrder[slot]}>
      {(panel) => {
        const Comp = PANEL_COMPONENTS[panel];
        return <Comp store={store} />;
      }}
    </For>
  );

  return (
    <div class="flex h-full flex-col bg-surface-0 text-text-primary">
      <MenuBar store={store} layout={layout} />
      <Toolbar tool={tool} setTool={setTool} view={view} setView={setView} store={store} />

      <div class="flex flex-1 overflow-hidden">
        <aside class="flex flex-col border-r border-border" style={{ width: `${layout.layout().leftWidth}px` }}>
          <TabStrip slot="left" layout={layout} />
          <div class="flex-1 overflow-auto">{renderSlot('left')}</div>
        </aside>
        <Splitter orientation="vertical" onDrag={(d) => layout.setSize('leftWidth', Math.max(180, layout.layout().leftWidth + d))} />

        <main class="flex flex-1 flex-col overflow-hidden">
          <div class="flex-1 overflow-hidden" onFocus={() => focus.setContext('scene')} tabIndex={-1}>
            <Show when={view() === 'scene'} fallback={<GameView />}>
              <SceneCanvas store={store} tool={tool} onSelect={(id) => store.setSelection(id)} />
            </Show>
          </div>
          <Splitter orientation="horizontal" onDrag={(d) => layout.setSize('bottomHeight', Math.max(120, layout.layout().bottomHeight - d))} />
          <section class="border-t border-border" style={{ height: `${layout.layout().bottomHeight}px` }}>
            <TabStrip slot="bottom" layout={layout} />
            <div class="h-[calc(100%-32px)] overflow-auto">{renderSlot('bottom')}</div>
          </section>
        </main>

        <Splitter orientation="vertical" onDrag={(d) => layout.setSize('rightWidth', Math.max(220, layout.layout().rightWidth - d))} />
        <aside class="flex flex-col border-l border-border" style={{ width: `${layout.layout().rightWidth}px` }}>
          <TabStrip slot="right" layout={layout} />
          <div class="flex-1 overflow-auto">{renderSlot('right')}</div>
        </aside>
      </div>

      <Show when={layout.layout().tabOrder['bottom-dock'].length > 0}>
        <section class="border-t border-border" style={{ height: '180px' }}>
          <TabStrip slot="bottom-dock" layout={layout} />
          <div class="h-[calc(100%-32px)] overflow-auto">{renderSlot('bottom-dock')}</div>
        </section>
      </Show>

      <CommandPalette focus={focus} commands={commands} />
    </div>
  );
};
```

## 8. UI panels

**`src/editor/src/ui/TabStrip.tsx`**

```tsx
import { For } from 'solid-js';
import type { LayoutStore, PanelSlot, PanelId } from '../state/layout';

type Props = { slot: PanelSlot; layout: LayoutStore };

export const TabStrip = (props:

Source

English

Tools in this stack