@moloodev/layout-sdk 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,149 @@
1
- # Temporary Holding Version
1
+ # Layout runtime
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Runtime foundation for the layout editor. This package does not mount an editor,
4
+ fetch assets, install packages, or own persistence. See the
5
+ [living spec](../../docs/superpowers/specs/2026-10-20-layout-catalog-runtime.md).
6
+
7
+ ```tsx
8
+ import { createStateStore } from "@json-render/core";
9
+ import { createExampleCatalogs } from "@moloodev/layout-sdk/catalog-format/fixtures";
10
+ import { resolveCatalog } from "@moloodev/layout-sdk/catalog";
11
+ import { Canvas } from "@react-three/fiber";
12
+ import { prepareSurface } from "@moloodev/layout-sdk";
13
+ import { examplePackages } from "@moloodev/layout-sdk/examples";
14
+ import { DomSurface } from "@moloodev/layout-sdk/react";
15
+ import { ThreeScene } from "@moloodev/layout-sdk/three";
16
+
17
+ // Supply real workspace asset IDs. These examples do not create assets.
18
+ const { puzzle, winter } = createExampleCatalogs({
19
+ puzzle: puzzleCatalogId,
20
+ tileImage: tileImageId,
21
+ winterImage: winterImageId,
22
+ buildingModel: buildingModelId,
23
+ });
24
+ const catalog = resolveCatalog([
25
+ { assetId: puzzleCatalogId, content: puzzle },
26
+ { assetId: winterCatalogId, content: winter },
27
+ ]);
28
+ const spec = {
29
+ root: "tile",
30
+ elements: {
31
+ tile: {
32
+ type: "puzzle.Tile",
33
+ props: { label: "Snowflake tile", selected: { $state: "/selected" } },
34
+ on: { press: { action: "select" } },
35
+ },
36
+ },
37
+ };
38
+ // Resolve image/model URLs through the application's existing asset access.
39
+ const prepare = (target: string) =>
40
+ prepareSurface({
41
+ catalog,
42
+ target,
43
+ spec,
44
+ packages: examplePackages,
45
+ resolveResource,
46
+ });
47
+ const hud = prepare("react-dom");
48
+ const scene = prepare("react-three-fiber");
49
+ const store = createStateStore({ selected: false });
50
+ const handlers = { select: () => store.set("/selected", true) };
51
+
52
+ <div>
53
+ <DomSurface surface={hud} handlers={handlers} store={store} />
54
+ <Canvas>
55
+ <ThreeScene surface={scene} handlers={handlers} store={store} />
56
+ </Canvas>
57
+ </div>
58
+ ```
59
+
60
+ Use `./react` for a DOM host and `./three` for a scene under an existing R3F
61
+ root. `./examples` registers the sample DOM and R3F implementations. Catalog assembly
62
+ and server-side reference inspection do not need React or Three.
63
+
64
+ Run `pnpm --filter @moloodev/layout-sdk/catalog-format test`,
65
+ `pnpm --filter @moloodev/layout-sdk/catalog test`, and
66
+ `pnpm --filter @moloodev/layout-sdk test` from the repository root.
67
+ UI verification belongs to the human; these are unit tests, not browser tests.
68
+
69
+ `prepareLayout` reads the saved layout's explicit catalog stack and prepares all
70
+ surfaces, reusing the prepared spec copies returned by `validateLayout` rather
71
+ than repeating per-surface validation. `composeSurfaces(prepared, boundary)` makes
72
+ per-element runtime registry entries and supplies mounted children through `RuntimeComponentProps.surfaces`.
73
+ The injected boundary callback owns renderer transitions. The host supplies the
74
+ Canvas for a DOM → R3F mount and renders `ThreeScene` for an R3F → R3F mount.
75
+ Saved component keys, props and native children are
76
+ unchanged. Unsupported transitions must fail explicitly.
77
+
78
+ An optional third argument to `composeSurfaces` is an `ElementObserver` component.
79
+ It receives `{ identity: { surface, element, type, target }, render(rootRef) }`.
80
+ Call `render(rootRef)` to observe the implementation's native root without adding
81
+ a DOM wrapper or Three group. Implementations opt in by attaching the optional
82
+ `RuntimeComponentProps.rootRef` to their primary DOM element or Object3D; all basic
83
+ and example implementations do this. Forward the ref's null notification on
84
+ unmount. The observer must tolerate no root (suspense/visibility) and multiple
85
+ instances of the same spec element (repeat). The original component type, native
86
+ children, named slots and mounted surface identities are preserved. This is a
87
+ runtime-only observation contract; selection, highlighting and interaction modes
88
+ belong to the editor's renderer adapters, not catalog JSON or saved props.
89
+
90
+ Observers also receive `nativeSlots`, the component contract's native slot names
91
+ (including `default`, even when empty). Surface mount slots are excluded.
92
+ An observer can call `render(rootRef, slotRefs)`; only declared native names reach
93
+ the implementation's optional `RuntimeComponentProps.slotRefs`. Existing observers
94
+ that only pass `rootRef`, and renderers without an observer, keep working.
95
+
96
+ Attach `slotRefs?.header` directly to the implementation's actual header container,
97
+ and `slotRefs?.default` to its native children container. No wrapper is introduced.
98
+ These are React 19 callback refs that may return a cleanup for each mounted root.
99
+ If forwarding through another callback, return that cleanup; do not discard it.
100
+ Each repeated instance has separate registrations. Multiple roots for one slot
101
+ remain observable so consumers can reject ambiguous geometry. Slots are transient
102
+ renderer observations, not element selection roots or persisted metadata.
103
+ This optional observer extension changes neither catalog/layout schemas nor the
104
+ implementation ABI or existing pinned package references.
105
+
106
+ `@moloodev/layout-sdk/basic` exports `basicCatalog` and `basicPackages`.
107
+ The catalog defines DOM Stack, Grid, Frame, Panel, Text, Button, and Viewport,
108
+ plus R3F Group, Transform, and Box. It has no resource dependencies.
109
+ Every DOM definition pins `@moloodev/layout-basic@1.3.0`.
110
+ Every R3F definition pins `@moloodev/layout-basic@1.1.0`.
111
+ `basicPackages` installs those two packages.
112
+ Grid uses row auto-placement, 1 to 12 equal-width columns, and a configurable gap.
113
+ Frame is a border-box column flex container with integer width (64–1600px) and
114
+ height (64–1200px), defaulting to 320×240px. It declares `editor.resize["react-dom"]`
115
+ property mappings. The runtime owns no resize gestures.
116
+ Panel supplies header, default, and footer native slots with column flex containers
117
+ and optional `slotRefs`, including empty regions with a 48px minimum height.
118
+ Its gap property applies both between slots and within them, ranging from 0 to
119
+ 128px (default 16). Editor-only empty-slot hints are not part of Panel's output.
120
+ Import the catalog JSON as a workspace `.mcat` asset and reference its actual asset
121
+ ID. The runtime does not invent built-in asset IDs or install catalogs into workspaces.
122
+
123
+ Example Tile labels are supplied through `props.label` (default: "Tile"). Set a
124
+ meaningful label for each tile; the DOM implementation exposes it as its accessible
125
+ name and shows selection with an inset shadow, leaving the browser's focus outline
126
+ available for keyboard navigation. In forced colors, a double border marks selection
127
+ using system colors. Both states reserve the same border width inside the tile's
128
+ dimensions. React hoists and deduplicates the example's stylesheet; consumers do not
129
+ need a separate CSS import. Interactive Three examples consume
130
+ click propagation so occluded objects and parent handlers do not also activate.
131
+
132
+ Catalog documents are schema version 1. Optional `editor.transform[target]`
133
+ maps numeric props to local position, Euler XYZ rotation in radians, and
134
+ unitless scale. Each operation maps one or more x/y/z axes. Properties must
135
+ be distinct across operations for a target.
136
+ Omitted axes remain unchanged. Implementations that opt in must apply these
137
+ semantics to the observed root, including when a seasonal override supplies the
138
+ values. Metadata stays owned by the original definition. A document whose
139
+ version is not 1 is `unsupported_schema`. Exact implementation versions and ABI
140
+ stay unchanged. This contract does not add transform gestures or infer support
141
+ for existing Group/Box components.
142
+
143
+ `basic.Transform` opts into the transform contract with `positionX/Y/Z`,
144
+ `rotationX/Y/Z`, and `scaleX/Y/Z`. It renders an observed Three Group with a default
145
+ child slot, local Euler XYZ rotation, and defaults of zero position/rotation and
146
+ unit scale. Scale props have a minimum of 0.01; position and rotation accept any
147
+ finite number. json-render state bindings update the group without changing the
148
+ stored spec. Group and Box share `@moloodev/layout-basic@1.1.0` and do not declare
149
+ `editor.transform`. The runtime adds no gesture controls.
package/package.json CHANGED
@@ -1,6 +1,53 @@
1
1
  {
2
2
  "name": "@moloodev/layout-sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": "./src/index.ts",
7
+ "./format": "./src/layout-format/index.ts",
8
+ "./catalog": "./src/catalog/index.ts",
9
+ "./catalog-format": "./src/catalog-format/index.ts",
10
+ "./catalog-format/fixtures": "./src/catalog-format/fixtures.ts",
11
+ "./react": "./src/react.tsx",
12
+ "./three": "./src/three.tsx",
13
+ "./examples": "./src/examples/index.ts",
14
+ "./basic": "./src/basic.tsx"
15
+ },
16
+ "dependencies": {
17
+ "@json-render/core": "^0.20.0",
18
+ "@json-render/react": "^0.20.0",
19
+ "@react-three/fiber": "9.8.2",
20
+ "three": "0.186.1",
21
+ "zod": "^4.5.4",
22
+ "@moloodev/platform": "0.1.0",
23
+ "@moloodev/editor-sdk": "0.1.0"
24
+ },
25
+ "peerDependencies": {
26
+ "react": "^19.0.0",
27
+ "react-dom": "^19.0.0"
28
+ },
29
+ "devDependencies": {
30
+ "@react-three/test-renderer": "9.1.1",
31
+ "@types/node": "^24.13.3",
32
+ "@types/react": "^19.2.18",
33
+ "@types/react-dom": "^19.2.7",
34
+ "@types/three": "0.186.0",
35
+ "react": "^19.2.8",
36
+ "react-dom": "^19.2.8",
37
+ "tsx": "^4.23.13",
38
+ "typescript": "^7.0.2",
39
+ "@moloodev/typescript-config": "0.1.0"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "files": [
45
+ "src",
46
+ "!src/**/*.test.ts",
47
+ "!src/**/*.test.tsx"
48
+ ],
49
+ "scripts": {
50
+ "typecheck": "tsc --noEmit",
51
+ "test": "tsx --test 'src/**/*.test.ts' 'src/**/*.test.tsx'"
52
+ }
6
53
  }
