diff --git a/frontend/libs/components/pagescene/package.json b/frontend/libs/components/pagescene/package.json
new file mode 100644
index 0000000000..07427048f3
--- /dev/null
+++ b/frontend/libs/components/pagescene/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "@storyteller/ui-pagescene",
+ "version": "0.0.1",
+ "type": "module",
+ "main": "./dist/index.js",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ "./package.json": "./package.json",
+ ".": {
+ "development": "./src/index.ts",
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js",
+ "default": "./dist/index.js"
+ }
+ }
+}
diff --git a/frontend/libs/components/pagescene/src/index.ts b/frontend/libs/components/pagescene/src/index.ts
new file mode 100644
index 0000000000..95d0f6301e
--- /dev/null
+++ b/frontend/libs/components/pagescene/src/index.ts
@@ -0,0 +1,4 @@
+// Public API for @storyteller/ui-pagescene.
+// (Stub during scaffold; populated incrementally as files migrate.)
+
+export {};
diff --git a/frontend/libs/components/pagescene/src/lib/adapter.ts b/frontend/libs/components/pagescene/src/lib/adapter.ts
new file mode 100644
index 0000000000..ce5add8bd2
--- /dev/null
+++ b/frontend/libs/components/pagescene/src/lib/adapter.ts
@@ -0,0 +1,111 @@
+// Single platform-abstraction surface the host (artcraft Tauri app or
+// any future web host) injects into PageScene. Mirrors the shape of
+// PageDrawAdapter — methods for Tauri-specific generation/upload, slot
+// renderers for host-owned modals, and optional event hooks for
+// telemetry / error surfacing / tab persistence.
+//
+// The library itself has zero Tauri imports. Everything platform-
+// specific is on this interface; the adapter is constructed in the
+// host wrapper and passed to .
+
+import type { ReactNode } from "react";
+import type {
+ CommonAspectRatio,
+ CommonResolution,
+ ImageModel,
+} from "@storyteller/model-list";
+import type { GenerationProvider } from "@storyteller/api-enums";
+
+// ─── Generation ────────────────────────────────────────────────────────
+
+export interface PageSceneGenerateRequest {
+ model?: ImageModel;
+ provider?: GenerationProvider;
+ prompt?: string;
+ imageMediaTokens?: string[];
+ sceneImageMediaToken?: string;
+ imageCount?: number;
+ aspectRatio?: CommonAspectRatio;
+ resolution?: CommonResolution;
+ frontendCaller?: string;
+ frontendSubscriberId?: string;
+}
+
+// ─── Scene I/O ─────────────────────────────────────────────────────────
+
+export interface PageSceneSavePayload {
+ saveJson: string;
+ sceneTitle: string;
+ sceneToken?: string;
+ sceneThumbnail: Blob | undefined;
+}
+
+// ─── Adapter ───────────────────────────────────────────────────────────
+
+export interface PageSceneAdapter {
+ // Generation enqueue. Same shape as PageDrawAdapter.enqueueEditImage.
+ enqueueGeneration(
+ req: PageSceneGenerateRequest,
+ ): Promise<{ status: string }>;
+
+ // Scene I/O — replaces the previous engine/api_manager.ts +
+ // engine/api_fetchers.ts. The host owns all HTTP / FetchProxy
+ // plumbing.
+ saveScene(payload: PageSceneSavePayload): Promise;
+ loadScene(token: string): Promise;
+ // Wraps Tauri-flavored CORS-bypassed fetches. Used by Scene's GLTF
+ // loader paths that resolve CDN URLs the browser can't fetch directly.
+ fetchAsset(url: string): Promise;
+
+ // Hosts. Engine builds CDN URLs (`${cdnOrigin}${bucket_path}`) and
+ // API URLs (`${apiSchemeAndHost}/v1/...`); the host supplies both.
+ getCdnOrigin(): string;
+ getApiSchemeAndHost(): string;
+ getCurrentUserToken?(): string | undefined;
+
+ // Slot renders for host-owned UI. The library renders these inside
+ // its own AssetMenu / scene-load modal containers — same shape as
+ // PageDrawAdapter.renderBaseImageSelector.
+ renderAssetBrowser(props: {
+ onAssetSelect: (asset: {
+ mediaToken: string;
+ name: string;
+ kind: string;
+ }) => void;
+ }): ReactNode;
+ renderSceneLoader(props: {
+ onSceneSelect: (token: string) => void;
+ }): ReactNode;
+
+ // Optional event hooks — telemetry, host-side modals, tab title sync.
+ onSelectionChange?(
+ sel: { uuid: string; assetType: string } | null,
+ ): void;
+ onSceneDirty?(dirty: boolean): void;
+ onError?(err: { title: string; message: string }): void;
+ onSceneSaved?(token: string): void;
+ // Wraps the host's `signalScene(...)` so the artcraft TopBar (and
+ // other app-wide consumers) keeps seeing scene title/owner/dirty
+ // state without the lib importing the host signal.
+ onSceneTitleChange?(meta: {
+ title: string;
+ token?: string;
+ ownerToken?: string;
+ isModified: boolean;
+ }): void;
+ onEnqueueMeta?(meta: {
+ prompt: string;
+ refImageUrls: string[];
+ modelType: string;
+ timestamp: number;
+ }): void;
+
+ // Tab-cache integration. The host (artcraft useTabStore) reads/
+ // writes the serialized scene JSON between tab switches; the library
+ // is single-instance and tab-agnostic.
+ cacheJsonString?: string;
+ onSceneSerialized?(json: string): void;
+
+ // Initial scene to load on mount (the route param in artcraft).
+ initialSceneToken?: string;
+}
diff --git a/frontend/libs/components/pagescene/tsconfig.json b/frontend/libs/components/pagescene/tsconfig.json
new file mode 100644
index 0000000000..162ab9faec
--- /dev/null
+++ b/frontend/libs/components/pagescene/tsconfig.json
@@ -0,0 +1,70 @@
+{
+ "files": [],
+ "include": [],
+ "references": [
+ {
+ "path": "../../common"
+ },
+ {
+ "path": "../../api"
+ },
+ {
+ "path": "../../api-enums"
+ },
+ {
+ "path": "../../model-list"
+ },
+ {
+ "path": "../../soundboard"
+ },
+ {
+ "path": "../promptbox"
+ },
+ {
+ "path": "../popover"
+ },
+ {
+ "path": "../loading"
+ },
+ {
+ "path": "../toaster"
+ },
+ {
+ "path": "../gallery-modal"
+ },
+ {
+ "path": "../model-selector"
+ },
+ {
+ "path": "../help-menu"
+ },
+ {
+ "path": "../login-modal"
+ },
+ {
+ "path": "../settings-modal"
+ },
+ {
+ "path": "../modal"
+ },
+ {
+ "path": "../tooltip"
+ },
+ {
+ "path": "../button"
+ },
+ {
+ "path": "../input"
+ },
+ {
+ "path": "../slider-v2"
+ },
+ {
+ "path": "./tsconfig.lib.json"
+ },
+ {
+ "path": "./tsconfig.spec.json"
+ }
+ ],
+ "extends": "../../../tsconfig.base.json"
+}
diff --git a/frontend/libs/components/pagescene/tsconfig.lib.json b/frontend/libs/components/pagescene/tsconfig.lib.json
new file mode 100644
index 0000000000..17841e8e11
--- /dev/null
+++ b/frontend/libs/components/pagescene/tsconfig.lib.json
@@ -0,0 +1,105 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "lib": ["dom", "esnext"],
+ "types": [
+ "node",
+ "@nx/react/typings/cssmodule.d.ts",
+ "@nx/react/typings/image.d.ts",
+ "vite/client"
+ ],
+ "rootDir": "src",
+ "jsx": "react-jsx",
+ "tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo"
+ },
+ "exclude": [
+ "out-tsc",
+ "dist",
+ "**/*.spec.ts",
+ "**/*.test.ts",
+ "**/*.spec.tsx",
+ "**/*.test.tsx",
+ "**/*.spec.js",
+ "**/*.test.js",
+ "**/*.spec.jsx",
+ "**/*.test.jsx",
+ "vite.config.ts",
+ "vite.config.mts",
+ "vitest.config.ts",
+ "vitest.config.mts",
+ "src/**/*.test.ts",
+ "src/**/*.spec.ts",
+ "src/**/*.test.tsx",
+ "src/**/*.spec.tsx",
+ "src/**/*.test.js",
+ "src/**/*.spec.js",
+ "src/**/*.test.jsx",
+ "src/**/*.spec.jsx"
+ ],
+ "include": [
+ "src/**/*.js",
+ "src/**/*.jsx",
+ "src/**/*.ts",
+ "src/**/*.tsx"
+ ],
+ "references": [
+ {
+ "path": "../../common/tsconfig.lib.json"
+ },
+ {
+ "path": "../../api/tsconfig.lib.json"
+ },
+ {
+ "path": "../../api-enums/tsconfig.lib.json"
+ },
+ {
+ "path": "../../model-list/tsconfig.lib.json"
+ },
+ {
+ "path": "../../soundboard/tsconfig.lib.json"
+ },
+ {
+ "path": "../promptbox/tsconfig.lib.json"
+ },
+ {
+ "path": "../popover/tsconfig.lib.json"
+ },
+ {
+ "path": "../loading/tsconfig.lib.json"
+ },
+ {
+ "path": "../toaster/tsconfig.lib.json"
+ },
+ {
+ "path": "../gallery-modal/tsconfig.lib.json"
+ },
+ {
+ "path": "../model-selector/tsconfig.lib.json"
+ },
+ {
+ "path": "../help-menu/tsconfig.lib.json"
+ },
+ {
+ "path": "../login-modal/tsconfig.lib.json"
+ },
+ {
+ "path": "../settings-modal/tsconfig.lib.json"
+ },
+ {
+ "path": "../modal/tsconfig.lib.json"
+ },
+ {
+ "path": "../tooltip/tsconfig.lib.json"
+ },
+ {
+ "path": "../button/tsconfig.lib.json"
+ },
+ {
+ "path": "../input/tsconfig.lib.json"
+ },
+ {
+ "path": "../slider-v2/tsconfig.lib.json"
+ }
+ ]
+}
diff --git a/frontend/libs/components/pagescene/tsconfig.spec.json b/frontend/libs/components/pagescene/tsconfig.spec.json
new file mode 100644
index 0000000000..18b7de51b8
--- /dev/null
+++ b/frontend/libs/components/pagescene/tsconfig.spec.json
@@ -0,0 +1,34 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "./out-tsc/vitest",
+ "types": [
+ "vitest/globals",
+ "vitest/importMeta",
+ "vite/client",
+ "node",
+ "vitest"
+ ],
+ "jsx": "react-jsx"
+ },
+ "include": [
+ "vite.config.ts",
+ "vite.config.mts",
+ "vitest.config.ts",
+ "vitest.config.mts",
+ "src/**/*.test.ts",
+ "src/**/*.spec.ts",
+ "src/**/*.test.tsx",
+ "src/**/*.spec.tsx",
+ "src/**/*.test.js",
+ "src/**/*.spec.js",
+ "src/**/*.test.jsx",
+ "src/**/*.spec.jsx",
+ "src/**/*.d.ts"
+ ],
+ "references": [
+ {
+ "path": "./tsconfig.lib.json"
+ }
+ ]
+}
diff --git a/frontend/libs/components/pagescene/vite.config.ts b/frontend/libs/components/pagescene/vite.config.ts
new file mode 100644
index 0000000000..c774f0df5a
--- /dev/null
+++ b/frontend/libs/components/pagescene/vite.config.ts
@@ -0,0 +1,40 @@
+///
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+import dts from 'vite-plugin-dts';
+import * as path from 'path';
+import { isExternal } from '../../shared-vite-config';
+
+export default defineConfig(() => ({
+ root: __dirname,
+ cacheDir: '../../../node_modules/.vite/libs/components/pagescene',
+ plugins: [react(), dts({ entryRoot: 'src', tsconfigPath: path.join(__dirname, 'tsconfig.lib.json') })],
+ build: {
+ outDir: './dist',
+ emptyOutDir: true,
+ reportCompressedSize: true,
+ commonjsOptions: {
+ transformMixedEsModules: true,
+ },
+ lib: {
+ entry: 'src/index.ts',
+ name: '@storyteller/ui-pagescene',
+ fileName: 'index',
+ formats: ['es' as const]
+ },
+ rollupOptions: {
+ external: isExternal
+ },
+ },
+ test: {
+ watch: false,
+ globals: true,
+ environment: 'jsdom',
+ include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
+ reporters: ['default'],
+ coverage: {
+ reportsDirectory: './test-output/vitest/coverage',
+ provider: 'v8' as const,
+ }
+ },
+}));