Guides · Embedding · SDK phase 1 of 3

Embed the editor

Phase 1 of the SDK ships three things a host can use today: an installable package, a framework-free <opendoc-editor> custom element, and a capability contract that is resolved before the frame's first navigation — so an embed cannot be talked out of the mode it was given. What it does not ship yet is at the bottom of this page, in as many words.

Try it first

The configuration playground puts every role, capability, chrome region and brand token on one page beside a running editor, and hands you the snippet that reproduces whatever you end up with. Reading a capability table is slower than switching to readonly and watching the ribbon go.

Everything below is extracted from code in this repository by webapp/tools/build-embed-docs.mjs, and a guard fails the build if the page and the code disagree. Each code panel's caption names the file it came from. That is not decoration: the public pages here have carried fabricated claims twice, in both directions, so no example on this page is typed and trusted — see how this page is kept honest.

Install it

@casualoffice/opendoc-embed version 0.1.0: 12 files, 214 kB unpacked, 0 runtime dependencies. Nothing publishes it to a registry yet, so today a host installs the tarball npm pack produces from a checkout. The packaging guard does exactly this — it packs the package, installs the tarball into an empty project, and imports it by its bare specifier, so "installable" is a measured fact rather than a manifest that looks right.

webapp/tests/embed_package.test.mjs — what the packaging guard really runs
# Nothing publishes @casualoffice/opendoc-embed to a registry yet, so a host
# installs the tarball `npm pack` produces from the checkout.
cd packages/opendoc-embed
npm pack                       # -> casualoffice-opendoc-embed-0.1.0.tgz
npm install /path/to/casualoffice-opendoc-embed-0.1.0.tgz

The package has 6 entry points, and a set of typings generated from the capability authority's real values rather than typed out beside it:

File
Ships as
What it is
./src/embed_element.mjs
import "@casualoffice/opendoc-embed"
The element class, the capability authority it re-exports, and defineOpenDocEditor.
./src/embed_define.js
import "@casualoffice/opendoc-embed/define"
The side-effect entry: importing it registers the tag and nothing else.
./src/capabilities.mjs
import "@casualoffice/opendoc-embed/capabilities"
Resolve a capability set without mounting anything — no DOM, so it answers in Node.
./src/host_contract.mjs
import "@casualoffice/opendoc-embed/contract"
The host contract: what each command requires, which events exist, what a refusal can say, and the envelope. No DOM.
./src/host_client.mjs
import "@casualoffice/opendoc-embed/client"
createHostClient — the postMessage transport, for a host that cannot reach into the frame.
./types/index.d.ts
Types
Generated from the authority's real values, so a role cannot exist in the types without existing in the code.

The package is the mount and the capability contract; it does not carry the engine. You also serve the editor's own static files (editor.html and the WebAssembly bundle beside it) from somewhere you control, and point editor-src at them.

Mount it

The element takes 8 attributes. mode names a role; can and chrome narrow it, per capability and per region, so the role never has to be the unit of what a host can express; prefs sets the container's opening positions. config takes an ONLYOFFICE-shaped block whole, for a host migrating one, and reports back everything it could not honour. This is the package's own documented usage, and a guard checks every attribute in it against the attributes the element really reads, and the mode value against the names the authority really accepts:

packages/opendoc-embed/README.md — the package's own usage, checked against the element
<script type="module">
  import "@casualoffice/opendoc-embed/define";
</script>

<opendoc-editor
  mode="commentor"
  can="-download"
  chrome="-brand"
  editor-src="/opendoc/editor.html"
  frame-title="Contract preview"
></opendoc-editor>

It is mode and not role because role is a global ARIA attribute: role="readonly" would publish an invalid ARIA role to every assistive technology on the page. frame-title has no default — an iframe with no accessible name is an axe frame-title violation, and the package has no business inventing English for your page, so you get one console warning and no label.

Mounting from script is the same three attributes. This is the code the demo host in this repository runs, not a paraphrase of it — the embed demo page mounts the editor twice, once this way and once as a hand-wired iframe, and the browser suite drives both:

