Studio Runtime
The @crocolead/studio-runtime package mounts inside your tenant web app and turns any DOM node tagged with data-block-id / data-field-key into an editable surface for the Crocolead admin at admin.crocolead.ru/content. Inline edits, drafts, publishing, undo/redo, version history — all wired through a cross-origin postMessage bridge.
Status
Install
pnpm add @crocolead/studio-runtime
# or npm install / yarn add — same resultPeer dependencies you almost certainly already have:
react ^18 || ^19
react-dom ^18 || ^19
@tanstack/react-query ^5Mount
Place once near the root of your app. The runtime self-gates on ?_studio=1 — public visitors never load the click bridge, CSS, or listener suite.
import { StudioRuntime } from '@crocolead/studio-runtime';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html>
<body>
{children}
<StudioRuntime
adminOrigin={process.env.NEXT_PUBLIC_ADMIN_ORIGIN}
manifestKey="my-tenant-v1"
/>
</body>
</html>
);
}adminOrigin is critical
adminOrigin as targetOrigin, and inbound messages from any other origin are dropped. Leave it unset only in local dev — a missing value falls back to '*', which would let a malicious parent intercept editor clicks if you deployed without setting it.Tag your DOM
The runtime walks tagged elements via these data attributes:
| Attribute | Purpose |
|---|---|
data-block-id="<id>" | Required. Identifies the editable block; click-to-focus targets the closest ancestor with this attribute. |
data-instance-id="<id>" | Optional. The specific PageSection instance — required when one page has multiple instances of the same block type. Anchors the right-rail inspector to the exact row. |
data-field-key="<key>" | Optional. Lets the parent scroll its inspector to a specific field row when the editor clicks one. |
data-studio-passthrough | Opt-out. Click on an element with this attribute fires normally; useful for inner UI (popovers, modals) inside a tagged block. |
<section
data-block-id="hero"
data-instance-id={instance.id}
className="..."
>
<p data-field-key="eyebrow">{eyebrow}</p>
<h1 data-field-key="headline">{headline}</h1>
<a href={ctaHref} data-field-key="ctaHref">
<span data-field-key="ctaLabel">{ctaLabel}</span>
</a>
</section><EditableText>
Wrap text content with EditableText for inline contenteditable edits. The component locks while autosave is in flight and remounts (with a fresh React key) when a save lands, so cursor state resets to the saved value instead of fighting the in-flight typing buffer.
import { EditableText } from '@crocolead/studio-runtime';
<EditableText
as="h1"
value={title}
fieldKey="title"
fieldLabel="Заголовок"
maxLength={80}
className="text-4xl font-bold"
/>Props:
as— HTML tag to render (h1/h2/p/span/div).value— current text. Updated by parent on save.fieldKey— must match the manifest field key.fieldLabel— Russian label shown in the inspector; decorative for the iframe.maxLength— character cap enforced inline.className— Tailwind / CSS-class string; applied to the rendered tag.
The editable manifest
Each tenant has an EditableManifestSet stored in the platform database. It declares the shape of everything the editor can change — pages, section types, field schemas, defaults, instance caps. Today (preview) we provision new partner manifests for you; M.2's meta-editor at /content/manifest lets ADMIN+ users edit them.
{
"key": "demo-partner-v1",
"label": "Demo Partner",
"pages": [
{
"path": "/",
"label": "Home",
"sectionTypes": {
"hero": {
"label": "Hero",
"schema": [
{ "key": "headline", "type": "text", "label": "Headline", "required": true, "maxLength": 80 },
{ "key": "subhead", "type": "longText", "label": "Sub-headline", "maxLength": 200 },
{ "key": "ctaHref", "type": "url", "label": "Button link" }
],
"defaults": { "headline": "..." },
"hideable": false,
"minInstances": 1,
"maxInstances": 1,
"fixedPosition": true
}
}
}
]
}Field types
| Type | Editor surface | Storage |
|---|---|---|
text | Single-line input | string |
longText | Textarea | string |
number | Number input | number |
url | URL input | string |
image | Uploader + preview | string | ResponsiveValue<string> |
select | Dropdown from options | string |
colorRef | Color picker | string (hex / token) |
productRef | Product picker (scoped to tenant) | string (product id) |
productList | Reorderable product picker | string[] |
businessRef | Tenant picker (hub-only) | string (business id) |
datetime | HTML5 datetime-local input | string (ISO 8601) |
phone | Telephone input (no enforced format) | string |
email | Email input (HTML5 native validation + permissive regex check on save) | string |
Responsive fields
responsive: true and the inspector surfaces breakpoint tabs (mobile / tablet / desktop) above the input. The renderer resolves via resolveResponsiveValue(value, currentBreakpoint) exported from @crocolead/shared-types.Custom field editors (sandboxed)
When the 14 stock field types aren't enough — delivery zone polygons, currency amounts with locale, address autocomplete — you can register a custom editor that renders in the admin right rail (and in the click-on-canvas inline dialog).
The editor runs in a sandboxed cross-origin iframe with sandbox="allow-scripts allow-forms allow-popups" — notably WITHOUT allow-same-origin. Your editor cannot read admin cookies, localStorage, or authentication state, and cannot make authenticated requests as the admin user. This is the security boundary that lets us load partner code at all.
Trust model
Manifest declaration
{
"key": "deliveryZone",
"type": "custom",
"label": "Delivery zone",
"hint": "Drag the corners to set the polygon",
"custom": {
"name": "delivery-zone-polygon",
"url": "https://your-domain.example/editors/delivery-zone.html",
"height": 360,
"config": {
"defaultCenter": { "lat": 42.42, "lng": 47.30 },
"defaultZoom": 13
}
}
}Field shape:
type— must be"custom".custom.name— stable identifier. Must match thenamethe iframe sends inFIELD_EDITOR_READY. Catches URL-swap attacks.custom.url— the iframe'ssrc. Must be HTTPS in production.custom.height— initial pixel height (50–800). Iframe can request a new height viaFIELD_EDITOR_RESIZE.custom.config— free-form JSON passed to the editor in the initial value message. Lets the manifest tune a generic editor for a specific use.
Protocol
// iframe → admin
FIELD_EDITOR_READY { name, desiredHeight? }
FIELD_EDITOR_CHANGE { value } // any JSON-safe shape
FIELD_EDITOR_ERROR { message } // null clears prior error
FIELD_EDITOR_RESIZE { height } // 50..800, parent clamps
// admin → iframe
FIELD_EDITOR_VALUE { value, field: { key, label, hint, ..., config }, locale }Reference editor
A working currency-amount editor lives at /sample-editors/currency-amount.html. Copy the file as a starting point — vanilla HTML/JS, no build step, ~80 lines including comments.
Origin handling
allow-same-origin post messages with origin: "null" — the admin accepts this and verifies the message source against the iframe's contentWindow ref. From your side, target the parent with targetOrigin: '*' (the payload carries no secrets, and your sandbox cannot inspect the admin's origin anyway).postMessage protocol
The runtime + parent admin live on different origins (your tenant domain vs admin.crocolead.ru). All cross-frame traffic is postMessage. The parent never reaches into the iframe's DOM; the iframe never reads the parent's storage. Both sides honour targetOrigin strictly when adminOrigin is configured.
Message types are exported from @crocolead/shared-types/studio-protocol (single source of truth across iframe + admin):
STUDIO_FOCUS iframe → admin click on a tagged block
STUDIO_INVALIDATE admin → iframe editor saved; refetch your queries
STUDIO_INVALIDATED iframe → admin ACK of the above (skips hard-reload)
STUDIO_NAVIGATE iframe → admin SPA route changed inside the iframe
STUDIO_SELECT_INSTANCE admin → iframe highlight + scroll-into-view an instance
STUDIO_UNDO / STUDIO_REDO iframe → admin Cmd+Z / Cmd+Shift+Z captured
STUDIO_PUBLISH iframe → admin Cmd+S captured
STUDIO_SHORTCUTS_PANEL iframe → admin Cmd+/ — show shortcuts panelGotchas
- Next.js RSC. The package ships with
"use client"at the top of both ESM and CJS bundles, so it works out of the box inside the App Router. You don't need to add a"use client"directive in your own file that imports it. - React Query singleton. We kept
@tanstack/react-queryas an external peer dep on purpose — your app and the studio runtime share the same provider. Bundling it would create a duplicate cache instance and the invalidate-on-save path would silently no-op. - Hydration flash on responsive images. For breakpoint-bound images, emit a
<picture><source media>triplet server-side rather than JS-switching onuseBreakpoint. The runtime ships server-rendered HTML to public visitors; a JS-driven swap creates a mobile-then-desktop flash. The aydaeda hub uses this pattern inHomeEditorialHeroandHomePhotoBlock. - manifestKey is informational today. The runtime stores the prop but doesn't yet announce it over postMessage — the admin auto-detects the manifest from the tenant's X-Tenant-ID. M.4+ adds the announce wire shape so partners with multiple manifest sets can route inspector context per route.
End-to-end quickstart
A complete partner integration in ~10 minutes:
- Email hello@crocolead.ru to register your tenant. You'll get back a subdomain (e.g.
your-brand.crocolead.ru) and a manifest key. pnpm add @crocolead/studio-runtime.- Mount
<StudioRuntime>near your root withadminOrigin="https://admin.crocolead.ru". - Tag your sections + fields per the table above.
- Wrap text with
<EditableText>for inline edits. - Visit
https://admin.crocolead.ru/content— the iframe loads your site at?_studio=1and your sections become editable.