@stratum-core/presentation
v0.4.2
Published
The presentation-mode contract a Stratum consumer site implements: the message channel, the record annotations, and the hover/select overlay.
Maintainers
Readme
@stratum-core/presentation
The consumer half of Stratum's presentation mode. A site embeds itself in the operational app, and an editor gets three things on their real pages: hovering any content shows which record produced it, clicking it opens that record in the editor, and typing in the form updates the page in place.
This package is the site's side of that contract — the message channel, the
record annotations, and the hover/select overlay. Everything in it is plain DOM
and plain postMessage with zero runtime dependencies; React and Next are
optional peers used only by the convenience entry points.
Install
npm install @stratum-core/presentation
pnpm add @stratum-core/presentation
yarn add @stratum-core/presentationRequires Node 18+ or any modern browser bundler. react >= 18 and next >= 15
are optional peers — you need them only for /react, /next and /field.
Entry points
There is no root export; import the entry point you need.
| Entry point | What it is | Needs |
| ---------------------------------------- | ------------------------------------------------- | ------------ |
| @stratum-core/presentation/annotate | Attributes that mark a record and its fields | — |
| @stratum-core/presentation/overlay | The hover/select overlay, as plain DOM | — |
| @stratum-core/presentation/protocol | Message types, version, and readers for the wire | — |
| @stratum-core/presentation/react | <PresentationOverlay> for any React app | react |
| @stratum-core/presentation/next | <PresentationOverlay> that refreshes the router | react, next |
| @stratum-core/presentation/field | <Field>, an inline span marking one field | react |
Next.js (App Router)
A complete integration is five files. Every snippet below is the shape a working consumer actually has, not a sketch.
1. Configure the host origin
# .env
STRATUM_OPERATIONAL_ORIGIN=https://app.stratum.exampleOne variable, and it is the on-switch. Unset means presentation is off, not open — the route must 404 rather than render a frame nobody is allowed to embed. Never read the origin off the incoming message; an origin you accept from the embedder is an origin any page can claim.
2. The route
// app/presentation/[collection]/[slug]/page.tsx
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { PresentationOverlay } from '@stratum-core/presentation/next'
import { PageView } from '@/components/page-view'
import { fetchPage } from '@/lib/fetch-page'
// Never cached: it renders a draft that changes while the editor works.
// Never indexed: it is not a page of the site.
export const dynamic = 'force-dynamic'
export const metadata: Metadata = { robots: { index: false, follow: false } }
export default async function PresentationPage({
params,
searchParams,
}: {
params: Promise<{ collection: string; slug: string }>
searchParams: Promise<{ token?: string }>
}) {
const { collection, slug } = await params
const { token } = await searchParams
const hostOrigin = process.env.STRATUM_OPERATIONAL_ORIGIN
if (!hostOrigin || !token) notFound()
const page = await fetchPage({ collection, slug, token })
// A rejected token and an absent draft are the same 404 here: the host built
// this URL and can say which, and the frame has no chrome to say it in.
if (!page) notFound()
return (
<PageView
page={page}
// Its presence is what puts record ids into the DOM, so the published
// page never carries them. `parent` is the sequence these records sit
// in, written `collection:id:field`.
presentation={{ parent: `${collection}:${page.id}:sections` }}
overlay={<PresentationOverlay hostOrigin={hostOrigin} accent="#ee7623" />}
/>
)
}The route renders the same components as the public route. If it renders anything else, presentation is lying to the editor about their own site.
3. Read content with the token
// lib/fetch-page.ts
export async function fetchPage({ collection, slug, token }) {
const res = await fetch(`${process.env.STRATUM_API_URL}/graphql`, {
method: 'POST',
headers: {
'content-type': 'application/json',
// Forwarded verbatim, and never verified: the site holds no secret and
// must not pretend to.
authorization: `Bearer ${token}`,
},
body: JSON.stringify({ query: pageQuery(collection), variables: { slug } }),
// A cached response is by definition the published one, so the editor
// would be shown their own draft's absence and told it was a preview.
cache: 'no-store',
})
if (res.status === 401) return null // say the session ended...
// ...and do NOT fall back to published content, or the editor concludes
// their draft was lost.
const { data } = await res.json()
return data?.[collection]?.[0] ?? null
}4. Annotate — only while previewing
This is the part to get right. Annotations must exist on the presentation route and nowhere else: on a published page they disclose your collection names and record ids, and the field wrappers change the DOM your audience gets. So the flag from step 2 gates them, rather than the components emitting them always.
// components/render-section.tsx
import { sectionAttributes } from '@stratum-core/presentation/annotate'
import { Field } from '@stratum-core/presentation/field'
// The collection each rendered record came from — the inverse of the type tag
// Stratum sends. Presentation opens the editor by collection name, so a wrong
// entry here opens the wrong record.
const COLLECTION_BY_TYPE = {
Banner: 'cms_banners',
Statement: 'cms_statements',
} as const
export function renderSection(section, presentation, index = 0) {
// Spread onto the section's root element. Absent when not previewing.
const marks = presentation
? sectionAttributes({
collection: COLLECTION_BY_TYPE[section.__typename],
id: section.id,
parent: presentation.parent,
index,
})
: undefined
// Wraps a value so the overlay can outline that field, and leaves it exactly
// as it was otherwise: an annotated page and a published one must lay out
// identically.
const f = (name: string, value: React.ReactNode) =>
presentation ? <Field name={name}>{value}</Field> : value
switch (section.__typename) {
case 'Banner':
return (
<Hero
key={section.id}
{...marks}
title={f('title', section.title)}
lede={section.lede ? f('lede', section.lede) : undefined}
/>
)
// ...
}
}Your components must forward unknown props to their root element, or marks
lands nowhere and the overlay sees an unannotated page.
5. Headers
Two rules, and both traps below are load-bearing.
// next.config.ts
const stratumOrigin = process.env.STRATUM_OPERATIONAL_ORIGIN?.trim()
const nextConfig: NextConfig = {
async headers() {
return [
{
// The embedded editing surface: framed by Stratum and by nothing else.
source: '/presentation/:path*',
headers: [
{
key: 'Content-Security-Policy',
value: `frame-ancestors ${stratumOrigin || "'none'"}`,
},
{ key: 'X-Robots-Tag', value: 'noindex, nofollow' },
// The token is in this page's URL, so without this every image, font
// and script it loads would carry a replayable session in its Referer.
{ key: 'Referrer-Policy', value: 'no-referrer' },
],
},
{
// Every page except /presentation, and nothing under /_next.
//
// Excluding /presentation matters because two CSP headers on one
// response are enforced as their intersection, so a blanket 'none'
// would win and blank the frame with nothing in any log.
//
// Excluding /_next matters more: these headers would otherwise be added
// to the dev server's HMR websocket upgrade, which breaks the handshake,
// so the client runtime never boots and *no page on the site hydrates*.
// Server-rendered HTML still looks perfect, which is why only driving a
// browser catches it.
source: '/((?!presentation|_next/).*)',
headers: [
{ key: 'Content-Security-Policy', value: "frame-ancestors 'none'" },
],
},
]
},
}6. Keep it out of the index
// app/robots.ts
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: '*',
allow: '/',
// /presentation renders unpublished drafts for one embedded editor.
disallow: ['/presentation'],
},
}
}7. Verify it
The route must refuse an unauthenticated request, carry the two headers, annotate its records, and mount the overlay. Check all four against a running deployment before you call it done — a rule nobody can run is a rule nobody follows.
The contract, in any framework
- A dynamic route —
/presentation/<collection>/<slug>?token=<jwt>— that reads its content with the token and renders the public components. noindex, plus arobots.txtdisallow.Content-Security-Policy: frame-ancestors <stratum origin>on that route, and'none'everywhere else.Referrer-Policy: no-referreron that route — the token is in the URL and would otherwise ride out in theRefererof every image and font request.- Annotations in its render path, emitted only on that route. On a published page they would disclose your collection names and record ids, and the field wrappers would change the DOM your audience gets.
- The overlay, mounted only on that route.
Without React, mount the overlay yourself and pass your own refresh — the two non-React entry points are all you need:
import { createOverlay } from '@stratum-core/presentation/overlay'
import { sectionAttributes, fieldAttributes } from '@stratum-core/presentation/annotate'
const overlay = createOverlay({
hostOrigin: HOST_ORIGIN,
onRefresh: () => location.reload(), // or however your framework re-reads content
})
// on teardown
overlay.destroy()Whatever the framework, exclude its dev-server asset path (/_next on Next)
from the site-wide frame-ancestors rule. Applied to an HMR websocket upgrade
it breaks the handshake, the client runtime never boots, and no page on the site
hydrates — with perfect server HTML and a green test suite.
The token
Short-lived and read-only: the platform denies every mutation from a preview
caller above the resolver layer. Forward it verbatim as Authorization: Bearer
on the content read and never verify it — the site holds no secret and must
not pretend to. If it is rejected, say the session has ended; do not fall back
to published content, or the editor concludes their draft was lost.
A cross-site frame receives no cookies, which is why the token travels in the URL rather than in a session.
API
/annotate
sectionAttributes({ collection, id, parent, index }): Record<string, string>
fieldAttributes(field: string): Record<string, string>
targetFrom(element: Element | null): Target | nullparent is the sequence the record sits in, written collection:id:field.
targetFrom walks up from an element to the record — and the field, when the
record still contains it — that produced it.
A field element must always be a descendant of its record's element. That containment is how the overlay decides which record a field belongs to; a field found above the record belongs to an ancestor record, not to this one.
The attributes are also exported by name: ID_ATTR, COLLECTION_ATTR,
FIELD_ATTR, PARENT_ATTR, INDEX_ATTR.
/overlay
createOverlay(options: OverlayOptions): Overlay| Option | Type | Notes |
| ------------ | ------------ | ------------------------------------------------------------ |
| hostOrigin | string | The one origin it talks to and takes orders from. Required. |
| onRefresh | () => void | Re-read the page's content. Required. |
| accent | string | Outline and label colour. Defaults to a high-contrast orange. |
| document | Document | Injected for tests; defaults to the ambient document. |
| window | Window | Injected for tests; defaults to the ambient window. |
Returns { destroy() }, which removes every listener and the outline element
and is safe to call twice.
onRefresh is yours because only your framework knows how its data is fetched —
router.refresh() on Next, a query invalidation elsewhere, a reload at worst.
The overlay never writes. A preview token is read-only by contract, so every change is made by the host with the operator's own session.
/react and /next
<PresentationOverlay hostOrigin={string} onRefresh={() => void} accent={string} />Renders nothing — the overlay owns its own element outside the React tree. The
/react version defaults onRefresh to a full page reload, which always works
and costs the scroll position. The /next version takes no onRefresh: it uses
router.refresh(), which re-renders the server component in place so the editor
keeps their scroll position. That route must be dynamic and its content read
uncached, or the refresh returns the same cached payload and the change appears
not to have happened.
/field
<Field name="title">{section.title}</Field>An inline span carrying nothing but the field's name, with no styling of its
own: an annotated page and a published one must lay out identically. It is a
separate module without "use client" on purpose — exporting it from /react
would put a client boundary around every annotated string on the page.
/protocol
PROTOCOL_VERSION: number
CHANNEL: 'stratum.presentation'
type Target = { collection: string; id: string; field?: string }
type FrameMessage // ready | hover | select | input
type HostMessage // hello | editable | highlight | patch | scrollTo | refresh
envelope<M>(message: M): Envelope<M>
readFrameMessage(data: unknown): FrameMessage | null
readHostMessage(data: unknown): HostMessage | null
sameTarget(a: Target | null, b: Target | null): booleanBoth sides announce PROTOCOL_VERSION on connect; a mismatch is reported to the
operator rather than guessed at. Every message rides in an envelope tagged with
CHANNEL, so unrelated postMessage traffic is ignorable.
Messages are addressed to one origin and accepted from one origin. A target read off the wire is rebuilt from its three known keys, so a sender cannot smuggle extra properties into an object the receiver may spread into a mutation input.
For coding agents
The package ships an agent skill covering the whole integration — the procedure, the traps that produce correct-looking source, and a verification checklist:
mkdir -p .claude/skills
cp -r node_modules/@stratum-core/presentation/skills/stratum-presentation .claude/skills/Claude Code picks it up from there. The traps in it are the ones that fail silently — a blank frame with nothing in any log, a site where no page hydrates, record ids leaking onto published pages — so an agent wiring this up unaided is likely to ship at least one of them.
Versioning
0.x: the wire protocol may change with a minor bump. PROTOCOL_VERSION is the
thing to check — the host and the site must agree on it, and a mismatch surfaces
to the operator rather than degrading silently.
License
Apache-2.0. See LICENSE.
