Skip to content

React Integration with generator-workbench

This guide explains the recommended way to integrate a React generator into the official generator-workbench shell.

For a new standard generator, the target architecture is:

  • generator-sdk provides platform capabilities such as auth, export, cloud save, history, billing, and template helpers.
  • your React code implements the generator runtime contract
  • generator-workbench renders the official shell around that runtime

This is different from the older "React page directly calls SDK APIs" approach. That raw SDK path still works for fully custom shells, but it is no longer the recommended default for standard generator integration.

What React Actually Integrates

React does not need a separate React-native workbench package.

In this repository, the intended model is:

  1. React implements the runtime
  2. the host page renders one <generator-workbench> custom element
  3. the host assigns sdk, runtime, and config to that element
  4. the workbench mounts the runtime into the correct DOM container

So the key question is not "can React use a Vue shell?" but:

Can your React generator expose the runtime contract expected by the workbench?

The answer is yes.

When To Use This Guide

Use this guide when:

  • your generator is written in React
  • you want the official shell
  • you want login / credits / export / template / cloud / history UI to be owned by the workbench
  • you want the generator to converge toward the standard runtime model

Do not use this guide as the primary path when:

  • you intentionally own a fully custom shell
  • you only need a few SDK APIs in an existing React page
  • you do not want to expose mount(), getState(), getPanelSchema(), and related runtime methods

Architecture

Recommended responsibility split:

  • React runtime owns:
    • business state
    • canvas rendering
    • parameter rendering
    • runtime export data
    • PanelSchema
  • generator-workbench owns:
    • top bar
    • login / avatar / logout entry
    • credits / billing shell UI
    • template import / publish entry
    • cloud save / history shell actions
  • generator-sdk owns:
    • auth
    • export actions
    • cloud APIs
    • history APIs
    • billing and template helpers
txt
src/
  sdk.js
  main.jsx
  workbench-host.jsx
  runtime/
    state.js
    panel-schema.js
    runtime.js

Step 1: Create a React Project

Use Vite:

bash
npm create vite@latest

or:

bash
pnpm create vite@latest

Suggested choices:

  • Framework: React
  • Variant: JavaScript

Then:

bash
cd your-project-name
npm install

Step 2: Install the SDK and Workbench

bash
npm install @atomm-developer/generator-sdk @atomm-developer/generator-workbench

generator-workbench is a custom-element shell. Your React app hosts that element; the workbench then hosts your runtime.

About atomm-ui and atomm-pro

generator-workbench internally depends on atomm-ui and, for some flows, atomm-pro.

That does not mean your runtime must be rewritten in Vue.

The practical boundary is:

  • the workbench uses those dependencies to render shell-owned UI
  • your React runtime can stay fully React-based
  • if your runtime business area also wants to reuse the same visual system, evaluate that separately instead of assuming shell dependencies automatically become runtime dependencies

Step 3: Initialize the SDK Once

Create src/sdk.js:

js
import { GeneratorSDK } from '@atomm-developer/generator-sdk'

export const sdk = GeneratorSDK.init({
  appKey: import.meta.env.VITE_ATOMM_APP_KEY,
  env: import.meta.env.MODE === 'production' ? 'prod' : 'dev',
})

Create .env.local:

bash
VITE_ATOMM_APP_KEY=your_app_key

Keep the SDK instance outside React component state. Treat it as a singleton service.

Step 4: Define Serializable Runtime State

Create src/runtime/state.js:

js
export function createInitialState() {
  return {
    meta: {
      schemaVersion: '1.0.0',
      generatorId: 'react-pendant',
      title: 'React Pendant Generator',
    },
    document: {
      width: 520,
      height: 360,
      unit: 'px',
    },
    params: {
      text: 'generator',
      width: 96,
      height: 128,
      accentColor: '#111111',
    },
  }
}

export function cloneState(value) {
  return JSON.parse(JSON.stringify(value))
}

export function setByPath(target, path, value) {
  const keys = path.split('.')
  let current = target

  for (let index = 0; index < keys.length - 1; index += 1) {
    const key = keys[index]
    if (!current[key] || typeof current[key] !== 'object') {
      current[key] = {}
    }
    current = current[key]
  }

  current[keys[keys.length - 1]] = value
}

The runtime state should stay JSON-serializable because the host may restore, save, export, or template it later.

