# @persona/layout-editor-remote-module

Shared manifest format and Vite plugin for remotely-defined dashboard layout modules — used by
Persona dashboard apps to declare a layout module directly in a typical app's `vite.config.ts`,
without editing the host.

## Install

```sh
yarn add @persona/layout-editor-remote-module
```

`vite` and `zod` are peer dependencies.

The optional `react` and `react-dom` peer dependencies are required only when using the React bridge
components.

The package is published to the public npm registry.

## Usage

```ts
// vite.config.ts
import {
  defineLayoutModule,
  personaLayoutModules,
} from '@persona/layout-editor-remote-module/vite-plugin';

export default defineConfig({
  plugins: [
    react(),
    personaLayoutModules({
      modules: [
        defineLayoutModule({
          key: 'risk_summary',
          displayName: 'Risk Summary',
          descriptionShort: 'Show the account risk score',
          allowAddingToLayout: true,
          layouts: ['case'],
          defaultSize: { case: { width: 4, height: 20 } },
          // The host loads this HTML page in an iframe.
          render: { entry: './src/layout-modules/risk-summary/index.html' },
        }),
      ],
    }),
  ],
});
```

An empty or omitted `layouts` makes the module available in every layout. When `layouts` lists
specific layouts, `defaultSize` must provide an entry for each one.

`defaultConfig` optionally overrides top-level `configSchema` defaults for new placements on a
specific layout. It is a shallow merge and never rewrites saved config. Each set key must validate
against `configSchema`; when `layouts` is non-empty, every `defaultConfig` layout must be listed
there. Hosts that predate this field ignore it, so keep a sensible `configSchema` default:

```ts
import { z } from 'zod';

defineLayoutModule({
  layouts: ['case', 'account'],
  configSchema: z.object({ ownerId: z.string().default('current_case.id') }),
  defaultConfig: { account: { ownerId: 'current_account.id' } },
});
```

Entries must be `.html` documents, which the host loads in an iframe. Non-HTML entries fail during
Vite config resolution. See `LayoutModuleEntry`'s doc comment in `src/define.ts` for the full
contract, including the optional `path` field for a client-routed SPA.

## Communicating with the host

Document-delivered modules can use the browser bridge to receive layout data, report the height
their document needs (including `body` and ancestor margins and padding), and resolve data paths
through the host. The host renders the document with `RemoteModuleFrame`:

```tsx
import { RemoteModuleFrame } from '@persona/layout-editor-remote-module/bridge-host';

<RemoteModuleFrame
  documentUrl={registration.document}
  moduleOrigin={registration.origin}
  title='Risk summary'
  sandbox='allow-scripts allow-same-origin'
  data={moduleData}
  context={moduleContext}
  dynamicHeight
  loadingFallback={<div>Loading module…</div>}
  resolveDataPath={resolveDataPath}
/>;
```

The required `sandbox` value is chosen by the host. The optional `allow` prop sets the iframe's
Permissions-Policy `allow` attribute when the host wants to delegate a capability to the module
document, e.g. `allow='clipboard-write'` so the module can call `navigator.clipboard.writeText`.

`loadingFallback` is drawn over the iframe until the module has rendered. `LayoutModuleBridge`
advertises the `loaded` message automatically after its first render and waits for the first height
measurement before sending it when height observation is enabled. A module that needs a loading
state should render it on its first render; `LayoutModuleBridge`'s `fallback` is hidden under the
host overlay until then and remains available for render errors.

- `createModuleBridge` and `useRemoteModuleBridge` keep legacy behavior by default; direct bridge
  users opt in with `signalsLoaded: true` and call `bridge.signalLoaded()` after rendering.
- Frames without the `loaded` capability are treated as legacy: `ready`, another bridge message, or
  a silent-frame timeout after iframe load dismisses the overlay.

By default, the bridge accepts host origins matching `*.withpersona.com`,
`*.withpersona-staging.com`, `localhost`, and `127.0.0.1`. Add development or preview origins with
`allowedHostOrigins`, optionally composing the exported `personaDashboardHostOrigins` helper:

```tsx
import {
  LayoutModuleBridge,
  personaDashboardHostOrigins,
} from '@persona/layout-editor-remote-module/bridge-frame';

<LayoutModuleBridge
  allowedHostOrigins={(origin) => personaDashboardHostOrigins(origin) || myDevHost(origin)}
>
  {({ data }) => <RiskSummary data={data} />}
</LayoutModuleBridge>;
```

