Case Study: Penpot Integration
Engineering solutions, DOM lifecycle constraints, and architectural capabilities from building SyncingBoard for Penpot.
This case study documents the real-world engineering decisions, DOM lifecycle constraints, and architectural solutions developed while building SyncingBoard’s integration with Penpot.
It is written both as technical documentation for the SyncingBoard codebase and as a comprehensive set of feedback and API insights for the Penpot core development team and open-source community.
1. Architectural Constraints in Penpot
Active-Page DOM Isolation (viewport.cljs)
Penpot’s web editor is built on ClojureScript using Rum (a React wrapper). To maintain high interactive canvas performance on complex design files, Penpot’s rendering engine isolates page DOM trees (viewport.cljs): only shape nodes belonging to the active current-page-id are instantiated and mounted in the browser element tree.
When a product team organizes multi-screen user flows or design systems across multiple Penpot pages (e.g., 01-Onboarding, 02-Dashboard, 03-Settings), background API fetches or canvas queries for off-page frames return unmounted or empty element payloads. Because React/Rum unmounts inactive page component trees, element geometry methods like getBoundingClientRect() or SVG DOM serialization cannot execute on off-page shape IDs.
How SyncingBoard solves this: See Solution #1: Programmatic Multi-Page Batch Navigation below for how the companion extension automates page switching and DOM mount polling during batch exports.
Lack of Shape-Level URL Deep-Linking
Unlike Figma (which encodes individual frame targets directly in standard browser share URLs via ?node-id=X:Y), Penpot workspace URLs only encode file and page identifiers (router.cljs):
Figma URL: https://www.figma.com/design/:fileKey/:fileName?node-id=123-456
Penpot URL: https://design.penpot.app/#/workspace/:fileId/:pageIdBecause standard Penpot share links do not encode shape-level query parameters, third-party integrations cannot target, import, or sync an individual Penpot frame simply by parsing URL strings on a backend server.
How SyncingBoard solves this: See Solution #2: Selection-Based Companion Bridge below for how active selection detection streams canvas payloads without shape-level URL links.
2. Engineering Solutions Tailored for Penpot
1. Programmatic Multi-Page Batch Navigation
To solve Penpot’s active-page DOM unmounting constraint during multi-frame batch syncs, SyncingBoard’s browser companion extension implements programmatic page automation in companionRelayClient.ts and usePenpotImporter.ts:
// Architectural sequence in companionRelayClient.ts
for (const targetFrame of batchQueue) {
if (targetFrame.pageId !== currentPageId) {
// 1. Trigger Penpot's internal page router RPC
await penpotPlugin.navigateToPage(targetFrame.pageId);
// 2. Poll DOM mutation observers until ClojureScript finishes mounting
await waitForDomMount(`[data-shape-id="${targetFrame.id}"]`);
}
// 3. Extract SVG vector payload from freshly mounted DOM
const svgPayload = await exportMountedFrameSvg(targetFrame.id);
// 4. Stream payload across WebSockets to Miro
await relayClient.sendPayload(svgPayload);
}- Page Mapping: Maps requested frame IDs to their parent Penpot page IDs in memory.
- Programmatic Router Triggers: Executes Penpot’s internal page navigation RPC (
penpot.navigateToPage(pageId)) for inactive target pages. - Mount Cycle Polling: Listens to DOM mutation observers until ClojureScript finishes mounting the page’s Web Components.
- Vector Extraction & Streaming: Extracts rendered SVG payloads directly from the mounted DOM and relays them via WebSockets (
/api/relay/penpot/result) to Miro before advancing to the next page automatically.
2. Selection-Based Companion Bridge
To work around the absence of shape-level URL deep-linking in Penpot, SyncingBoard relies on active selection detection via the companion extension in PenpotAdapter.ts.
When a user selects a frame inside Penpot, the companion script intercepts the selection event, packages the node payload (including shape ID, title, and SVG payload), and streams it to the active Miro plugin session across WebSockets. This bypasses URL parsing limitations completely and enables zero-click frame selection mirroring.
3. Zero-Config Self-Hosted Gateway
Because Penpot is open-source and self-hostable on private corporate domains (e.g., penpot.internal.company.com), requiring OAuth 2.0 app registration for every custom domain creates high setup friction.
SyncingBoard solved this in penpot-companion-ui.html by running the companion script inside the user’s active browser session. It leverages existing authenticated session cookies, enabling SyncingBoard to work out-of-the-box on any private or self-hosted Penpot instance without registering OAuth client credentials or modifying server configurations.
4. Client-Side Vector Sanitization Pipeline
Penpot exports raw, un-rasterized SVG strings containing scoped ClojureScript CSS classes (.shape-fill-1), un-inlined web fonts, and relative viewport coordinates.
In /api/relay/update-image/route.ts, SyncingBoard implements a vector sanitization pipeline:
- Converts class-based CSS rules into inline
style=""attributes on individual SVG element tags. - Recalculates relative
viewBoxbounds to eliminate surrounding whitespace padding. - Strips foreign CSS selectors so SVGs render cleanly across Miro and FigJam without styling leakage.
5. ClojureScript HTML Entity Sanitization
Penpot stores element names as ClojureScript structures encoding special characters as HTML entities (e.g. &, <, ').
When SyncingBoard extracted node titles to build deterministic pairing hashes inside Miro metadata tags, raw HTML entities caused hash mismatches during subsequent re-sync triggers. SyncingBoard implemented decodeHtmlEntities() in pairingId.ts to sanitize titles before generating pairing hashes or writing native Miro item metadata.
6. Foreign SVG Bounding-Box Geometry Preservation
Swapping raw Penpot SVG vector payloads into whiteboards like Miro or FigJam alters image widget aspect ratio bounding boxes compared to PNG bitmapped screenshots.
SyncingBoard built a geometry preservation engine in MiroAdapter.ts (snapshot+restore retry loop):
- Snapshot: Captures original Miro widget canvas coordinates
(x, y, width, height, scale)prior to payload swaps. - Swap: Replaces the widget’s
urlproperty with the updated Penpot SVG. - Restore & Rescale: Recalculates aspect ratio scale offsets and restores the widget’s exact bounding box dimensions.
3. Architectural Capabilities & Unconventional Workflows
+------------------------------------+ +------------------------------------+
| Workstation Desktop (Studio) | | Presentation Laptop (Meeting) |
| - Penpot Editor Tab | | - Miro Board Canvas |
| - SyncingBoard Companion Script | | - SyncingBoard Miro Plugin |
+------------------------------------+ +------------------------------------+
\ /
\ /
v v
+---------------------------------------------------+
| WebSocket Relay Server (Vercel + Ably + Redis) |
| - Real-time Sub-Second Event Relaying |
+---------------------------------------------------+Cross-Machine Sync: The “Distributed Render Node”
Because SyncingBoard decouples the design editor from the target whiteboard using WebSocket relay channels in relayAbly.ts, the two browser tabs do not need to run on the same computer. You can leave Penpot open on a heavy desktop PC in your studio acting as a dedicated “Render Server”, while updating or preparing your Miro board from a lightweight laptop prior to a stakeholder review across the globe!
Multi-User Collaboration & Render Isolation
While Penpot natively supports real-time multi-user editing within the same file, cross-canvas sync integrations introduce specific concurrency considerations when multiple designers collaborate simultaneously. Programmatic page switches during multi-page batch syncs can disrupt another designer’s active editing view if they share a browser session. SyncingBoard solves concurrent sync conflicts by isolating WebSocket relay sessions per user in relayAbly.ts and pairingId.ts, ensuring each designer’s companion extension operates on an independent channel token.
Zero-Retention Peer Relay
Design screens stream in-memory through WebSocket relay channels with short 5-minute Upstash Redis session keys in relayRedis.ts. Once the sync finishes, zero image bytes or user design files remain on any server.
Live Whiteboard Broadcasting
Opt-in live mode turns any Miro board into a real-time monitor that automatically reflects canvas updates as designers select and edit frames inside Penpot.
4. Summary Wishlist for the Penpot Core Dev Team
Based on these findings, here are 4 specific API recommendations validated against Penpot’s official technical architecture documentation (exporter/src/app):
1. Expose Penpot’s Internal Puppeteer Exporter (exporter/src/app) to the Plugin API
Penpot already operates an internal Node.js + Puppeteer headless exporter microservice (exporter/src/app) for manual file downloads. Exposing an async plugin RPC endpoint (e.g., penpot.exportFrameAsync(frameId: string)) that bridges to Penpot’s existing Puppeteer Exporter service would enable third-party plugins to execute background batch syncs for off-page shape IDs without triggering visual page switches or requiring new export infrastructure.
2. Native Shape-Level Deep-Linking URLs
Exposing shape/frame parameters in Penpot workspace URLs (e.g., /#/workspace/:fileId/:pageId?shape=:shapeId) would allow third-party tools, documentation platforms, and team chats to deep-link and import specific Penpot frames directly by URL link.
3. Promise-Based Page Mount Lifecycle Hooks
Providing explicit promise-based lifecycle hooks (e.g., penpot.onPageRendered()) would replace arbitrary DOM mutation polling when navigating pages programmatically during batch exports.
4. Native Sanitized Title Strings in Plugin RPC
Exposing raw un-encoded node names in the plugin API saves third-party integrations from manually decoding ClojureScript HTML entities in frame titles.
Want to inspect the code or collaborate on Penpot integrations? Explore the open-source repository at github.com/luismichio/syncingboard or visit syncingboard.com .