Step 5: Define PanelSchema

Create src/runtime/panel-schema.js:

js
export function getPanelSchema() {
  return {
    version: '1.0.0',
    generatorId: 'react-pendant',
    groups: [
      {
        id: 'content',
        title: 'Content',
        fields: [
          {
            id: 'text',
            label: 'Text',
            type: 'text',
            bind: { path: 'params.text' },
          },
        ],
      },
      {
        id: 'size',
        title: 'Size',
        fields: [
          {
            id: 'width',
            label: 'Width',
            type: 'number',
            bind: { path: 'params.width' },
          },
          {
            id: 'height',
            label: 'Height',
            type: 'number',
            bind: { path: 'params.height' },
          },
        ],
      },
    ],
  }
}

export function applyPanelFilter(schema, panelFilter) {
  if (!panelFilter) {
    return schema
  }

  const includeGroups = panelFilter.includeGroups || null
  const excludeGroups = new Set(panelFilter.excludeGroups || [])
  const includeFields = panelFilter.includeFields || null
  const excludeFields = new Set(panelFilter.excludeFields || [])
  const readonlyFields = new Set(panelFilter.readonlyFields || [])

  const groups = schema.groups
    .filter((group) => !excludeGroups.has(group.id))
    .filter((group) => !includeGroups || includeGroups.includes(group.id))
    .map((group) => ({
      ...group,
      fields: group.fields
        .filter((field) => !excludeFields.has(field.id) && !excludeFields.has(field.bind.path))
        .filter((field) => !includeFields || includeFields.includes(field.id) || includeFields.includes(field.bind.path))
        .map((field) => ({
          ...field,
          readonly:
            field.readonly ||
            readonlyFields.has(field.id) ||
            readonlyFields.has(field.bind.path),
        })),
    }))
    .filter((group) => group.fields.length > 0)

  return {
    ...schema,
    groups,
  }
}

This is what allows template hosts and workbench-owned template flows to consume partial parameters instead of hardcoding a separate form.

Step 6: Wrap React UI as a Runtime

Create src/runtime/runtime.js:

jsx
import React from 'react'
import { createRoot } from 'react-dom/client'
import { createInitialState, cloneState, setByPath } from './state'
import { getPanelSchema, applyPanelFilter } from './panel-schema'

function CanvasView({ state }) {
  return (
    <div style={{ display: 'grid', placeItems: 'center', minHeight: '100%' }}>
      <div
        style={{
          width: 520,
          height: 360,
          borderRadius: 20,
          border: '1px solid #dbe3f0',
          background: '#fff',
          display: 'grid',
          placeItems: 'center',
        }}
      >
        <div style={{ textAlign: 'center' }}>
          <div style={{ fontSize: 28, color: state.params.accentColor }}>
            {state.params.text}
          </div>
          <div style={{ marginTop: 8, color: '#64748b' }}>
            {state.params.width} x {state.params.height} mm
          </div>
        </div>
      </div>
    </div>
  )
}

function PanelView({ schema, state, onFieldChange }) {
  return (
    <div style={{ padding: 16 }}>
      {schema.groups.map((group) => (
        <section key={group.id} style={{ marginBottom: 16 }}>
          <h3>{group.title}</h3>
          {group.fields.map((field) => (
            <label
              key={field.id}
              style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 12 }}
            >
              <span>{field.label}</span>
              <input
                disabled={field.readonly}
                type={field.type === 'number' ? 'number' : 'text'}
                value={String(resolveFieldValue(state, field.bind.path) ?? '')}
                onChange={(event) => {
                  const nextValue =
                    field.type === 'number'
                      ? Number(event.target.value)
                      : event.target.value
                  onFieldChange(field.bind.path, nextValue)
                }}
              />
            </label>
          ))}
        </section>
      ))}
    </div>
  )
}

function FullWorkspace(props) {
  return (
    <div
      style={{
        display: 'grid',
        gridTemplateColumns: '320px minmax(0, 1fr)',
        minHeight: '100%',
      }}
    >
      <aside style={{ borderRight: '1px solid #e5e7eb', background: '#fff' }}>
        <PanelView {...props} />
      </aside>
      <main style={{ padding: 24, background: '#f5f7fb' }}>
        <CanvasView state={props.state} />
      </main>
    </div>
  )
}

