@gridd/library-embed
v0.2.1
Published
Embeddable DeckAI slide library: a React iframe component driving the gridd-library postMessage protocol, plus the offline layers it runs on — a pluggable Blob cache (IndexedDB by default) and remote/local data sources behind one interface
Maintainers
Readme
@gridd/library-embed
React component that embeds the DeckAI slide library in an iframe and drives the
gridd-library postMessage protocol, so the host app writes no raw message handlers.
It also ships the pieces that make the Library work with no network: the built UI, a
pluggable Blob cache (IndexedDB by default), and remote/local data sources behind one
interface — see Offline. Those layers are
framework-free and exported separately, so the same code runs in a React task pane, in a
sync web worker, and in the SlideCraft desktop shell.
It is the sibling of @gridd/embed but a
separate package on its own version line: a different application (the DeckAI add-in,
not Gridd's embeddable.html), a different origin, and a different protocol. Nothing is
shared between the two at runtime.
Install
npm install @gridd/library-embedreact (^18 || ^19) is a peer dependency.
Usage
import { GriddLibrary } from "@gridd/library-embed/react";
<GriddLibrary
baseUrl="https://black-ground-08064670f.1.azurestaticapps.net/taskpane.html"
mode="library"
styleSheet={deck.styleSheet}
getIdToken={({ forceRefresh }) => auth.currentUser?.getIdToken(forceRefresh) ?? Promise.resolve(null)}
onSlidePicked={(griddDoc, pick) => copyIntoSelectedSlide(griddDoc, pick.name)}
onSignInRequest={() => openSignInDialog()}
width="100%"
height="100%"
/>;The root export is the same module, so from "@gridd/library-embed" also works.
Props
| Prop | Type | Default | Description |
| ----------------- | ---------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------- |
| baseUrl | string | required | URL of the add-in's taskpane.html. Its origin is the only one trusted or posted to. |
| mode | "library" | "library" | Which surface the add-in renders. Sent as ?mode= and repeated in the handshake context. |
| styleSheet | GriddStyleSheetData | — | Host deck's stylesheet, so previews match. Controlled — re-pushed on identity change. |
| idToken | string \| null | — | Firebase ID token of the signed-in user. Controlled — re-pushed on change. |
| getIdToken | ({ forceRefresh }) => Promise<string \| null> | — | Preferred over idToken: mints a token on demand, including when the iframe reports staleness. |
| width/height | string \| number | "100%" | Iframe box. |
| onReady | () => void | — | Fires once the handshake completes. |
| onSlidePicked | (doc: GriddSlideDocument, pick: LibraryPick) => void | — | The user chose a slide. doc is the Gridd body to copy; pick carries its metadata. |
| onSignInRequest | () => void | — | The user hit a gate needing the host's sign-in flow. The panel has no login of its own. |
| onError | (error: LibraryError) => void | — | The iframe (or token minting) failed in a way worth surfacing. |
library mode
The add-in is a PowerPoint task pane by default. mode=library asks it to mount just
the slide-library browser — no Office chrome and no Office.js on the render path, the
same bypass idea as Gridd's ?styleSheet=1 route. In that mode "insert into slide" does
not write to PowerPoint; it posts gridd-library-pick to the parent instead.
Two query parameters are appended to baseUrl:
| Param | Meaning |
| ------------ | ------------------------------------------------------------------------------------------------ |
| mode | library — render the embeddable library. |
| hostOrigin | The embedding page's origin. Advisory: it lets the add-in target its replies instead of "*". |
Protocol
Message-type constants are exported (READY_TYPE, PICK_TYPE, …) so a host never types a
literal. The add-in side deliberately mirrors them in
src/taskpane/embed/libraryProtocol.ts rather than importing this package, so its webpack
build does not depend on this package being built or published — change one, change both,
and bump PROTOCOL_VERSION in both.
iframe ──► host gridd-library-ready "I am mounted and listening"
host ──► iframe gridd-library-context mode + stylesheet + ID token
iframe ──► host gridd-library-token-request "my token is stale"
host ──► iframe gridd-library-token a fresh Firebase ID token
iframe ──► host gridd-library-pick the chosen slide's Gridd JSON
host ──► iframe gridd-library-pick-result ack, so the panel can confirm
iframe ──► host gridd-library-signin-request "the host should sign the user in"
iframe ──► host gridd-library-error surfaced to onErrorHandshake
- On mount the iframe posts
{ type: "gridd-library-ready", id }. - The host replies
gridd-library-contextwith the deck's stylesheet and the current ID token.ready— not iframeonLoad— is the reliable "app is listening" signal, so context is never delivered earlier than this. - Whenever
styleSheetor the token changes, the host pushes a freshgridd-library-context. Each push carries the complete context and a monotonicseq; the iframe replaces its copy wholesale (an omitted field means "not set", never "unchanged") and may ignore aseqlower than the last one it applied.
Host→iframe messages also carry protocolVersion (currently 1).
Pick payload
gridd-library-pick from the iframe:
{
"type": "gridd-library-pick",
"id": 7, // optional; when present the host acks with gridd-library-pick-result
"document": { /* Gridd JSON */ }, // object, or a JSON string — both accepted
"slideId": "value-chain-3", // optional metadata below this line
"name": "Value chain",
"category": "Strategy",
"premium": true,
"thumbnail": "https://…/value-chain.png"
}document is the add-in's existing template-apply shape, which lines up with Gridd's own
document keys:
type GriddSlideDocument = {
cols?: unknown[];
rows?: unknown[];
cells?: unknown; // ProseMirror docs, keyed by cell UUID
merged?: unknown[];
borders?: unknown[];
cellStyles?: Record<string, unknown>; // keyed by cell UUID
spacerStyles?: Record<string, unknown>;
};The bridge does not police or normalise this payload — every field is optional and it
is handed to onSlidePicked as received. A host that needs guaranteed defaults (or that
wants to regenerate cell UUIDs to avoid collisions across embeds) should run it through
its own adapter before writing it into its store.
Auth
The host owns the login. The studio signs the user in with Firebase and passes that user's ID token down; the iframe forwards it to the premium server as a bearer token. One identity across the studio, the library, and the add-in — no second sign-in in the panel.
- Prefer
getIdTokenoveridToken: Firebase ID tokens expire after ~1h, and the callback lets both the host (on prop change) and the iframe (viagridd-library-token-request) obtain a fresh one. WithidTokenalone the host must rotate the prop itself. - Signed out, pass
null/omit. The library still browses free slides; a premium slide postsgridd-library-signin-requestso the host can open its own sign-in flow. - The server is the trust boundary. It verifies the ID token and the user's entitlement before returning premium slide data. Lock badges in the library UI are UX, never the gate.
Security
The iframe posts with "*" — it cannot know the embedding origin ahead of time — so this
component is the strict side of every exchange:
- messages are ignored unless
event.sourceis this component's own iframe window; - messages are ignored unless
event.originequals the origin ofbaseUrl; - every reply and push names that concrete origin — never
"*".
This matters more than in @gridd/embed, because a Firebase ID token crosses the boundary.
It is posted only to the validated add-in origin, and it is scoped to that origin
deliberately.
Offline: the cache and the two data sources
The Library is a server-backed premium catalog, so offline it has two problems — the UI is remote and the catalog data is remote. Both are solved inside this package, once, so the website, the desktop app and the PowerPoint add-in share one implementation.
Shipping the UI
@gridd/library-embed/node (Node condition only) resolves the built Library UI on disk:
import { libraryAppPath, libraryAppEntry, hasLibraryApp } from "@gridd/library-embed/node";It is Electron only. In the add-in the Library is the taskpane and serves itself over
https; nothing there needs a path. The bundle is produced by npm run build:app, which
copies the add-in's webpack output into app/ and refuses the copy if taskpane.html has
grown a root-relative or self-hosted absolute URL — either would make the desktop shell
fetch its own UI over the network.
@gridd/library-embed/cache
One interface, several backends, picked at runtime:
import { openLibraryCache, formatCacheSize } from "@gridd/library-embed/cache";
const cache = await openLibraryCache(); // never throws
cache.store.backend; // "indexeddb" | "bridge" | "memory"
await cache.store.put("thumbs/12.png", blob);
await cache.estimate(); // { bytes, entries, persisted, ... }| | |
| --- | --- |
| Values are Blobs | localStorage caps at ~5 MB and stores strings; thumbnails alone exceed that. It is used only for the tiny "catalog last synced at" marker. |
| IndexedDB is the default | It is the only backend available in the PowerPoint add-in, so it is the one that has to work first and the one every environment shares. |
| A bridge is optional | An Electron preload may set globalThis.griddLibraryCacheBridge to add a disk backend — needed only for the loopback-origin fallback, where the origin changes each launch and takes the IndexedDB with it. It is a backend, never a second code path. |
| A blocked store degrades | Safari/WKWebView can refuse third-party storage outright. openLibraryCache then returns a memory-only store for the session instead of throwing. durable is false, and the UI says so. |
| Nothing here is storage | Every entry is re-derivable from the server. navigator.storage.persist() is requested and usually refused in a partitioned third-party context; nothing the user owns may ever live only here. |
| It is capped | enforceCacheLimit sweeps LRU to a ceiling, pinning catalog*.json and meta/. A cache that grows without bound is a support ticket about disk space — and in a partitioned bucket, silent eviction of the entries you wanted most. |
Partitioning
Chromium 115+ partitions storage by (top-level site, frame origin). The Library's IndexedDB inside PowerPoint on the web is a different bucket from the same origin inside Excel on the web, and different again from inside SlideCraft. Two consequences to build for, not discover:
- each host warms its own cache — never tell the user "you already downloaded this";
- a cache warmed in the add-in does nothing for SlideCraft, and the reverse.
resolveOfficePartitionKey() reads Office.context.partitionKey, which Office exposes
exactly for this. It is a hash of the top-level domain and the add-in domain, and it is
undefined in environments without partitioning, such as the Office-on-Windows webview —
both paths are supported. Microsoft's guidance prefixes localStorage keys with it; the
equivalent here is the IndexedDB database name, so namespaceDatabaseName puts it there.
@gridd/library-embed/data
Two sources behind one interface — remote (the authenticated premium API) and local
(a static snapshot: catalog.json, slides/<id>.json, files/<id>.pptx,
thumbs/<id>.<ext>) — and a coordinator that layers them:
import { LibraryData, RemoteLibrarySource } from "@gridd/library-embed/data";
const library = new LibraryData({
cache,
remote: new RemoteLibrarySource({ baseUrl, getIdToken }),
});
await library.getCatalog(); // remote-first up, cache-first down
await library.getSlide(id, catalogEntry); // the record: Gridd body + metadata
await library.getSlideFile(id, catalogEntry); // the PPTX bytes, ETag-revalidated
await library.sync(); // "Update library"
await library.getStatus(); // freshness + offline entitlement windowCache-first when the network is down, remote-first when it is up. navigator.onLine
only picks which to try first; a failed remote read always falls through to the cache, and
an "offline" read that misses still tries the network before giving up.
A slide is two payloads. The record comes from /frameworks/<id> with include=none,
so the PPTX never rides inside the JSON — base64 in a body costs a third again in bandwidth
and in cache, and cannot be revalidated on its own. The bytes come from
/frameworks/<id>/file, are cached raw, and are re-read with If-None-Match, so inserting
the same template twice costs a 304 rather than the file. (A cross-origin deployment must
send Access-Control-Expose-Headers: ETag, or every read is an unconditional download.)
sync() never pre-downloads the whole premium catalog — that is size the user did not ask
for and an entitlement leak. It fetches the entitlement first, then caches thumbnails for
the whole catalog (a locked slide still shows its picture) but slide bodies and files only
for the entitled set, and prunes both when they fall out of entitlement on the next run.
Entitlements offline
The server is the trust boundary, and offline there is no server, so the cached entitlement
decision is trusted for the same window as the offline session — DEFAULT_OFFLINE_ENTITLEMENT_MS,
48 hours — and expires with it. After that the Library shows the catalog but cannot open
premium slides.
Say this in the UI, because a feature that quietly stops working mid-flight is worse than one with a stated limit:
catalog from 12 March · premium available offline until at most 14 March
"at most", because an evictable cache can end it early. And re-sync silently whenever a network appears, so the window keeps moving and the user rarely meets it.
Verifying it
Run this in all three runtimes — SlideCraft web, SlideCraft desktop, and the PowerPoint add-in on both Windows (WebView2) and Mac (WKWebView). Safari is where third-party storage assumptions break, so the Mac pass is not optional.
In each:
- Load online once and let the sync finish.
- Go offline and reload.
- The catalog renders from cache; a cached slide inserts; thumbnails show; nothing spins forever waiting on the API.
- Then clear site data and reload offline: it must say "catalog unavailable offline" rather than hanging.
Also check, per host: settings shows a cache size and clears it; the status line names the catalog date and the offline premium ceiling; and where storage is blocked the panel still works and says the library is not saved on this device.
Build
npm install
npm run build # tsup → dist/{react,cache,data}/index.{js,cjs,d.ts}
npm run build:app # add-in webpack output → app/ (needs a built add-in)
npm run typecheckbuild:app reads the repo's dist/; override with LIBRARY_APP_DIST=/path/to/build.
Versioning
Versioned and published independently of @gridd/embed. Bump the minor for additive
protocol messages or props; bump the major (and PROTOCOL_VERSION) for a change the
add-in side must land in lockstep.
