Phaser 게임 엔진용 SolidJS 에디터 호스트, .pen 디자인 스펙에 맞춰 제작
README 링크에서 발견 · today
씬 캔버스, 도킹, 엄격한 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: