diff --git a/.prettierrc.json b/.prettierrc.json
new file mode 100644
index 00000000..5d3c35d9
--- /dev/null
+++ b/.prettierrc.json
@@ -0,0 +1,5 @@
+{
+ "singleQuote": false,
+ "semi": true,
+ "trailingComma": "none"
+}
diff --git a/eslint.config.js b/eslint.config.js
new file mode 100644
index 00000000..9b206480
--- /dev/null
+++ b/eslint.config.js
@@ -0,0 +1,32 @@
+import js from "@eslint/js";
+import globals from "globals";
+import reactHooks from "eslint-plugin-react-hooks";
+import reactRefresh from "eslint-plugin-react-refresh";
+import tseslint from "typescript-eslint";
+
+export default tseslint.config(
+ {
+ ignores: ["coverage", "dist", "playwright-report", "test-results"]
+ },
+ js.configs.recommended,
+ ...tseslint.configs.recommended,
+ {
+ files: ["**/*.{ts,tsx}"],
+ languageOptions: {
+ ecmaVersion: "latest",
+ sourceType: "module",
+ globals: {
+ ...globals.browser,
+ ...globals.node
+ }
+ },
+ plugins: {
+ "react-hooks": reactHooks,
+ "react-refresh": reactRefresh
+ },
+ rules: {
+ ...reactHooks.configs.recommended.rules,
+ "react-refresh/only-export-components": ["warn", { allowConstantExport: true }]
+ }
+ }
+);
diff --git a/index.html b/index.html
new file mode 100644
index 00000000..aebe37d0
--- /dev/null
+++ b/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ WebEditor3D
+
+
+
+
+
+
diff --git a/package.json b/package.json
new file mode 100644
index 00000000..15a14d64
--- /dev/null
+++ b/package.json
@@ -0,0 +1,43 @@
+{
+ "name": "webeditor3d",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc --noEmit && vite build",
+ "preview": "vite preview",
+ "typecheck": "tsc --noEmit",
+ "lint": "eslint .",
+ "format": "prettier --write .",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "test:e2e": "playwright test"
+ },
+ "dependencies": {
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "three": "^0.169.0"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.13.0",
+ "@playwright/test": "^1.48.2",
+ "@testing-library/jest-dom": "^6.6.3",
+ "@testing-library/react": "^16.0.1",
+ "@types/node": "^22.8.7",
+ "@types/react": "^18.3.3",
+ "@types/react-dom": "^18.3.0",
+ "@types/three": "^0.169.0",
+ "@vitejs/plugin-react": "^4.3.3",
+ "eslint": "^9.13.0",
+ "eslint-plugin-react-hooks": "^5.1.0",
+ "eslint-plugin-react-refresh": "^0.4.13",
+ "globals": "^15.11.0",
+ "jsdom": "^25.0.1",
+ "prettier": "^3.3.3",
+ "typescript": "^5.6.3",
+ "typescript-eslint": "^8.10.0",
+ "vite": "^5.4.10",
+ "vitest": "^2.1.4"
+ }
+}
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 00000000..443159ab
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,26 @@
+import { defineConfig, devices } from "@playwright/test";
+
+export default defineConfig({
+ testDir: "./tests/e2e",
+ timeout: 30_000,
+ expect: {
+ timeout: 5_000
+ },
+ use: {
+ baseURL: "http://127.0.0.1:4173",
+ trace: "on-first-retry"
+ },
+ projects: [
+ {
+ name: "chromium",
+ use: {
+ ...devices["Desktop Chrome"]
+ }
+ }
+ ],
+ webServer: {
+ command: "npm run dev -- --host 127.0.0.1 --port 4173",
+ url: "http://127.0.0.1:4173",
+ reuseExistingServer: !process.env.CI
+ }
+});
diff --git a/src/app/App.tsx b/src/app/App.tsx
new file mode 100644
index 00000000..9445068f
--- /dev/null
+++ b/src/app/App.tsx
@@ -0,0 +1,260 @@
+import { useEffect, useRef, useState } from "react";
+
+import { createSetSceneNameCommand } from "../commands/set-scene-name-command";
+import { Panel } from "../shared-ui/Panel";
+import { ViewportCanvas } from "../viewport-three/ViewportCanvas";
+import type { EditorStore } from "./editor-store";
+import { useEditorStoreState } from "./use-editor-store";
+
+interface AppProps {
+ store: EditorStore;
+}
+
+function describeSelection(selectionKind: string): string {
+ switch (selectionKind) {
+ case "none":
+ return "No authored selection yet";
+ case "brushes":
+ return "Brush selection placeholder";
+ case "entities":
+ return "Entity selection placeholder";
+ case "modelInstances":
+ return "Model instance selection placeholder";
+ default:
+ return "Unknown selection";
+ }
+}
+
+function getErrorMessage(error: unknown): string {
+ if (error instanceof Error) {
+ return error.message;
+ }
+
+ return "An unexpected error occurred.";
+}
+
+export function App({ store }: AppProps) {
+ const editorState = useEditorStoreState(store);
+ const [sceneNameDraft, setSceneNameDraft] = useState(editorState.document.name);
+ const [statusMessage, setStatusMessage] = useState("Viewport shell ready.");
+ const importInputRef = useRef(null);
+
+ useEffect(() => {
+ setSceneNameDraft(editorState.document.name);
+ }, [editorState.document.name]);
+
+ const applySceneName = () => {
+ const normalizedName = sceneNameDraft.trim() || "Untitled Scene";
+
+ if (normalizedName === editorState.document.name) {
+ setStatusMessage("Scene name is already current.");
+ return;
+ }
+
+ store.executeCommand(createSetSceneNameCommand(normalizedName));
+ setStatusMessage(`Scene renamed to ${normalizedName}.`);
+ };
+
+ const handleSaveDraft = () => {
+ const didSave = store.saveDraft();
+ setStatusMessage(didSave ? "Local draft saved." : "Local draft storage is unavailable.");
+ };
+
+ const handleLoadDraft = () => {
+ try {
+ const didLoad = store.loadDraft();
+ setStatusMessage(didLoad ? "Local draft loaded." : "No local draft was found.");
+ } catch (error) {
+ setStatusMessage(getErrorMessage(error));
+ }
+ };
+
+ const handleExportJson = () => {
+ const exportedJson = store.exportDocumentJson();
+ const blob = new Blob([exportedJson], { type: "application/json" });
+ const objectUrl = URL.createObjectURL(blob);
+ const anchor = document.createElement("a");
+
+ anchor.href = objectUrl;
+ anchor.download = `${editorState.document.name.replace(/\s+/g, "-").toLowerCase() || "scene"}.json`;
+ anchor.click();
+ URL.revokeObjectURL(objectUrl);
+
+ setStatusMessage("Scene document exported as JSON.");
+ };
+
+ const handleImportButtonClick = () => {
+ importInputRef.current?.click();
+ };
+
+ const handleImportChange = async (event: React.ChangeEvent) => {
+ const file = event.currentTarget.files?.[0];
+
+ if (file === undefined) {
+ return;
+ }
+
+ try {
+ const source = await file.text();
+ store.importDocumentJson(source);
+ setStatusMessage(`Imported ${file.name}.`);
+ } catch (error) {
+ setStatusMessage(getErrorMessage(error));
+ } finally {
+ event.currentTarget.value = "";
+ }
+ };
+
+ return (
+
+
+
+
WebEditor3D
+
Milestone 0 foundation slice
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Viewport
+
Imperative three.js editor surface
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/app.css b/src/app/app.css
new file mode 100644
index 00000000..7425867e
--- /dev/null
+++ b/src/app/app.css
@@ -0,0 +1,362 @@
+:root {
+ color-scheme: dark;
+ font-family: "Avenir Next", "Segoe UI", sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ --color-bg: #13171d;
+ --color-surface: rgba(31, 36, 45, 0.92);
+ --color-surface-strong: #2a313d;
+ --color-border: rgba(255, 255, 255, 0.08);
+ --color-text: #f2eee8;
+ --color-muted: #a8b0bd;
+ --color-accent: #cf7b42;
+ --color-accent-strong: #f3be8f;
+ --color-accent-soft: rgba(207, 123, 66, 0.18);
+ --shadow-panel: 0 18px 48px rgba(3, 7, 12, 0.38);
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body,
+#root {
+ margin: 0;
+ min-height: 100%;
+}
+
+body {
+ min-height: 100vh;
+ background:
+ radial-gradient(circle at top, rgba(80, 96, 120, 0.35) 0%, rgba(23, 28, 37, 0) 42%),
+ linear-gradient(180deg, #1b2029 0%, #101318 100%);
+ color: var(--color-text);
+}
+
+button,
+input {
+ font: inherit;
+}
+
+button {
+ border: 1px solid transparent;
+ border-radius: 12px;
+ cursor: pointer;
+ transition:
+ background-color 120ms ease,
+ border-color 120ms ease,
+ transform 120ms ease;
+}
+
+button:hover:not(:disabled) {
+ transform: translateY(-1px);
+}
+
+button:disabled {
+ cursor: not-allowed;
+ opacity: 0.45;
+}
+
+.app-shell {
+ display: grid;
+ grid-template-rows: 64px minmax(0, 1fr) 36px;
+ min-height: 100vh;
+ gap: 12px;
+ padding: 12px;
+}
+
+.toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 0 18px;
+ background: rgba(18, 22, 28, 0.88);
+ border: 1px solid var(--color-border);
+ border-radius: 18px;
+ box-shadow: var(--shadow-panel);
+ backdrop-filter: blur(16px);
+}
+
+.toolbar__brand {
+ display: flex;
+ flex-direction: column;
+}
+
+.toolbar__title {
+ font-size: 1rem;
+ font-weight: 700;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+}
+
+.toolbar__subtitle {
+ color: var(--color-muted);
+ font-size: 0.78rem;
+}
+
+.toolbar__actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.toolbar__group {
+ display: flex;
+ gap: 8px;
+ padding: 8px;
+ background: rgba(255, 255, 255, 0.02);
+ border: 1px solid var(--color-border);
+ border-radius: 14px;
+}
+
+.toolbar__button {
+ padding: 0.65rem 0.9rem;
+ background: var(--color-surface-strong);
+ color: var(--color-text);
+}
+
+.toolbar__button:hover:not(:disabled) {
+ border-color: rgba(255, 255, 255, 0.12);
+}
+
+.toolbar__button--active {
+ background: var(--color-accent-soft);
+ border-color: rgba(207, 123, 66, 0.55);
+ color: var(--color-accent-strong);
+}
+
+.toolbar__button--accent {
+ background: var(--color-accent);
+ color: #20140c;
+ font-weight: 700;
+}
+
+.workspace {
+ display: grid;
+ grid-template-columns: minmax(240px, 280px) minmax(0, 1fr) minmax(280px, 320px);
+ gap: 12px;
+ min-height: 0;
+}
+
+.side-column {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ min-height: 0;
+}
+
+.panel {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: 18px;
+ overflow: hidden;
+ box-shadow: var(--shadow-panel);
+}
+
+.panel__header {
+ padding: 14px 16px;
+ border-bottom: 1px solid var(--color-border);
+ color: #e4d8ca;
+ font-size: 0.76rem;
+ font-weight: 700;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+}
+
+.panel__body {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ padding: 16px;
+}
+
+.stat-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.stat-card {
+ padding: 12px;
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0.02) 100%);
+ border: 1px solid var(--color-border);
+ border-radius: 14px;
+}
+
+.label {
+ color: var(--color-muted);
+ font-size: 0.74rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.value {
+ margin-top: 0.35rem;
+ font-size: 0.95rem;
+ font-weight: 600;
+}
+
+.form-field {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.text-input {
+ width: 100%;
+ padding: 0.85rem 0.95rem;
+ background: rgba(7, 9, 13, 0.4);
+ color: var(--color-text);
+ border: 1px solid var(--color-border);
+ border-radius: 12px;
+}
+
+.inline-actions {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.placeholder-list {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.placeholder-list li {
+ padding: 10px 12px;
+ color: var(--color-muted);
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px dashed rgba(255, 255, 255, 0.11);
+ border-radius: 12px;
+}
+
+.viewport-region {
+ display: grid;
+ grid-template-rows: 42px minmax(0, 1fr);
+ min-height: 0;
+ background: rgba(20, 24, 31, 0.92);
+ border: 1px solid var(--color-border);
+ border-radius: 24px;
+ overflow: hidden;
+ box-shadow: var(--shadow-panel);
+}
+
+.viewport-region__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 18px;
+ background: linear-gradient(90deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0) 100%);
+ border-bottom: 1px solid var(--color-border);
+}
+
+.viewport-region__title {
+ font-size: 0.76rem;
+ font-weight: 700;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+}
+
+.viewport-region__caption {
+ color: var(--color-muted);
+ font-size: 0.82rem;
+}
+
+.viewport-canvas {
+ position: relative;
+ min-height: 420px;
+ background:
+ radial-gradient(circle at top, rgba(130, 154, 188, 0.28) 0%, rgba(130, 154, 188, 0) 38%),
+ linear-gradient(180deg, #55657c 0%, #2c3440 34%, #151920 100%);
+}
+
+.viewport-canvas canvas {
+ display: block;
+ width: 100%;
+ height: 100%;
+}
+
+.status-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 0 16px;
+ background: rgba(18, 22, 28, 0.88);
+ border: 1px solid var(--color-border);
+ border-radius: 16px;
+ color: var(--color-muted);
+}
+
+.status-bar__strong {
+ color: var(--color-text);
+ font-weight: 700;
+}
+
+.visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+@media (max-width: 1100px) {
+ .workspace {
+ grid-template-columns: minmax(240px, 280px) minmax(0, 1fr);
+ }
+
+ .workspace > .side-column:last-child {
+ grid-column: 1 / -1;
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 820px) {
+ .app-shell {
+ grid-template-rows: auto auto auto;
+ }
+
+ .toolbar {
+ flex-direction: column;
+ align-items: flex-start;
+ padding: 16px;
+ }
+
+ .workspace {
+ grid-template-columns: 1fr;
+ }
+
+ .workspace > .side-column:last-child {
+ display: flex;
+ }
+
+ .viewport-canvas {
+ min-height: 320px;
+ }
+
+ .status-bar {
+ flex-direction: column;
+ align-items: flex-start;
+ padding: 12px 16px;
+ }
+}
diff --git a/src/app/editor-store.ts b/src/app/editor-store.ts
new file mode 100644
index 00000000..86135c7b
--- /dev/null
+++ b/src/app/editor-store.ts
@@ -0,0 +1,178 @@
+import { CommandHistory } from "../commands/command-history";
+import type { CommandContext, EditorCommand } from "../commands/command";
+import type { EditorSelection } from "../core/selection";
+import type { ToolMode } from "../core/tool-mode";
+import { createEmptySceneDocument, type SceneDocument } from "../document/scene-document";
+import {
+ DEFAULT_SCENE_DRAFT_STORAGE_KEY,
+ loadSceneDocumentDraft,
+ type KeyValueStorage,
+ saveSceneDocumentDraft
+} from "../serialization/local-draft-storage";
+import { parseSceneDocumentJson, serializeSceneDocument } from "../serialization/scene-document-json";
+
+export interface EditorStoreState {
+ document: SceneDocument;
+ selection: EditorSelection;
+ toolMode: ToolMode;
+ canUndo: boolean;
+ canRedo: boolean;
+ lastCommandLabel: string | null;
+}
+
+interface EditorStoreOptions {
+ initialDocument?: SceneDocument;
+ storage?: KeyValueStorage | null;
+ storageKey?: string;
+}
+
+type EditorStoreListener = () => void;
+
+export class EditorStore {
+ private document: SceneDocument;
+ private selection: EditorSelection = { kind: "none" };
+ private toolMode: ToolMode = "select";
+ private readonly history = new CommandHistory();
+ private readonly listeners = new Set();
+ private readonly storage: KeyValueStorage | null;
+ private readonly storageKey: string;
+ private lastCommandLabel: string | null = null;
+
+ private readonly commandContext: CommandContext = {
+ getDocument: () => this.document,
+ setDocument: (document) => {
+ this.document = document;
+ },
+ getSelection: () => this.selection,
+ setSelection: (selection) => {
+ this.selection = selection;
+ },
+ getToolMode: () => this.toolMode,
+ setToolMode: (toolMode) => {
+ this.toolMode = toolMode;
+ }
+ };
+
+ constructor(options: EditorStoreOptions = {}) {
+ this.document = options.initialDocument ?? createEmptySceneDocument();
+ this.storage = options.storage ?? null;
+ this.storageKey = options.storageKey ?? DEFAULT_SCENE_DRAFT_STORAGE_KEY;
+ }
+
+ subscribe = (listener: EditorStoreListener) => {
+ this.listeners.add(listener);
+
+ return () => {
+ this.listeners.delete(listener);
+ };
+ };
+
+ getState = (): EditorStoreState => ({
+ document: this.document,
+ selection: this.selection,
+ toolMode: this.toolMode,
+ canUndo: this.history.canUndo(),
+ canRedo: this.history.canRedo(),
+ lastCommandLabel: this.lastCommandLabel
+ });
+
+ setToolMode(toolMode: ToolMode) {
+ if (this.toolMode === toolMode) {
+ return;
+ }
+
+ this.toolMode = toolMode;
+ this.emit();
+ }
+
+ setSelection(selection: EditorSelection) {
+ this.selection = selection;
+ this.emit();
+ }
+
+ executeCommand(command: EditorCommand) {
+ this.history.execute(command, this.commandContext);
+ this.lastCommandLabel = command.label;
+ this.emit();
+ }
+
+ undo(): boolean {
+ const command = this.history.undo(this.commandContext);
+
+ if (command === null) {
+ return false;
+ }
+
+ this.lastCommandLabel = `Undid ${command.label}`;
+ this.emit();
+ return true;
+ }
+
+ redo(): boolean {
+ const command = this.history.redo(this.commandContext);
+
+ if (command === null) {
+ return false;
+ }
+
+ this.lastCommandLabel = `Redid ${command.label}`;
+ this.emit();
+ return true;
+ }
+
+ replaceDocument(document: SceneDocument, resetHistory = true) {
+ this.document = document;
+ this.selection = { kind: "none" };
+
+ if (resetHistory) {
+ this.history.clear();
+ this.lastCommandLabel = null;
+ }
+
+ this.emit();
+ }
+
+ saveDraft(): boolean {
+ if (this.storage === null) {
+ return false;
+ }
+
+ saveSceneDocumentDraft(this.storage, this.document, this.storageKey);
+ return true;
+ }
+
+ loadDraft(): boolean {
+ if (this.storage === null) {
+ return false;
+ }
+
+ const document = loadSceneDocumentDraft(this.storage, this.storageKey);
+
+ if (document === null) {
+ return false;
+ }
+
+ this.replaceDocument(document);
+ return true;
+ }
+
+ exportDocumentJson(): string {
+ return serializeSceneDocument(this.document);
+ }
+
+ importDocumentJson(source: string): SceneDocument {
+ const document = parseSceneDocumentJson(source);
+ this.replaceDocument(document);
+ return document;
+ }
+
+ private emit() {
+ for (const listener of this.listeners) {
+ listener();
+ }
+ }
+}
+
+export function createEditorStore(options?: EditorStoreOptions): EditorStore {
+ return new EditorStore(options);
+}
diff --git a/src/app/use-editor-store.ts b/src/app/use-editor-store.ts
new file mode 100644
index 00000000..a4e02ccd
--- /dev/null
+++ b/src/app/use-editor-store.ts
@@ -0,0 +1,7 @@
+import { useSyncExternalStore } from "react";
+
+import type { EditorStore, EditorStoreState } from "./editor-store";
+
+export function useEditorStoreState(store: EditorStore): EditorStoreState {
+ return useSyncExternalStore(store.subscribe, store.getState, store.getState);
+}
diff --git a/src/assets/.gitkeep b/src/assets/.gitkeep
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/src/assets/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/src/commands/command-history.ts b/src/commands/command-history.ts
new file mode 100644
index 00000000..7c51295a
--- /dev/null
+++ b/src/commands/command-history.ts
@@ -0,0 +1,49 @@
+import type { CommandContext, EditorCommand } from "./command";
+
+export class CommandHistory {
+ private readonly undoStack: EditorCommand[] = [];
+ private readonly redoStack: EditorCommand[] = [];
+
+ execute(command: EditorCommand, context: CommandContext) {
+ command.execute(context);
+ this.undoStack.push(command);
+ this.redoStack.length = 0;
+ }
+
+ undo(context: CommandContext): EditorCommand | null {
+ const command = this.undoStack.pop();
+
+ if (command === undefined) {
+ return null;
+ }
+
+ command.undo(context);
+ this.redoStack.push(command);
+ return command;
+ }
+
+ redo(context: CommandContext): EditorCommand | null {
+ const command = this.redoStack.pop();
+
+ if (command === undefined) {
+ return null;
+ }
+
+ command.execute(context);
+ this.undoStack.push(command);
+ return command;
+ }
+
+ clear() {
+ this.undoStack.length = 0;
+ this.redoStack.length = 0;
+ }
+
+ canUndo(): boolean {
+ return this.undoStack.length > 0;
+ }
+
+ canRedo(): boolean {
+ return this.redoStack.length > 0;
+ }
+}
diff --git a/src/commands/command.ts b/src/commands/command.ts
new file mode 100644
index 00000000..e1aeb2d2
--- /dev/null
+++ b/src/commands/command.ts
@@ -0,0 +1,19 @@
+import type { EditorSelection } from "../core/selection";
+import type { ToolMode } from "../core/tool-mode";
+import type { SceneDocument } from "../document/scene-document";
+
+export interface CommandContext {
+ getDocument(): SceneDocument;
+ setDocument(document: SceneDocument): void;
+ getSelection(): EditorSelection;
+ setSelection(selection: EditorSelection): void;
+ getToolMode(): ToolMode;
+ setToolMode(toolMode: ToolMode): void;
+}
+
+export interface EditorCommand {
+ id: string;
+ label: string;
+ execute(context: CommandContext): void;
+ undo(context: CommandContext): void;
+}
diff --git a/src/commands/set-scene-name-command.ts b/src/commands/set-scene-name-command.ts
new file mode 100644
index 00000000..2c6ae5a3
--- /dev/null
+++ b/src/commands/set-scene-name-command.ts
@@ -0,0 +1,36 @@
+import { createOpaqueId } from "../core/ids";
+
+import type { EditorCommand } from "./command";
+
+export function createSetSceneNameCommand(nextName: string): EditorCommand {
+ const normalizedName = nextName.trim() || "Untitled Scene";
+ let previousName: string | null = null;
+
+ return {
+ id: createOpaqueId("command"),
+ label: `Rename scene to ${normalizedName}`,
+ execute(context) {
+ const currentDocument = context.getDocument();
+
+ if (previousName === null) {
+ previousName = currentDocument.name;
+ }
+
+ context.setDocument({
+ ...currentDocument,
+ name: normalizedName
+ });
+ },
+ undo(context) {
+ if (previousName === null) {
+ return;
+ }
+
+ const currentDocument = context.getDocument();
+ context.setDocument({
+ ...currentDocument,
+ name: previousName
+ });
+ }
+ };
+}
diff --git a/src/core/ids.ts b/src/core/ids.ts
new file mode 100644
index 00000000..6c9ced75
--- /dev/null
+++ b/src/core/ids.ts
@@ -0,0 +1,10 @@
+let fallbackCounter = 0;
+
+export function createOpaqueId(prefix: string): string {
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
+ return `${prefix}-${crypto.randomUUID()}`;
+ }
+
+ fallbackCounter += 1;
+ return `${prefix}-${Date.now()}-${fallbackCounter}`;
+}
diff --git a/src/core/selection.ts b/src/core/selection.ts
new file mode 100644
index 00000000..44efcd0d
--- /dev/null
+++ b/src/core/selection.ts
@@ -0,0 +1,5 @@
+export type EditorSelection =
+ | { kind: "none" }
+ | { kind: "brushes"; ids: string[] }
+ | { kind: "entities"; ids: string[] }
+ | { kind: "modelInstances"; ids: string[] };
diff --git a/src/core/tool-mode.ts b/src/core/tool-mode.ts
new file mode 100644
index 00000000..a43af7ef
--- /dev/null
+++ b/src/core/tool-mode.ts
@@ -0,0 +1 @@
+export type ToolMode = "select" | "box-create" | "play";
diff --git a/src/core/vector.ts b/src/core/vector.ts
new file mode 100644
index 00000000..042ea117
--- /dev/null
+++ b/src/core/vector.ts
@@ -0,0 +1,11 @@
+export interface Vec3 {
+ x: number;
+ y: number;
+ z: number;
+}
+
+export const DEFAULT_SUN_DIRECTION: Vec3 = {
+ x: -0.6,
+ y: 1,
+ z: 0.35
+};
diff --git a/src/document/migrate-scene-document.ts b/src/document/migrate-scene-document.ts
new file mode 100644
index 00000000..8e09228c
--- /dev/null
+++ b/src/document/migrate-scene-document.ts
@@ -0,0 +1,114 @@
+import { SCENE_DOCUMENT_VERSION, type SceneDocument, type WorldSettings } from "./scene-document";
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function expectFiniteNumber(value: unknown, label: string): number {
+ if (typeof value !== "number" || !Number.isFinite(value)) {
+ throw new Error(`${label} must be a finite number.`);
+ }
+
+ return value;
+}
+
+function expectString(value: unknown, label: string): string {
+ if (typeof value !== "string") {
+ throw new Error(`${label} must be a string.`);
+ }
+
+ return value;
+}
+
+function expectHexColor(value: unknown, label: string): string {
+ const normalizedValue = expectString(value, label);
+
+ if (!/^#[0-9a-f]{6}$/i.test(normalizedValue)) {
+ throw new Error(`${label} must use #RRGGBB format.`);
+ }
+
+ return normalizedValue;
+}
+
+function expectEmptyCollection(value: unknown, label: string): Record {
+ if (!isRecord(value)) {
+ throw new Error(`${label} must be a record.`);
+ }
+
+ if (Object.keys(value).length > 0) {
+ throw new Error(`${label} must be empty in the foundation schema.`);
+ }
+
+ return {};
+}
+
+function readWorldSettings(value: unknown): WorldSettings {
+ if (!isRecord(value)) {
+ throw new Error("world must be an object.");
+ }
+
+ const background = value.background;
+ const ambientLight = value.ambientLight;
+ const sunLight = value.sunLight;
+
+ if (!isRecord(background)) {
+ throw new Error("world.background must be an object.");
+ }
+
+ if (!isRecord(ambientLight)) {
+ throw new Error("world.ambientLight must be an object.");
+ }
+
+ if (!isRecord(sunLight)) {
+ throw new Error("world.sunLight must be an object.");
+ }
+
+ const direction = sunLight.direction;
+
+ if (!isRecord(direction)) {
+ throw new Error("world.sunLight.direction must be an object.");
+ }
+
+ return {
+ background: {
+ mode: "solid",
+ colorHex: expectHexColor(background.colorHex, "world.background.colorHex")
+ },
+ ambientLight: {
+ colorHex: expectHexColor(ambientLight.colorHex, "world.ambientLight.colorHex"),
+ intensity: expectFiniteNumber(ambientLight.intensity, "world.ambientLight.intensity")
+ },
+ sunLight: {
+ colorHex: expectHexColor(sunLight.colorHex, "world.sunLight.colorHex"),
+ intensity: expectFiniteNumber(sunLight.intensity, "world.sunLight.intensity"),
+ direction: {
+ x: expectFiniteNumber(direction.x, "world.sunLight.direction.x"),
+ y: expectFiniteNumber(direction.y, "world.sunLight.direction.y"),
+ z: expectFiniteNumber(direction.z, "world.sunLight.direction.z")
+ }
+ }
+ };
+}
+
+export function migrateSceneDocument(source: unknown): SceneDocument {
+ if (!isRecord(source)) {
+ throw new Error("Scene document must be a JSON object.");
+ }
+
+ if (source.version !== SCENE_DOCUMENT_VERSION) {
+ throw new Error(`Unsupported scene document version: ${String(source.version)}.`);
+ }
+
+ return {
+ version: SCENE_DOCUMENT_VERSION,
+ name: expectString(source.name, "name"),
+ world: readWorldSettings(source.world),
+ materials: expectEmptyCollection(source.materials, "materials"),
+ textures: expectEmptyCollection(source.textures, "textures"),
+ assets: expectEmptyCollection(source.assets, "assets"),
+ brushes: expectEmptyCollection(source.brushes, "brushes"),
+ modelInstances: expectEmptyCollection(source.modelInstances, "modelInstances"),
+ entities: expectEmptyCollection(source.entities, "entities"),
+ interactionLinks: expectEmptyCollection(source.interactionLinks, "interactionLinks")
+ };
+}
diff --git a/src/document/scene-document.ts b/src/document/scene-document.ts
new file mode 100644
index 00000000..b1944af5
--- /dev/null
+++ b/src/document/scene-document.ts
@@ -0,0 +1,71 @@
+import { DEFAULT_SUN_DIRECTION, type Vec3 } from "../core/vector";
+
+export const SCENE_DOCUMENT_VERSION = 1 as const;
+
+export interface WorldBackgroundSettings {
+ mode: "solid";
+ colorHex: string;
+}
+
+export interface WorldAmbientLightSettings {
+ colorHex: string;
+ intensity: number;
+}
+
+export interface WorldSunLightSettings {
+ colorHex: string;
+ intensity: number;
+ direction: Vec3;
+}
+
+export interface WorldSettings {
+ background: WorldBackgroundSettings;
+ ambientLight: WorldAmbientLightSettings;
+ sunLight: WorldSunLightSettings;
+}
+
+export interface SceneDocument {
+ version: typeof SCENE_DOCUMENT_VERSION;
+ name: string;
+ world: WorldSettings;
+ materials: Record;
+ textures: Record;
+ assets: Record;
+ brushes: Record;
+ modelInstances: Record;
+ entities: Record;
+ interactionLinks: Record;
+}
+
+export function createDefaultWorldSettings(): WorldSettings {
+ return {
+ background: {
+ mode: "solid",
+ colorHex: "#2f3947"
+ },
+ ambientLight: {
+ colorHex: "#f7f1e8",
+ intensity: 1
+ },
+ sunLight: {
+ colorHex: "#fff1d5",
+ intensity: 1.75,
+ direction: DEFAULT_SUN_DIRECTION
+ }
+ };
+}
+
+export function createEmptySceneDocument(overrides: Partial> = {}): SceneDocument {
+ return {
+ version: SCENE_DOCUMENT_VERSION,
+ name: overrides.name ?? "Untitled Scene",
+ world: overrides.world ?? createDefaultWorldSettings(),
+ materials: {},
+ textures: {},
+ assets: {},
+ brushes: {},
+ modelInstances: {},
+ entities: {},
+ interactionLinks: {}
+ };
+}
diff --git a/src/entities/.gitkeep b/src/entities/.gitkeep
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/src/entities/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/src/geometry/.gitkeep b/src/geometry/.gitkeep
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/src/geometry/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/src/main.tsx b/src/main.tsx
new file mode 100644
index 00000000..c34f9610
--- /dev/null
+++ b/src/main.tsx
@@ -0,0 +1,25 @@
+import React from "react";
+import ReactDOM from "react-dom/client";
+
+import { App } from "./app/App";
+import "./app/app.css";
+import { createEditorStore } from "./app/editor-store";
+import { getBrowserStorage, loadOrCreateSceneDocument } from "./serialization/local-draft-storage";
+
+const rootElement = document.getElementById("root");
+
+if (rootElement === null) {
+ throw new Error("Expected #root element to bootstrap the editor.");
+}
+
+const storage = getBrowserStorage();
+const editorStore = createEditorStore({
+ initialDocument: loadOrCreateSceneDocument(storage),
+ storage
+});
+
+ReactDOM.createRoot(rootElement).render(
+
+
+
+);
diff --git a/src/materials/.gitkeep b/src/materials/.gitkeep
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/src/materials/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/src/runtime-three/.gitkeep b/src/runtime-three/.gitkeep
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/src/runtime-three/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/src/serialization/local-draft-storage.ts b/src/serialization/local-draft-storage.ts
new file mode 100644
index 00000000..e6bea3b5
--- /dev/null
+++ b/src/serialization/local-draft-storage.ts
@@ -0,0 +1,45 @@
+import { createEmptySceneDocument, type SceneDocument } from "../document/scene-document";
+
+import { parseSceneDocumentJson, serializeSceneDocument } from "./scene-document-json";
+
+export interface KeyValueStorage {
+ getItem(key: string): string | null;
+ setItem(key: string, value: string): void;
+ removeItem(key: string): void;
+}
+
+export const DEFAULT_SCENE_DRAFT_STORAGE_KEY = "webeditor3d.scene-document-draft";
+
+export function getBrowserStorage(): KeyValueStorage | null {
+ if (typeof window === "undefined") {
+ return null;
+ }
+
+ return window.localStorage;
+}
+
+export function saveSceneDocumentDraft(
+ storage: KeyValueStorage,
+ document: SceneDocument,
+ key = DEFAULT_SCENE_DRAFT_STORAGE_KEY
+) {
+ storage.setItem(key, serializeSceneDocument(document));
+}
+
+export function loadSceneDocumentDraft(storage: KeyValueStorage, key = DEFAULT_SCENE_DRAFT_STORAGE_KEY): SceneDocument | null {
+ const rawDocument = storage.getItem(key);
+
+ if (rawDocument === null) {
+ return null;
+ }
+
+ return parseSceneDocumentJson(rawDocument);
+}
+
+export function loadOrCreateSceneDocument(storage: KeyValueStorage | null, key = DEFAULT_SCENE_DRAFT_STORAGE_KEY): SceneDocument {
+ if (storage === null) {
+ return createEmptySceneDocument();
+ }
+
+ return loadSceneDocumentDraft(storage, key) ?? createEmptySceneDocument();
+}
diff --git a/src/serialization/scene-document-json.ts b/src/serialization/scene-document-json.ts
new file mode 100644
index 00000000..3f7eb36b
--- /dev/null
+++ b/src/serialization/scene-document-json.ts
@@ -0,0 +1,19 @@
+import type { SceneDocument } from "../document/scene-document";
+import { migrateSceneDocument } from "../document/migrate-scene-document";
+
+export function serializeSceneDocument(document: SceneDocument): string {
+ return JSON.stringify(document, null, 2);
+}
+
+export function parseSceneDocumentJson(source: string): SceneDocument {
+ let parsedValue: unknown;
+
+ try {
+ parsedValue = JSON.parse(source);
+ } catch (error) {
+ const cause = error instanceof Error ? error.message : "Unknown JSON parse failure.";
+ throw new Error(`Scene document JSON could not be parsed: ${cause}`);
+ }
+
+ return migrateSceneDocument(parsedValue);
+}
diff --git a/src/shared-ui/Panel.tsx b/src/shared-ui/Panel.tsx
new file mode 100644
index 00000000..cefa893b
--- /dev/null
+++ b/src/shared-ui/Panel.tsx
@@ -0,0 +1,14 @@
+import type { PropsWithChildren } from "react";
+
+interface PanelProps extends PropsWithChildren {
+ title: string;
+}
+
+export function Panel({ title, children }: PanelProps) {
+ return (
+
+ );
+}
diff --git a/src/viewport-three/ViewportCanvas.tsx b/src/viewport-three/ViewportCanvas.tsx
new file mode 100644
index 00000000..557200db
--- /dev/null
+++ b/src/viewport-three/ViewportCanvas.tsx
@@ -0,0 +1,37 @@
+import { useEffect, useRef } from "react";
+
+import type { WorldSettings } from "../document/scene-document";
+
+import { ViewportHost } from "./viewport-host";
+
+interface ViewportCanvasProps {
+ world: WorldSettings;
+}
+
+export function ViewportCanvas({ world }: ViewportCanvasProps) {
+ const containerRef = useRef(null);
+ const hostRef = useRef(null);
+
+ useEffect(() => {
+ const container = containerRef.current;
+
+ if (container === null) {
+ return;
+ }
+
+ const viewportHost = new ViewportHost();
+ hostRef.current = viewportHost;
+ viewportHost.mount(container, world);
+
+ return () => {
+ viewportHost.dispose();
+ hostRef.current = null;
+ };
+ }, []);
+
+ useEffect(() => {
+ hostRef.current?.updateWorld(world);
+ }, [world]);
+
+ return ;
+}
diff --git a/src/viewport-three/viewport-host.ts b/src/viewport-three/viewport-host.ts
new file mode 100644
index 00000000..d8a21396
--- /dev/null
+++ b/src/viewport-three/viewport-host.ts
@@ -0,0 +1,100 @@
+import {
+ AmbientLight,
+ AxesHelper,
+ Color,
+ DirectionalLight,
+ GridHelper,
+ PerspectiveCamera,
+ Scene,
+ Vector3,
+ WebGLRenderer
+} from "three";
+
+import type { WorldSettings } from "../document/scene-document";
+
+export class ViewportHost {
+ private readonly scene = new Scene();
+ private readonly camera = new PerspectiveCamera(60, 1, 0.1, 1000);
+ private readonly renderer = new WebGLRenderer({ antialias: true });
+ private readonly ambientLight = new AmbientLight();
+ private readonly sunLight = new DirectionalLight();
+ private resizeObserver: ResizeObserver | null = null;
+ private animationFrame = 0;
+ private container: HTMLElement | null = null;
+
+ constructor() {
+ this.camera.position.set(8, 8, 8);
+ this.camera.lookAt(new Vector3(0, 0, 0));
+
+ const gridHelper = new GridHelper(40, 40, 0xc68d67, 0x4e596b);
+ const axesHelper = new AxesHelper(2);
+
+ this.scene.add(gridHelper);
+ this.scene.add(axesHelper);
+ this.scene.add(this.ambientLight);
+ this.scene.add(this.sunLight);
+ this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+ }
+
+ mount(container: HTMLElement, world: WorldSettings) {
+ this.container = container;
+ container.appendChild(this.renderer.domElement);
+ this.updateWorld(world);
+ this.resize();
+
+ this.resizeObserver = new ResizeObserver(() => {
+ this.resize();
+ });
+ this.resizeObserver.observe(container);
+
+ this.render();
+ }
+
+ updateWorld(world: WorldSettings) {
+ this.scene.background = new Color(world.background.colorHex);
+ this.ambientLight.color.set(world.ambientLight.colorHex);
+ this.ambientLight.intensity = world.ambientLight.intensity;
+ this.sunLight.color.set(world.sunLight.colorHex);
+ this.sunLight.intensity = world.sunLight.intensity;
+ this.sunLight.position.set(world.sunLight.direction.x, world.sunLight.direction.y, world.sunLight.direction.z).normalize().multiplyScalar(18);
+ }
+
+ dispose() {
+ if (this.animationFrame !== 0) {
+ cancelAnimationFrame(this.animationFrame);
+ this.animationFrame = 0;
+ }
+
+ this.resizeObserver?.disconnect();
+ this.resizeObserver = null;
+ this.renderer.dispose();
+
+ if (this.container !== null && this.container.contains(this.renderer.domElement)) {
+ this.container.removeChild(this.renderer.domElement);
+ }
+
+ this.container = null;
+ }
+
+ private resize() {
+ if (this.container === null) {
+ return;
+ }
+
+ const width = this.container.clientWidth;
+ const height = this.container.clientHeight;
+
+ if (width === 0 || height === 0) {
+ return;
+ }
+
+ this.camera.aspect = width / height;
+ this.camera.updateProjectionMatrix();
+ this.renderer.setSize(width, height, false);
+ }
+
+ private render = () => {
+ this.animationFrame = window.requestAnimationFrame(this.render);
+ this.renderer.render(this.scene, this.camera);
+ };
+}
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
new file mode 100644
index 00000000..11f02fe2
--- /dev/null
+++ b/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/tests/domain/create-empty-scene-document.test.ts b/tests/domain/create-empty-scene-document.test.ts
new file mode 100644
index 00000000..756da148
--- /dev/null
+++ b/tests/domain/create-empty-scene-document.test.ts
@@ -0,0 +1,15 @@
+import { describe, expect, it } from "vitest";
+
+import { SCENE_DOCUMENT_VERSION, createEmptySceneDocument } from "../../src/document/scene-document";
+
+describe("createEmptySceneDocument", () => {
+ it("creates a versioned empty scene document", () => {
+ const document = createEmptySceneDocument();
+
+ expect(document.version).toBe(SCENE_DOCUMENT_VERSION);
+ expect(document.name).toBe("Untitled Scene");
+ expect(document.brushes).toEqual({});
+ expect(document.entities).toEqual({});
+ expect(document.modelInstances).toEqual({});
+ });
+});
diff --git a/tests/domain/editor-store.test.ts b/tests/domain/editor-store.test.ts
new file mode 100644
index 00000000..6349def5
--- /dev/null
+++ b/tests/domain/editor-store.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, it } from "vitest";
+
+import { createEditorStore } from "../../src/app/editor-store";
+import { createSetSceneNameCommand } from "../../src/commands/set-scene-name-command";
+import { createEmptySceneDocument } from "../../src/document/scene-document";
+import type { KeyValueStorage } from "../../src/serialization/local-draft-storage";
+
+class MemoryStorage implements KeyValueStorage {
+ private readonly values = new Map();
+
+ getItem(key: string): string | null {
+ return this.values.get(key) ?? null;
+ }
+
+ setItem(key: string, value: string): void {
+ this.values.set(key, value);
+ }
+
+ removeItem(key: string): void {
+ this.values.delete(key);
+ }
+}
+
+describe("EditorStore", () => {
+ it("applies command history with undo and redo", () => {
+ const store = createEditorStore();
+
+ store.executeCommand(createSetSceneNameCommand("Foundation Room"));
+
+ expect(store.getState().document.name).toBe("Foundation Room");
+ expect(store.getState().canUndo).toBe(true);
+
+ expect(store.undo()).toBe(true);
+ expect(store.getState().document.name).toBe("Untitled Scene");
+ expect(store.getState().canRedo).toBe(true);
+
+ expect(store.redo()).toBe(true);
+ expect(store.getState().document.name).toBe("Foundation Room");
+ });
+
+ it("saves and loads a local draft document", () => {
+ const storage = new MemoryStorage();
+ const writerStore = createEditorStore({ storage });
+
+ writerStore.executeCommand(createSetSceneNameCommand("Draft Scene"));
+ expect(writerStore.saveDraft()).toBe(true);
+
+ const readerStore = createEditorStore({
+ initialDocument: createEmptySceneDocument({ name: "Fresh Scene" }),
+ storage
+ });
+
+ expect(readerStore.loadDraft()).toBe(true);
+ expect(readerStore.getState().document.name).toBe("Draft Scene");
+ });
+});
diff --git a/tests/e2e/app-smoke.e2e.ts b/tests/e2e/app-smoke.e2e.ts
new file mode 100644
index 00000000..8632d6f4
--- /dev/null
+++ b/tests/e2e/app-smoke.e2e.ts
@@ -0,0 +1,24 @@
+import { expect, test } from "@playwright/test";
+
+test("app boots and shows the viewport shell", async ({ page }) => {
+ const pageErrors: string[] = [];
+ const consoleErrors: string[] = [];
+
+ page.on("pageerror", (error) => {
+ pageErrors.push(error.message);
+ });
+
+ page.on("console", (message) => {
+ if (message.type() === "error") {
+ consoleErrors.push(message.text());
+ }
+ });
+
+ await page.goto("/");
+
+ await expect(page.getByText("WebEditor3D")).toBeVisible();
+ await expect(page.getByTestId("viewport-shell")).toBeVisible();
+
+ expect(pageErrors).toEqual([]);
+ expect(consoleErrors).toEqual([]);
+});
diff --git a/tests/serialization/scene-document-json.test.ts b/tests/serialization/scene-document-json.test.ts
new file mode 100644
index 00000000..9631b989
--- /dev/null
+++ b/tests/serialization/scene-document-json.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from "vitest";
+
+import { createEmptySceneDocument } from "../../src/document/scene-document";
+import { migrateSceneDocument } from "../../src/document/migrate-scene-document";
+import { parseSceneDocumentJson, serializeSceneDocument } from "../../src/serialization/scene-document-json";
+
+describe("scene document JSON", () => {
+ it("round-trips the current empty schema", () => {
+ const document = createEmptySceneDocument({ name: "Bootstrap Scene" });
+ const serializedDocument = serializeSceneDocument(document);
+
+ expect(parseSceneDocumentJson(serializedDocument)).toEqual(document);
+ });
+
+ it("rejects unsupported versions", () => {
+ expect(() =>
+ migrateSceneDocument({
+ version: 99,
+ name: "Legacy",
+ world: {},
+ materials: {},
+ textures: {},
+ assets: {},
+ brushes: {},
+ modelInstances: {},
+ entities: {},
+ interactionLinks: {}
+ })
+ ).toThrow("Unsupported scene document version");
+ });
+});
diff --git a/tests/setup/vitest.setup.ts b/tests/setup/vitest.setup.ts
new file mode 100644
index 00000000..f149f27a
--- /dev/null
+++ b/tests/setup/vitest.setup.ts
@@ -0,0 +1 @@
+import "@testing-library/jest-dom/vitest";
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 00000000..5ea6db5b
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "verbatimModuleSyntax": true,
+ "jsx": "react-jsx",
+ "types": ["node", "vite/client", "vitest/globals"]
+ },
+ "include": ["src", "tests", "vite.config.ts", "vitest.config.ts", "playwright.config.ts"]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 00000000..ce28aaa3
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ host: "0.0.0.0",
+ port: 5173
+ }
+});
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 00000000..9c7afdf0
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,15 @@
+import { defineConfig, mergeConfig } from "vitest/config";
+
+import viteConfig from "./vite.config";
+
+export default mergeConfig(
+ viteConfig,
+ defineConfig({
+ test: {
+ environment: "jsdom",
+ globals: true,
+ setupFiles: ["./tests/setup/vitest.setup.ts"],
+ include: ["tests/**/*.test.ts", "tests/**/*.test.tsx"]
+ }
+ })
+);