webapp/src/embed_host_demo.js — mountElement()
/** The custom element's own path: the host DECLARES the configuration and the
 *  element resolves, sandboxes and reports. `can` is an attribute here, which
 *  is the whole difference from the panel above — `mountIframe` has to build
 *  the same list into a query string by hand. The readout is taken from the
 *  element's event rather than recomputed here: if the page and the element
 *  disagreed, the page would be hiding it. */
function mountElement({ mode, can }

Setting mode or editor-src on a live element remounts it rather than retuning it. A permission handed over after the document is already on screen was never a permission.

You do not have to use the package. A host can wire the iframe itself — and this is what that costs, which is also the argument for the element: every line here is a decision the element already made.

webapp/src/embed_host_demo.js — mountIframe()
/** What a host writes with no package at all — and why they should not have
 *  to. Every line below is a decision the element already made: put the mode
 *  and the withhold list in the URL before the frame is attached, derive the
 *  sandbox from the capability set, and give the frame an accessible name. */
function mountIframe(request) {
  const frame = document.createElement("iframe");
  frame.setAttribute("sandbox", resolveFor(request).sandbox.join(" "));
  frame.setAttribute("allow", "clipboard-read; clipboard-write");
  frame.title = panel.dataset.frameTitle ?? "";
  frame.src = editorUrl(request).href;
  return frame;
}

Resolution is pure and has no DOM, so a host can ask what a role grants before mounting anything — which matters, because each embed holds its own WebAssembly instance and a page should not have to boot one to find out what it would be allowed to do:

webapp/src/embed_host_demo.js — resolveFor()
/** The capability set, review mode and sandbox for a host's request, as an embed
 *  would see them: `framed: true`, because every embed on this page is framed.
 *
 *  `can` goes to the same `resolveCapabilities` the element and the editor page
 *  both call, so the readout cannot describe a narrowing the frame did not get. */
function resolveFor({ mode, can }

Serve it yourself

The element's editor-src points at an origin, and that origin is a directory of static files. There is no server behind the editor: the format work is in the WebAssembly module the browser downloads, so a host serves the editor the way it serves its own assets — a bucket, a CDN, its existing web server, or the container image in this repository. Nothing here phones home, and nothing here needs a licence check.

That also means the collaboration relay is a second, separate deployment rather than part of installing this. A host embedding a document for one reader needs no server at all; a host running a room several people edit at once adds one, and it holds no document even then. The compose file keeps the two apart, which is what the table below is reading:

Service
What it is
Compose profile
editor
The product: a static file server, with nothing behind it.
none, so it starts by default
relay
The optional relay. It orders chunks, holds no document, and is not needed to edit.
relay

The commands below are generated from that same compose file, so a renamed service or profile changes them here rather than leaving a page that tells you to run something that does not exist. The full deployment story — the journal's backup and restore, TLS termination, what can be monitored, honest sizing, and upgrading without losing a room's history — is the deployment guide.

# docker-compose.yml — generated from it, so a renamed profile changes this.

# The editor on its own. No server, nothing to create, nothing to back up.
docker compose up --build editor

# Adding the optional relay: create the room's journal once, then serve it.
docker compose run --rm relay create /var/lib/opendoc-relay/room.journal
docker compose --profile relay up --build

# Reading the journal back, on a stopped room or on a copy. Not a service:
# it runs and exits.
docker compose run --rm relay inspect /var/lib/opendoc-relay/room.journal

Capabilities, and the five roles

There are 9 capabilities. A host grants or withholds them; the editor asks the resolved set and never asks for a role name.

Capability
What it is
Enforced by
open
Replace the loaded document from inside the editor.
chrome
new
Start a blank document.
chrome
save
Write the document back out.
browser (allow-downloads), chrome
download
Export to a file the visitor keeps.
browser (allow-downloads), chrome
print
Open the browser's print dialog.
browser (allow-modals), chrome
edit
Change the document at all.
engine (review mode), chrome
comment
Annotate, and change the document as tracked suggestions.
engine (review mode), chrome
branding
Show OpenDoc's own name and mark inside the frame.
nothing yet
autosave
Keep drafts of the visitor's typing on this origin.
chrome

5 roles are presets over that set, in strictly increasing order of what they grant. The ordering is asserted, not described: a "higher" role quietly losing something a lower one had is the classic permissions defect, so the chain is proven monotone for every role there is and every role added later.

Role
Review mode
Grants
preview
viewing
Nothing at all.
Sandbox: no tokens beyond the unconditional two
readonly
viewing
print
Sandbox adds allow-modals
commentor
suggesting
print download comment
Sandbox adds allow-downloads allow-modals
edit
editing
save download print edit comment autosave
Sandbox adds allow-downloads allow-modals
owner
editing
open new save download print edit comment branding autosave
Sandbox adds allow-downloads allow-modals

Every embed's frame is sandboxed with allow-scripts allow-same-origin unconditionally — without them the editor cannot run its own WebAssembly or read its own origin, and an embed that cannot boot is not a permission decision. Together they do mean the frame can reach out of the sandbox, which is why the sandbox is one layer and not the enforcement.

8 names are accepted in total: the roles, plus the older preset names that are a published URL contract. An unrecognised name falls back to the framed default rather than throwing, and never to more than it — a typo in a host's URL must not leave the editor with no chrome, and must not grant anything extra either.

Legacy name
Relationship
Grants
standalone
Exactly owner.
open new save download print edit comment branding autosave
viewer
Exactly readonly.
print
embedded
Not an alias of any role.
save download print edit comment

A host that wires its own iframe passes the same vocabulary in the URL: ?mode=readonly. Framing alone is enough — a page that is not the top window resolves to embedded without the host having to discover a parameter, which is the whole point: the defect this closed was a framed editor being a standalone one, with File ▸ Open live inside someone else's page. ?autosave=1 and ?autosave=0 move the one per-capability override there is.

Per capability, not per tier

The roles are names for subsets, never the unit of enforcement. Nothing downstream branches on a role name: the review mode, the sandbox tokens and every gated command are computed from the capability set. That is what makes "print, download, save, edit and comment are withheld independently" a property of the design rather than a promise.

A host picks one of the 8 accepted names and then narrows it, per capability, with can. An embed whose editor-src carries ?can=-print,-download on top of mode="commentor" may comment and nothing else.

The list resolves through the same function the presets do, which is what keeps "roles are presets over the capability set" a fact rather than a claim — a guard proves every role is reachable by narrowing the role above it. It only ever takes away, whether or not an entry carries its minus sign, and an unknown entry is dropped: dropping narrows nothing, so a typo in a URL can never grant more than the role did.

preview is not readonly

They are two different presentations, not two rungs of a ladder. preview is the runtime as a layout and rendering engine — a picture of the document, for a thumbnail, an attachment preview, a search result. readonly is a reading experience of a published document: navigation, outline, find, zoom, page controls and a paper copy. The test of the difference is whether a static image could replace it: for preview, nearly; for readonly, no, because the reader navigates and searches. So preview is not "readonly minus print".

The chrome reflects that. A readonly container gets 10 regions — the menu bar, the navigation rail, the status bar, zoom, find and the document title — and no editing ribbon at all, not a ribbon full of greyed buttons. A preview container gets 0: the runtime paints the document and nothing else.

Why the menu bar, and not just "no ribbon"

readonly is granted exactly one capability — print — and File ▸ Print lives in the menu bar. Hiding the ribbon without revealing the menu bar would have made the one thing a reader is allowed to do unreachable, which is why a guard asserts that every capability a preset grants has an affordance in chrome that preset shows. It also keeps the editor's one-navigation-axis rule: a container has the tab strip or the menu bar, never both.

Three layers of enforcement

In order of how hard they are to defeat.

1 · The browser. The element derives the iframe's sandbox tokens from the capability set, and the browser does not negotiate: a frame without allow-downloads cannot start a download however its chrome or its engine is persuaded, and one without allow-modals cannot open the print dialog. For comparison, ONLYOFFICE's createIframe (apps/api/documents/api.js:1279) sets no sandbox attribute at all — that file contains the string nowhere — so this is a layer they do not have.

2 · The engine. A capability set maps onto the editor's three existing review modes: edit → Editing, comment → Suggesting (every body change recorded as a tracked revision), neither → Viewing. Viewing is read-only at a single fail-closed choke point that refuses every mutation path — typing, deletion, paste, toolbar formatting, table operations, comment and revision decisions — rather than relying on any individual control being disabled. A readonly embed is therefore in Viewing on arrival, not an Editing page with dimmed buttons, and the browser suite proves it by stripping every disabled and aria-disabled attribute in the frame and then attacking the document with typing, paste and 17 ribbon commands, one by one rather than a sample. The evidence is the engine's own word and character counts, not the DOM.

3 · The chrome. A withheld capability disables its control with a reason and never hides it: a control that vanishes teaches nothing, and a product that silently lacks a feature is indistinguishable from one that is broken. Open the command palette inside a readonly embed and look at Open… — it is present, it is disabled, and its title says why. This is the layer that is least complete today: the editor page consults 8 of the 9 capabilities.

Capability
Command id
What it decides
autosave
—
Decides whether this origin keeps drafts of the visitor's typing at all.
comment
—
Decides whether Suggesting is offered as a mode, and whether a comment can be resolved or deleted.
download
—
Every File ▸ Export row is present and disabled, with the reason as its title.
edit
—
Decides the review mode the editor arrives in, and whether the Editing button is offered.
new
file.new
File ▸ New is present and disabled, with the reason as its title.
open
file.open
File ▸ Open is present and disabled, with the reason as its title.
print
file.print
File ▸ Print is present and disabled, with the reason as its title.
save
file.save
File ▸ Save is present and disabled, with the reason as its title.

The other 1 are not consulted by the chrome at all. print, download and save are withheld by the sandbox — which works, but the refusal arrives from the browser rather than from a control that explains itself, and the editor's own status line can still report a save that the browser declined. branding is consulted nowhere yet: withholding it does not remove OpenDoc's mark from the frame. Both are listed below rather than left to be discovered.

Nothing here is gated

The package is Apache-2.0 and consults no licence, in any code path. That is the whole competitive position rather than a footnote: ONLYOFFICE's host-customization path early-returns when the editor is not licensed — apps/common/main/lib/controller/LayoutManager.js:68, if (!_licensed || !config) return; — so the customization a host is offered is a licence check away from doing nothing. Gating ours would surrender the only structural advantage that cannot be copied.

Commanding it, and being told what happened

A host gets a typed way to command the editor and to hear what it did, and it is the same contract whether they hold a reference to the editor or are talking to it across an iframe boundary. Contract version 1: 197 commands by name and 11 families of value commands, 7 events, 7 refusal codes and 4 verbs (describe execute query ping).

Both transports are built from one schema, webapp/src/host_contract.mjs. Neither carries a command list, an event list or a refusal code of its own — which is what makes adding a command to one without the other a build failure rather than a half-reachable API. The in-process handle is window.opendoc inside the frame; the postMessage client is createHostClient. The demo host drives both, and the only difference between them is how the call travels:

webapp/src/embed_host_demo.js — runCommand()
/**
 * Runs one command over one transport, and returns exactly what came back.
 *
 * The two branches are the whole demonstration, and they are four lines apart so a
 * reader can see that the only difference between them is how the call travels.
 */
async function runCommand(panel, transport, id) {
  if (transport === "post-message") {
    const client = clients.get(panel);
    return client ? client.execute(id) : null;
  }
  const session = liveFrame(panel)?.contentWindow?.opendoc ?? null;
  return session ? session.execute(id) : null;
}

A command id is the unit of addressing because it already was: the editor's registry is reached by id, every keyboard chord resolves to one, and 111 ribbon controls carry data-command. Most ids are literals; some name a value of a control (format.family.Georgia, style.Heading 1, view.zoom.150) and those sets are generated from the document, the font inventory or markup, so the schema declares the family prefix instead. Measured in a browser, the registry offers 214 ids, of which 93 are family members — and the font inventory differed between two reads of the same document, so a table of literals would have been wrong on arrival.

Events

change carries a revision handle and a dirty flag, never a snapshot. An event that serialised the document on every keystroke would make per-interaction work grow with the document, which is a defect however fast it feels on a small file.

Event
Detail
When it fires
ready
contract capabilities editingMode commands
Once, when the document is open and on screen.
change
revision dirty
After every applied edit, whoever made it.
selection
anchor focus hasRange
Whenever the caret or the selection moves.
save
format name bytes
After File ▸ Save writes the document out.
export
format name bytes
After an export writes another format out.
error
code message command
When something failed that was not a command being refused.
refusal
code command requires message
Whenever a command is refused, including one nobody asked for over the API.

A refusal is a value, not a toast

execute resolves to { ok, command, revision } or to { ok: false, refusal: { code, command, requires, message } }, and a host branches on the code — never on the message, which is localised and belongs to the chrome. This matters because the same defect has already been fixed one layer down: Save on a readonly embed used to run, fire no download, and report “Saved”. An API that only emitted a toast would reintroduce it.

Refusal code
What it means
Decided by
unknown-command
No such command in this state.
the contract
capability-withheld
The host did not grant what this command requires. Decided before dispatch, so the engine is never asked.
the API
unavailable
The command exists and cannot run right now; the message is the chrome's own reason.
the chrome
engine-refused
It ran and the engine refused it — a read-only document, or Viewing mode.
the engine
threw
It raised; the message is the exception's.
the command
bad-request
The envelope or the arguments were not the contract's.
the transport
timeout
The transport gave up waiting for an answer.
the transport

The capability gate is the fourth enforcement layer, beside the browser sandbox, the engine's Viewing choke point and the chrome, and it runs before dispatch. A host that was not granted edit cannot run an editing command through the API either — which is the whole point, because three locked doors and an unlocked one is one unlocked door. It is deliberately stricter than the chrome in one place: deciding a tracked change writes it into the document outright, so review.accept* requires edit and a commentor cannot accept their own suggestions. Google Docs' Commenter cannot either.

The origin contract

A wildcard target origin in an editor that can be embedded anywhere is a security decision, not a default, so there is no default: the editor accepts its own origin and any origin the deployment names with ?hostOrigin=, and nothing else. A message from an unknown origin is dropped in silence rather than answered with an error, because an error reply tells whoever is probing what this frame is. Every outbound post names an explicit target; postMessage(msg, "*") appears nowhere, and that is driven rather than grepped — a source scan would pass the moment the wildcard arrived through a variable.

webapp/src/host_contract.mjs — parseOriginAllowlist()
/**
 * Parses a host-declared origin allowlist.
 *
 * THE ORIGIN CONTRACT, stated rather than defaulted. A wildcard target origin in
 * an editor that can be embedded anywhere is a security decision, not a default:
 * `postMessage(documentContent, "*")` hands the bytes to whatever page happens
 * to be the parent. ONLYOFFICE does exactly that, with a TODO where the decision
 * should be (`reference/web-apps/apps/api/documents/api.js:1303-1306`).
 *
 * WHAT WE ACCEPT, AND WHY:
 *
 *   * The editor's OWN origin, always and implicitly. A same-origin embed — the
 *     shape `<opendoc-editor>` produces, because `sandboxTokensFor` grants
 *     `allow-same-origin` so the frame can reach its own WebAssembly — is the
 *     common case, and it needs no configuration to work.
 *   * Any origin the host names explicitly, as `?hostOrigin=https://app.example`
 *     (comma-separated for several). Explicit, so a cross-origin host is a
 *     deliberate act by whoever deployed the editor rather than whoever framed
 *     it.
 *   * Nothing else. `*` and `null` are REJECTED rather than honoured: they are
 *     the two spellings of "anyone", and an editor that accepts them has no
 *     origin contract at all. A rejected entry is dropped, so a typo narrows the
 *     allowlist and never widens it.
 *
 * Complexity: O(entries).
 *
 * @param {string|null|undefined} raw the `hostOrigin` parameter, if any.
 * @param {string} selfOrigin the editor's own origin.
 * @returns {readonly string[]} absolute origins, deduplicated, `selfOrigin` first.
 */
export function parseOriginAllowlist(raw, selfOrigin) {
  const allowed = [];
  const add = (value) => {
    if (typeof value !== "string") return;
    const origin = value.trim();
    // "null" is the origin a sandboxed or `data:` document reports, and "*" is
    // the wildcard. Neither identifies anybody.
    if (!origin || origin === "*" || origin === "null") return;
    let parsed = null;
    try {
      parsed = new URL(origin);
    } catch {
      return;
    }
    // An entry must BE an origin, not a URL with a path: `https://x/y` and
    // `https://x` are the same origin, and accepting the first spelling invites
    // a host to believe the path is part of the check.
    if (parsed.origin !== origin) return;
    if (!allowed.includes(parsed.origin)) allowed.push(parsed.origin);
  };
  add(selfOrigin);
  for (const entry of String(raw ?? "").split(",")) add(entry);
  return Object.freeze(allowed);
}

Make it your product

Name, mark, palette, font and wording are one file — webapp/brand.json — and one command. Nothing below needs a code change: the generator writes the stylesheet, the module and the icon links the editor reads, and build.sh fails if a committed artifact is not what a fresh run produces.

webapp/brand.example.json — a white-label, and the input to its own guard
{
  "version": 1,
  "name": "Northwind Docs",
  "mark": "./opendoc-mark.svg",
  "markHref": "https://example.invalid/northwind",
  "customer": {
    "name": "Northwind Trading Co.",
    "info": "Document workspace for the Northwind field teams.",
    "address": "14 Harbour Road, Bristol BS1 4RN",
    "www": "https://example.invalid/northwind",
    "mail": "mailto:[email protected]",
    "phone": "tel:+441170000000"
  },
  "feedback": {
    "text": "Tell us what broke",
    "url": "https://example.invalid/northwind/support"
  },
  "help": {
    "text": "Northwind help centre",
    "url": "https://example.invalid/northwind/help"
  },
  "tabTitle": "document",
  "theme": {
    "tokens": {
      "--accent": "#0f6d6a",
      "--accent-ink": "#ffffff"
    },
    "light": {
      "--bg": "#f2f0ec",
      "--bg-2": "#f7f6f3",
      "--surface": "#ffffff",
      "--surface-alt": "#f4f2ee",
      "--ink": "#1b1a18",
      "--muted": "#5c5953",
      "--faint": "#67635c",
      "--line": "#e4e1da",
      "--line-strong": "#cec9bf",
      "--primary-bg": "#1b1a18",
      "--primary-ink": "#faf9f6",
      "--accent-text": "#0c5a57"
    },
    "dark": {
      "--bg": "#16181a",
      "--bg-2": "#1c1f21",
      "--surface": "#202426",
      "--surface-alt": "#272b2e",
      "--ink": "#e9eaea",
      "--muted": "#a9aeb0",
      "--faint": "#9ba1a3",
      "--line": "#303538",
      "--line-strong": "#414749",
      "--primary-bg": "#e9eaea",
      "--primary-ink": "#16181a",
      "--accent-text": "#5fc3bf"
    }
  },
  "font": null,
  "strings": {
    "de": {
      "branding.themeSetByHost": "Die Farbe wird von Northwind Docs vorgegeben."
    }
  }
}

18 token names are overridable and frozen as a public contract: the accent family, the surfaces, the foregrounds, the lines and the primary action. Geometry, z-order and type scale are refused, because those are structure rather than brand. So is the --paper* layer: the sheet is white in both themes on purpose so that everything drawn onto it has a fixed contrast partner, and a host who darkened it would put the dark theme's insertion and deletion marks on a dark page.

An unreadable palette is refused, not shipped

A host palette is measured as it will render — our tokens with yours written over them, per theme, with var() and color-mix() resolved — because a brand colour measured on its own passes when it is unreadable only against the surface it will be painted on. Text pairs clear 4.5:1 and edges clear 3:1, which is WCAG's own split, so a hairline is not held to the prose bar. A failure stops the build and says what would work:

webapp/tools/build-brand.mjs — what a failing palette is told
$ node webapp/tools/build-brand.mjs
brand.json was refused:
  - light: --muted (#9aa0a6) on --surface (#ffffff) measures 2.64:1 and must clear 4.5:1. You set --muted: #72777b there would pass, on the same hue.
  - light: --accent-ink (#ffffff) on --accent (#f5a524) measures 2.04:1 and must clear 4.5:1. You set --accent-ink: #484848 there would pass, on the same hue.
  - light: --accent-text (var(--accent)) on --surface (#ffffff) measures 2.04:1 and must clear 4.5:1. You set --accent: #a06c17 there would pass, on the same hue.
  - system dark: --accent-ink (#ffffff) on --accent (#f5a524) measures 2.04:1 and must clear 4.5:1. You set --accent-ink: #484848 there would pass, on the same hue.
  - explicit dark: --accent-ink (#ffffff) on --accent (#f5a524) measures 2.04:1 and must clear 4.5:1. You set --accent-ink: #484848 there would pass, on the same hue.

Refused rather than corrected, because shipping your product in a colour you did not choose is a worse outcome than a failed build — and rather than warned about, because a warning in a build log is read once and then the integrator ships it. The failure lands on the person who can still fix it, at the moment it is free to fix.

Choose the chrome

25 regions, of which 8 are ribbon bands, each withheld independently with chrome. A withheld region is a presentation decision about your page, not a permission — so it is absent and silent, where a command you were offered but cannot run right now is disabled and says why. "Never, for you" versus "not right now".

Region
What it is
readonly
preview
brand
Our name and mark in the top bar.
—
—
title
The document name, and renaming it.
yes
—
state
The document-state chips and Document properties in the top bar.
yes
—
review
The Editing / Suggesting / Read-only switcher in the top bar.
yes
—
menu
The application menu bar — one of the two navigation axes.
yes
—
ribbon
The whole tabbed ribbon, strip and bands together.
—
—
band.file
The File page: open, save, export, print, properties, settings.
—
—
band.home
Character and paragraph formatting, styles, clipboard, undo.
—
—
band.insert
Tables, pictures, shapes, links, comments, headers, symbols.
—
—
band.layout
Page setup, margins, columns, spacing, watermark, arrange.
—
—
band.references
Captions, cross-references, contents, footnotes, fields.
—
—
band.review
Tracked changes, comments, proofing, the review mode control.
—
—
band.view
Outline, zoom, page setup, showing changes.
—
—
band.table
The contextual table band, present when the caret is in a table.
—
—
rail
The left navigation rail: outline and page thumbnails.
yes
—
ruler
The ruler strip above the page: indents and tab stops.
—
—
caret
The text caret and the selection highlight on the page.
yes
—
context
The right-click menu on the document.
yes
—
objects
Object selection: the outline, its handles and the object bar.
—
—
history
The version-history timeline, its ribbon entry and its preview bar.
—
—
status
The status bar: counts, page number, language, mode.
yes
—
zoom
The zoom cluster in the status bar.
yes
—
find
The find and replace card.
yes
—
selection
The floating toolbar above a selection.
—
—
settings
The settings dialog and the gear that opens it.
—
—

Change the words

Setting name renames the product in all nineteen languages. The override set is derived by substitution over the committed catalogues rather than written per locale, because nineteen hand-written overrides are nineteen chances to forget one and the one forgotten is the one a reader sees. Host overrides are a layer on the same t() lookup the catalogues resolve through — * for every language, an exact tag to narrow — and there is deliberately no second string mechanism.

A face is named as a file you place beside the editor, never a URL. The chrome self-hosts every font it uses and a test asserts no remote font reference, because local-first means the chrome paints with the network off; a white-label that fetched a webfont from a CDN would break that for every deployment that used it.

What you installed, and what it speaks

@casualoffice/opendoc-embed/release reports three numbers, and they are deliberately not one. The package version is what npm resolved; the engine version (0.0.1) is the Rust workspace the WebAssembly came from; the contract version (1) is what the editor speaks, and it is stable across package releases because an added command, event or refusal code is additive — tying it to the package version would make you re-pin for changes that break nothing. The licence is Apache-2.0, with no licence check, no tab counting and no modal anywhere in it.

There is no build commit in that module. The commit is stamped into the deployed assets at deploy time; a committed file carrying a hash would be wrong for every build after the one that wrote it.

How this page is kept honest

The pages of this site have carried fabricated claims twice, in both directions — a parity figure that contradicted the fidelity page, a corpus file that did not exist, a repaint timing with no benchmark, and three shipped features listed as "Not yet". Understating is also false. So this page is built the way the package is:

webapp/tools/build-embed-docs.mjs generates every code panel, every table and every number above from the code they document, and --check fails when the committed page is not what a fresh run produces. That makes both directions of drift a build failure: editing the page by hand, and changing the code without regenerating the page. webapp/tests/embedding_page.test.mjs then re-derives the numbers independently of the generator, proves each extracted snippet still appears verbatim in the file its caption names, and proves every item under "does not do yet" is still true. Both run in the browser-smoke job of .github/workflows/ci.yml, which is itself asserted, because a gate nobody armed has been claimed here before.

The behavioural claims are not this page's word either. These are the tests they rest on:

File
Lane
The test
webapp/tests/roles.test.mjs
npm run test:unit
the role chain is monotone: a higher role never loses a lower one's grant
webapp/tests/embed_package.test.mjs
npm run test:unit
the package packs, installs, and imports — not just claims to
webapp/tests/e2e/embedded-capability-gate.spec.mjs
npm run test:e2e
both embeds resolve their role before first paint, and the chrome refuses with a reason
webapp/tests/e2e/embedded-capability-gate.spec.mjs
npm run test:e2e
the simplest possible embed is not a standalone editor
webapp/tests/host_contract.test.mjs
npm run test:unit
both transports return the identical result, for every command in the contract
webapp/tests/host_contract.test.mjs
npm run test:unit
nothing is ever posted to a wildcard, in either direction
webapp/tests/host_contract.test.mjs
npm run test:unit
a message from an origin the editor does not know is dropped in silence
webapp/tests/e2e/host-contract.spec.mjs
npm run test:e2e
the contract covers the command registry exactly, in every state a family needs
webapp/tests/e2e/host-contract.spec.mjs
npm run test:e2e
a refusal the CHROME makes is a refusal the API makes
webapp/tests/e2e/host-contract.spec.mjs
npm run test:e2e
a command the contract calls ungated really does not touch the document
crates/casual-doc-sdk/src/host_parity.rs
cargo test
the_editor_and_this_crate_declare_the_same_host_events
crates/casual-doc-sdk/src/host_parity.rs
cargo test
the_editor_and_this_crate_declare_the_same_refusal_codes
crates/casual-doc-sdk/src/host_parity.rs
cargo test
a_runtime_event_maps_onto_a_host_event_rather_than_a_new_name
webapp/tests/e2e/embedded-capability-gate.spec.mjs
npm run test:e2e
a readonly host cannot mutate the document through the engine, chrome defeated

What this does not do yet

Phase 1 is one of three. Each item here is checked by the guard, so it cannot outlive the gap it describes.