package/src/basic.tsx ADDED
@@ -0,0 +1,366 @@
1
+ import type { CSSProperties } from "react";
2
+ import type {
3
+ ComponentContract,
4
+ ComponentDefinition,
5
+ Content,
6
+ ObjectSchema,
7
+ } from "./catalog-format";
8
+ import type { ImplementationPackage, RuntimeComponentProps } from "./runtime";
9
+
10
+ const props = (properties: ObjectSchema["properties"]): ObjectSchema => ({
11
+ type: "object",
12
+ properties,
13
+ required: Object.keys(properties),
14
+ additionalProperties: false,
15
+ });
16
+ const contract = (
17
+ properties: ObjectSchema["properties"],
18
+ slots = false,
19
+ events: string[] = [],
20
+ ): ComponentContract => ({
21
+ version: 1,
22
+ props: props(properties),
23
+ slots: slots ? { default: { accepts: [] } } : {},
24
+ events,
25
+ resources: {},
26
+ });
27
+ const contracts = {
28
+ Stack: contract(
29
+ {
30
+ direction: { type: "string", enum: ["row", "column"] },
31
+ gap: { type: "number", minimum: 0, maximum: 128 },
32
+ },
33
+ true,
34
+ ),
35
+ Grid: contract(
36
+ {
37
+ columns: { type: "integer", minimum: 1, maximum: 12 },
38
+ gap: { type: "number", minimum: 0, maximum: 128 },
39
+ },
40
+ true,
41
+ ),
42
+ Frame: contract(
43
+ {
44
+ width: { type: "integer", minimum: 64, maximum: 1600 },
45
+ height: { type: "integer", minimum: 64, maximum: 1200 },
46
+ },
47
+ true,
48
+ ),
49
+ Panel: {
50
+ ...contract({ gap: { type: "number", minimum: 0, maximum: 128 } }),
51
+ slots: {
52
+ header: { accepts: [] },
53
+ default: { accepts: [] },
54
+ footer: { accepts: [] },
55
+ },
56
+ },
57
+ Text: contract({
58
+ text: { type: "string" },
59
+ size: { type: "number", minimum: 8, maximum: 96 },
60
+ }),
61
+ Button: contract({ text: { type: "string" } }, false, ["press"]),
62
+ Viewport: {
63
+ ...contract({ height: { type: "number", minimum: 100, maximum: 1200 } }),
64
+ surfaceSlots: { scene: { target: "react-three-fiber" } },
65
+ },
66
+ Group: contract({}, true),
67
+ Transform: contract(
68
+ {
69
+ positionX: { type: "number" },
70
+ positionY: { type: "number" },
71
+ positionZ: { type: "number" },
72
+ rotationX: { type: "number" },
73
+ rotationY: { type: "number" },
74
+ rotationZ: { type: "number" },
75
+ scaleX: { type: "number", minimum: 0.01 },
76
+ scaleY: { type: "number", minimum: 0.01 },
77
+ scaleZ: { type: "number", minimum: 0.01 },
78
+ },
79
+ true,
80
+ ),
81
+ Box: contract(
82
+ {
83
+ color: { type: "string" },
84
+ size: { type: "number", minimum: 0.1, maximum: 10 },
85
+ },
86
+ false,
87
+ ["press"],
88
+ ),
89
+ };
90
+ const packageVersion = {
91
+ "react-dom": "1.3.0",
92
+ "react-three-fiber": "1.1.0",
93
+ };
94
+ function definition(
95
+ entry: keyof typeof contracts,
96
+ target: keyof typeof packageVersion,
97
+ defaults: ComponentDefinition["defaults"],
98
+ layout: "leaf" | "flow" | "grid" | "spatial",
99
+ editing?: Pick<ComponentDefinition["editor"], "resize" | "transform">,
100
+ ): ComponentDefinition {
101
+ return {
102
+ key: `basic.${entry}`,
103
+ contract: contracts[entry],
104
+ description: entry,
105
+ defaults,
106
+ resources: {},
107
+ implementations: {
108
+ [target]: {
109
+ package: "@moloodev/layout-basic",
110
+ version: packageVersion[target],
111
+ entry,
112
+ abi: 1,
113
+ },
114
+ },
115
+ editor: {
116
+ category: target === "react-dom" ? "화면" : "3D 장면",
117
+ layout: { [target]: layout },
118
+ ...editing,
119
+ },
120
+ };
121
+ }
122
+
123
+ /** Download/import as an ordinary workspace asset. No virtual catalog IDs or automatic installation. */
124
+ export const basicCatalog: Content = {
125
+ version: 1,
126
+ requires: [],
127
+ entries: [
128
+ definition("Stack", "react-dom", { direction: "column", gap: 16 }, "flow"),
129
+ definition("Grid", "react-dom", { columns: 3, gap: 16 }, "grid"),
130
+ definition(
131
+ "Frame",
132
+ "react-dom",
133
+ { width: 320, height: 240 },
134
+ "flow",
135
+ { resize: { "react-dom": { width: "width", height: "height" } } },
136
+ ),
137
+ definition("Panel", "react-dom", { gap: 16 }, "flow"),
138
+ definition("Text", "react-dom", { text: "새 텍스트", size: 24 }, "leaf"),
139
+ definition("Button", "react-dom", { text: "버튼" }, "leaf"),
140
+ definition("Viewport", "react-dom", { height: 320 }, "leaf"),
141
+ definition("Group", "react-three-fiber", {}, "spatial"),
142
+ definition(
143
+ "Transform",
144
+ "react-three-fiber",
145
+ {
146
+ positionX: 0,
147
+ positionY: 0,
148
+ positionZ: 0,
149
+ rotationX: 0,
150
+ rotationY: 0,
151
+ rotationZ: 0,
152
+ scaleX: 1,
153
+ scaleY: 1,
154
+ scaleZ: 1,
155
+ },
156
+ "spatial",
157
+ {
158
+ transform: {
159
+ "react-three-fiber": {
160
+ position: { x: "positionX", y: "positionY", z: "positionZ" },
161
+ rotation: { x: "rotationX", y: "rotationY", z: "rotationZ" },
162
+ scale: { x: "scaleX", y: "scaleY", z: "scaleZ" },
163
+ },
164
+ },
165
+ },
166
+ ),
167
+ definition(
168
+ "Box",
169
+ "react-three-fiber",
170
+ { color: "#76a9fa", size: 1 },
171
+ "leaf",
172
+ ),
173
+ ],
174
+ };
175
+
176
+ function Stack({ element, children, rootRef }: RuntimeComponentProps) {
177
+ return (
178
+ <div
179
+ ref={rootRef}
180
+ style={{
181
+ display: "flex",
182
+ flexDirection: element.props.direction as "row" | "column",
183
+ gap: Number(element.props.gap),
184
+ padding: 16,
185
+ }}
186
+ >
187
+ {children}
188
+ </div>
189
+ );
190
+ }
191
+ function Text({ element, rootRef }: RuntimeComponentProps) {
192
+ return (
193
+ <span ref={rootRef} style={{ fontSize: Number(element.props.size) }}>
194
+ {String(element.props.text)}
195
+ </span>
196
+ );
197
+ }
198
+ function Grid({ element, children, rootRef }: RuntimeComponentProps) {
199
+ return (
200
+ <div
201
+ ref={rootRef}
202
+ style={{
203
+ display: "grid",
204
+ gridTemplateColumns: `repeat(${Number(element.props.columns)}, minmax(0, 1fr))`,
205
+ gridAutoFlow: "row",
206
+ gap: Number(element.props.gap),
207
+ padding: 16,
208
+ minHeight: 80,
209
+ }}
210
+ >
211
+ {children}
212
+ </div>
213
+ );
214
+ }
215
+ function Frame({ element, children, rootRef }: RuntimeComponentProps) {
216
+ return (
217
+ <div
218
+ ref={rootRef}
219
+ style={{
220
+ display: "flex",
221
+ flexDirection: "column",
222
+ gap: 16,
223
+ padding: 16,
224
+ boxSizing: "border-box",
225
+ flexShrink: 0,
226
+ width: Number(element.props.width),
227
+ height: Number(element.props.height),
228
+ overflow: "auto",
229
+ }}
230
+ >
231
+ {children}
232
+ </div>
233
+ );
234
+ }
235
+ function Panel({
236
+ element,
237
+ children,
238
+ slots,
239
+ rootRef,
240
+ slotRefs,
241
+ }: RuntimeComponentProps) {
242
+ const gap = Number(element.props.gap);
243
+ const style: CSSProperties = {
244
+ display: "flex",
245
+ flexDirection: "column",
246
+ gap,
247
+ minHeight: 48,
248
+ minWidth: 80,
249
+ };
250
+ return (
251
+ <section
252
+ ref={rootRef}
253
+ style={{ display: "flex", flexDirection: "column", gap, padding: 16 }}
254
+ >
255
+ <header ref={slotRefs?.header} style={style}>
256
+ {slots?.header}
257
+ </header>
258
+ <div ref={slotRefs?.default} style={style}>
259
+ {children}
260
+ </div>
261
+ <footer ref={slotRefs?.footer} style={style}>
262
+ {slots?.footer}
263
+ </footer>
264
+ </section>
265
+ );
266
+ }
267
+ function Button({ element, emit, rootRef }: RuntimeComponentProps) {
268
+ return (
269
+ <button
270
+ ref={rootRef}
271
+ type="button"
272
+ style={{
273
+ border: "1px solid currentColor",
274
+ padding: "8px 16px",
275
+ borderRadius: 6,
276
+ }}
277
+ onClick={() => emit("press")}
278
+ >
279
+ {String(element.props.text)}
280
+ </button>
281
+ );
282
+ }
283
+ function Viewport({ element, surfaces, rootRef }: RuntimeComponentProps) {
284
+ return (
285
+ <div
286
+ ref={rootRef}
287
+ style={{ height: Number(element.props.height), minWidth: 200 }}
288
+ >
289
+ {surfaces?.scene ?? <p>3D surface를 연결하세요.</p>}
290
+ </div>
291
+ );
292
+ }
293
+ function Group({ children, rootRef }: RuntimeComponentProps) {
294
+ return <group ref={rootRef}>{children}</group>;
295
+ }
296
+ function Transform({ element, children, rootRef }: RuntimeComponentProps) {
297
+ const value = element.props;
298
+ return (
299
+ <group
300
+ ref={rootRef}
301
+ position={[
302
+ Number(value.positionX),
303
+ Number(value.positionY),
304
+ Number(value.positionZ),
305
+ ]}
306
+ rotation={[
307
+ Number(value.rotationX),
308
+ Number(value.rotationY),
309
+ Number(value.rotationZ),
310
+ "XYZ",
311
+ ]}
312
+ scale={[Number(value.scaleX), Number(value.scaleY), Number(value.scaleZ)]}
313
+ >
314
+ {children}
315
+ </group>
316
+ );
317
+ }
318
+ function Box({ element, emit, rootRef }: RuntimeComponentProps) {
319
+ return (
320
+ <mesh
321
+ ref={rootRef}
322
+ onClick={(event) => {
323
+ event.stopPropagation();
324
+ emit("press");
325
+ }}
326
+ >
327
+ <boxGeometry
328
+ args={[
329
+ Number(element.props.size),
330
+ Number(element.props.size),
331
+ Number(element.props.size),
332
+ ]}
333
+ />
334
+ <meshBasicMaterial color={String(element.props.color)} />
335
+ </mesh>
336
+ );
337
+ }
338
+
339
+ export const basicPackages: ImplementationPackage[] = [
340
+ {
341
+ name: "@moloodev/layout-basic",
342
+ version: packageVersion["react-dom"],
343
+ abi: 1,
344
+ target: "react-dom",
345
+ entries: {
346
+ Stack: { contract: contracts.Stack, component: Stack },
347
+ Grid: { contract: contracts.Grid, component: Grid },
348
+ Frame: { contract: contracts.Frame, component: Frame },
349
+ Panel: { contract: contracts.Panel, component: Panel },
350
+ Text: { contract: contracts.Text, component: Text },
351
+ Button: { contract: contracts.Button, component: Button },
352
+ Viewport: { contract: contracts.Viewport, component: Viewport },
353
+ },
354
+ },
355
+ {
356
+ name: "@moloodev/layout-basic",
357
+ version: packageVersion["react-three-fiber"],
358
+ abi: 1,
359
+ target: "react-three-fiber",
360
+ entries: {
361
+ Group: { contract: contracts.Group, component: Group },
362
+ Transform: { contract: contracts.Transform, component: Transform },
363
+ Box: { contract: contracts.Box, component: Box },
364
+ },
365
+ },
366
+ ];
@@ -0,0 +1,3 @@
1
+ export { contractIdentity, resolveCatalog } from "./resolve";
2
+ export type { CatalogSource, ResolvedCatalog } from "./resolve";
3
+ export { prepareSpec, validateLayout, SpecError } from "./validate";