function resolveFieldValue(state, path) {
  return path.split('.').reduce((current, key) => current?.[key], state)
}

export function createGeneratorRuntime() {
  let state = createInitialState()
  const listeners = new Set()
  const mounts = new Map()

  function emit(event) {
    listeners.forEach((listener) => listener(event))
  }

  function getState() {
    return cloneState(state)
  }

  function renderMount(record) {
    const schema = applyPanelFilter(getPanelSchema(), record.panelFilter)

    const onFieldChange = (path, value) => {
      patchState(
        { [path]: value },
        {
          source: 'user',
        },
      )

      emit({
        type: 'params_change',
        data: {
          field: path.replace(/^params\./, ''),
          value,
          params: cloneState(state.params),
        },
      })
    }

    if (record.mode === 'embed' && record.target === 'canvas') {
      record.root.render(<CanvasView state={state} />)
      return
    }

    if (record.mode === 'embed' && record.target === 'panel') {
      record.root.render(
        <PanelView
          schema={schema}
          state={state}
          onFieldChange={onFieldChange}
        />,
      )
      return
    }

    record.root.render(
      <FullWorkspace
        schema={schema}
        state={state}
        onFieldChange={onFieldChange}
      />,
    )
  }

  function rerender() {
    mounts.forEach((record) => renderMount(record))
  }

  function setState(nextState, options = {}) {
    state = cloneState(nextState)
    rerender()

    if (!options.silent) {
      emit({
        type: 'state-change',
        state: getState(),
        source: options.source || 'host',
      })
    }
  }

  function patchState(patch, options = {}) {
    Object.entries(patch || {}).forEach(([path, value]) => {
      setByPath(state, path, value)
    })

    rerender()

    if (!options.silent) {
      emit({
        type: 'state-change',
        state: getState(),
        patch,
        source: options.source || 'host',
      })
    }
  }

  return {
    version: '1.0.0',
    generatorId: 'react-pendant',
    capabilities: {
      modes: ['full', 'embed'],
      mountTargets: ['full', 'canvas', 'panel'],
      supportsPatchState: true,
      supportsReadonly: true,
      supportsPanelFilter: true,
      supportsPartialPanel: true,
      exports: ['cover', 'download-svg', 'open-in-studio'],
    },
    mount({
      mode,
      target = 'full',
      container,
      state: incomingState,
      panelFilter,
    }) {
      if (incomingState) {
        state = cloneState(incomingState)
      }

      const existing = mounts.get(container)
      const root = existing?.root || createRoot(container)
      const record = {
        root,
        mode,
        target,
        container,
        panelFilter,
      }

      mounts.set(container, record)
      renderMount(record)
      emit({ type: 'ready' })

      return {
        unmount() {
          const current = mounts.get(container)
          current?.root.unmount()
          mounts.delete(container)
        },
      }
    },
    getState,
    setState,
    patchState,
    getPanelSchema({ panelFilter } = {}) {
      return applyPanelFilter(getPanelSchema(), panelFilter)
    },
    async export(action) {
      const svg = `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="520" height="360" viewBox="0 0 520 360">
  <rect width="520" height="360" rx="20" fill="white" stroke="#dbe3f0" />
  <text x="260" y="170" text-anchor="middle" font-size="28" fill="${state.params.accentColor}">
    ${state.params.text}
  </text>
  <text x="260" y="210" text-anchor="middle" font-size="14" fill="#64748b">
    ${action}
  </text>
</svg>`

      return {
        dataUrl: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`,
        mimeType: 'image/svg+xml',
        ext: 'svg',
        width: 520,
        height: 360,
      }
    },
    subscribe(listener) {
      listeners.add(listener)
      return () => listeners.delete(listener)
    },
  }
}

This is the key adaptation layer.

The workbench does not mount your React application directly. It mounts a runtime object. That runtime object is where React is attached to the DOM containers passed by the host.

Step 7: Host the Workbench Custom Element from React

Create src/workbench-host.jsx:

jsx
import { useEffect, useRef } from 'react'
import { GeneratorWorkbench } from '@atomm-developer/generator-workbench'
import { sdk } from './sdk'
import { createGeneratorRuntime } from './runtime/runtime'

GeneratorWorkbench.defineGeneratorWorkbench()

export default function WorkbenchHost() {
  const elementRef = useRef(null)
  const runtimeRef = useRef(null)

  if (!runtimeRef.current) {
    runtimeRef.current = createGeneratorRuntime()
    window.__GENERATOR_RUNTIME__ = runtimeRef.current
  }

  useEffect(() => {
    const element = elementRef.current
    if (!element) {
      return undefined
    }

    element.sdk = sdk
    element.runtime = runtimeRef.current
    element.config = {
      title: 'React Pendant Generator',
      mode: 'shell',
      readyPolicy: 'runtime-ready',
      exportEnabled: true,
      studioEnabled: true,
      templateEnabled: true,
    }

    let disposed = false

    Promise.resolve(element.mount()).catch((error) => {
      console.error('Failed to mount workbench', error)
    })

    return () => {
      if (!disposed) {
        element.unmount?.()
        disposed = true
      }
    }
  }, [])

  return <generator-workbench ref={elementRef} />
}

Then update src/main.jsx:

jsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import WorkbenchHost from './workbench-host'

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <WorkbenchHost />
  </React.StrictMode>,
)

This is the recommended React-side host pattern:

  • React renders the custom element
  • React assigns object properties through a ref
  • the custom element mounts the runtime

Step 8: Understand shell, full, and embed

Most React generators should start with:

js
element.config = {
  mode: 'shell',
}

In shell mode:

  • the workbench keeps the official top bar
  • your runtime owns the internal workspace layout
  • your runtime usually mounts as a full workspace

If you need template-host-style separation, your runtime must also support:

js
runtime.mount({
  mode: 'embed',
  target: 'canvas',
  container,
})

runtime.mount({
  mode: 'embed',
  target: 'panel',
  container,
  panelFilter,
})

That is why the runtime adapter above supports:

  • mode: 'full'
  • mode: 'embed'
  • target: 'full' | 'canvas' | 'panel'

Step 9: Let the Workbench Own Platform UI

Once you adopt generator-workbench, do not rebuild shell features inside the React runtime unless you intentionally want custom behavior.

Typical ownership after integration:

  • login button: workbench
  • avatar / logout entry: workbench
  • credits badge: workbench
  • export entry: workbench
  • template publish / import entry: workbench
  • cloud save / history entry: workbench

The React runtime should stay focused on:

  • state
  • rendering
  • schema
  • export payload

Step 10: Turn On Optional Shell Capabilities

For cloud save and history:

js
element.config = {
  title: 'React Pendant Generator',
  mode: 'shell',
  cloudEnabled: true,
  historyEnabled: true,
  autoSaveEnabled: true,
  getCloudSaveOptions: ({ state }) => ({
    title: 'Draft',
    snapshot: state,
  }),
}

These features require the runtime to expose stable state and preferably subscribe(listener).

For Open in Studio and download, the runtime should provide export(action), and the SDK/workbench combination decides how that export is consumed.

TypeScript Note

If your React project uses TypeScript, add JSX typing for the custom element:

ts
declare namespace JSX {
  interface IntrinsicElements {
    'generator-workbench': React.DetailedHTMLProps<
      React.HTMLAttributes<HTMLElement>,
      HTMLElement
    >
  }
}

Common Mistakes

  • Treating generator-workbench as if it replaces runtime business logic
  • Rendering login, credits, and template shell UI inside the React runtime again
  • Building only one React page instead of exposing a runtime object
  • Skipping getPanelSchema() and then expecting template flows to work
  • Making state non-serializable
  • Implementing only a full page and not supporting embed targets when template scenarios are needed

If You Still Want Raw SDK-Only React Integration

That path still works when you intentionally own a custom shell.

In that case:

  • React renders the full page itself
  • you call sdk.auth.login(), sdk.export.openInStudio(), and other APIs directly
  • you do not use generator-workbench as the shell host

Use that path only when shell customization is more important than standard generator integration.

Upload Resources

  1. Run the production build, for example npm run build.
  2. Compress the generated dist directory into a zip package.
  3. Make sure the zip root contains index.html.
  4. Upload it to https://www.atomm.com/generator-upload.

One-Sentence Summary

The recommended React integration path is:

React implements the runtime contract, generator-workbench hosts the official shell, and generator-sdk provides the platform capabilities underneath.

MIT Licensed