The document entry uses `LayoutModuleBridge` as a render-prop component:

```tsx
import { LayoutModuleBridge } from '@persona/layout-editor-remote-module/bridge-frame';

<LayoutModuleBridge>
  {({ data, context, resolveDataPath }) => (
    <RiskSummary data={data} context={context} resolveDataPath={resolveDataPath} />
  )}
</LayoutModuleBridge>;
```

Alongside `data` (the module's persisted config), the host sends a typed `context` payload
(`RemoteModuleContext`) describing where the module is rendered — facts the host owns and the module
cannot derive itself:

- `layoutType` — which layout kind the module is placed on (`account`, `case`, or `transaction`)
- `layoutObjectId` — the ID of the object the layout is rendered for (e.g. the viewed case's ID on a
  `case` layout), or `null` when there is none
- `isReadOnly` — the host object is locked for edits
- `isInEditor` — the module is rendered inside the layout editor rather than on a live record
- `isUsingStubData` — the host is feeding placeholder/stub data instead of a real record
- `dynamicHeight` — whether the host sizes the iframe to the height the bridge reports
  (`RemoteModuleFrame`'s `dynamicHeight`). The reported height covers the whole frame document,
  including `body` and ancestor margins and padding. A module that fills a fixed-height iframe with
  viewport units (`100vh`) should size to its content when it's set — combined with any `body`
  margin or padding the reported height exceeds the iframe, so it grows on every report.
  `RemoteModuleFrame` sets it from its `dynamicHeight` prop, overriding any value in the `context`
  prop.

`context` is sent with `init` and pushed again whenever it changes, just like `data`; inside the
iframe it arrives through `ready()`, `getContext()`, and the `context` render prop (all
`RemoteModuleContext | null` until init).

The host still appends `access_token`, `environment_id`, and `locale` as query parameters. The app
hosting the module consumes and strips those parameters itself, so by the time `LayoutModuleBridge`
runs they have already been handled; the bridge neither reads nor strips them. Keep the host
iframe's `referrerpolicy` at its default `no-referrer` (or use `strict-origin`) so the URL
parameters cannot leak through subsequent requests.

The bridge's security model is intentionally narrow:

- The host sends only to the exact module origin and accepts messages only from that origin and
  iframe window.
- The iframe accepts only messages from its parent window and an allowed host origin, then pins the
  first trusted origin for the lifetime of the bridge.
- The iframe derives the ready target from `ancestorOrigins` or the referrer, and the host retries
  `init` until the frame answers.
- Messages use a versioned protocol that stays compatible with frames built against older releases
  (from 0.5.3): receivers ignore message types they don't know and strip keys they don't know, and
  fields are never removed, renamed or narrowed. `context.layoutType` can be a layout type added
  later, so handle values you don't know. Credentials remain in the iframe URL and are never handled
  through bridge messages.
- `extraParams` rejects reserved names and credential-like keys.

### Consuming a manifest

The plugin emits `{ modules: RemoteModuleRegistrationManifestV1[] }` at `manifestPath`. A host
fetching that document should run it through `parseRemoteModuleManifest` — it validates the
envelope, every module, and key uniqueness, and returns the typed document:

```ts
import {
  parseRemoteModuleManifest,
  RemoteModuleManifestParseError,
} from '@persona/layout-editor-remote-module';

const response = await fetch(`${appOrigin}/_layout_modules_manifest.json`);
const { modules } = parseRemoteModuleManifest(await response.json());
```

It throws `RemoteModuleManifestParseError` (with `.issues` and a prettified `.message`) on a
malformed document; `safeParseRemoteModuleManifest` returns `{ success, data | error }` instead of
throwing.

### Rendering a config panel

A host stores a placed module's config under a `config` property, so its RJSF form edits
`{ config }`. The `./config-form` entry adapts a manifest to that shape, with no React or RJSF
dependency:

```ts
import {
  nestRemoteConfigSchema,
  nestRemoteUiConfigSchema,
  remoteConfigDefaults,
} from '@persona/layout-editor-remote-module/config-form';

const schema = nestRemoteConfigSchema(module.configSchema); // `$ref`s rewritten to match
const uiSchema = nestRemoteUiConfigSchema(module.uiConfigSchema);
const initialConfig = remoteConfigDefaults(module.configSchema, module.defaultConfig?.[layoutType]); // top-level `default`s plus per-layout overrides
```

Render the form with `schema`, `uiSchema` and `formData: { config }`, and read the edited config
back from `formData.config`.
