Universal iframe overlay widget: one script on the host (embed.js), full support-style UI in a hosted app, wired with postMessage.
For a community hub / forum layer with host auth, threads, and activity feed, see the separate project: projectmate-hub. This repo stays focused on the lightweight embed overlay for existing host integrations.
Use this section when you want another codebase (or an AI assistant there) to add ProjectMate without cloning this repo.
Two HTTPS URLs (same or different domains is fine):
embed.js— the bootstrap script (small, vanilla JS). Production:https://projectmate.uft1.com/embed.js- Overlay app — the static UI loaded inside the iframe. Production:
https://projectmate.uft1.com/overlay/(must resolve to the built app’sindex.htmland its assets)
The embed validates postMessage using new URL(appUrl).origin, so appUrl must be the canonical base URL of that overlay deployment (usually the directory that contains index.html).
Add once per page (typically before </body>). Call ProjectMate.init(...) only in the browser (not during SSR without a guard).
<script src="https://projectmate.uft1.com/embed.js"></script>
<script>
ProjectMate.init({
projectId: "YOUR_STABLE_ID",
appUrl: "https://projectmate.uft1.com/overlay/",
host: {
id: "acme-prod",
name: "Acme",
version: "2.4.1",
environment: "production",
plan: "enterprise",
region: "ap-southeast-1",
locale: "en-PH",
timezone: "Asia/Manila",
supportEmail: "support@acme.com",
modules: {
feedback: true,
updates: true,
issues: true,
chat: false,
},
permissions: {
admin: ["view", "manage", "publish"],
viewer: ["view"],
},
},
multiHost: {
enabled: true,
activeHostId: "acme-prod",
totalHosts: 6,
canSwitchHosts: true,
benchmarkLabel: "Top 25% response time this week",
},
about: {
title: "Your product name",
description: "One line about what this site/tool does.",
},
features: {
chat: false,
feedback: true,
updates: true,
issues: true,
about: true,
},
theme: "auto",
accentColor: "#4f46e5",
links: {
docs: "https://example.com/docs",
},
changelog: [
{
version: "Host v2.4.1",
date: "2026-05-09",
bullets: ["Current host-site version deployed", "ProjectMate multi-host metadata enabled"],
},
{
version: "1.0.0",
date: "2026-05-08",
bullets: ["First public release"],
},
],
quotes: [
"Great hosting is thoughtful consistency.",
"Ship, learn, improve.",
"Clarity beats cleverness.",
],
// Cloudflare Worker endpoint for issue submission/listing/moderation.
issuesEndpoint: "https://projectmate-issues-api.example.workers.dev",
issueWorkflow: {
requireImageApproval: true,
},
launcher: {
position: "bottom-right",
offsetX: 16,
offsetY: 16,
label: "Help",
},
autoOpen: {
hash: "help",
query: { name: "help", value: "1" },
path: "/support",
pathMatch: "prefix",
},
});
</script>Remove or adjust optional blocks you do not use (changelog, issuesEndpoint, feedbackEndpoint, autoOpen, etc.). projectId and appUrl are required.
| Field | Type | Purpose |
|---|---|---|
projectId |
string | Stable id for your product (shown in payloads / future analytics). |
appUrl |
string (absolute https://… URL) |
Iframe src base for the hosted overlay app. |
| Field | Purpose |
|---|---|
about |
{ title, description } for the About tab. |
features |
Booleans: about, feedback, updates, issues, chat — toggles nav sections. |
theme |
"light" | "dark" | "auto" (follows prefers-color-scheme when auto). |
accentColor |
CSS color for launcher / accents, e.g. "#6366f1". |
links |
Object of label → absolute URL (e.g. docs, GitHub). |
customSections |
Array of { title, content } — content is markdown, sanitized in the overlay. |
host |
Host metadata shown in About: { id, name, version, environment, plan, region, locale, timezone, supportEmail, modules, permissions }. |
multiHost |
Aggregate context for multi-tenant pages: { enabled, activeHostId, totalHosts, canSwitchHosts, benchmarkLabel }. |
changelog |
Static releases: [{ version, date?, bullets: string[] }]. |
quotes |
Array of strings; overlay picks one random quote for About on load. |
issuesEndpoint |
Absolute URL for issues API (POST /issues, GET /issues, moderation routes). |
issueWorkflow |
{ requireImageApproval?: boolean } for moderated image visibility defaults. |
feedbackEndpoint |
Backward-compatible fallback endpoint used if issuesEndpoint is not provided. |
web3forms |
Client-side feedback via Web3Forms: { accessKey, subject?, fromName? }. The overlay POSTs to https://api.web3forms.com/submit from the iframe (free plan). Takes precedence over feedbackEndpoint when both are set. |
launcher |
{ hidden?, position, offsetX, offsetY, label? } — set hidden: true to skip the floating button entirely (use autoOpen and/or ProjectMate.open() instead). |
autoOpen |
Open the overlay when the current page URL matches any rule (rules are OR’d). See below. |
If you see Issue submission is not configured yet for this workspace., feedback is not wired up. Use one of:
- Web3Forms (no backend) —
web3forms.accessKey+features.feedback: true(noissuesEndpoint/feedbackEndpoint). - BYO API —
issuesEndpointorfeedbackEndpoint+features.feedback: true(and/orfeatures.issues: true).
Submissions run from the hosted overlay origin (e.g. https://projectmate.uft1.com), not from your site’s server. Web3Forms blocks server/proxy IPs on the free plan.
ProjectMate.init({
projectId: "sfl-crab",
appUrl: "https://projectmate.uft1.com/overlay/",
features: { feedback: true, issues: false, updates: true, about: true, chat: false },
web3forms: {
accessKey: "<WEB3FORMS_ACCESS_KEY>",
subject: "SFL Crab feedback",
fromName: "d1g.uk",
},
});- CORS: Web3Forms allows browser
fetchfrom any origin by default; useContent-Type: application/json. If you enable domain lock on the key (Pro), allow your embed origins (e.g.d1g.uk,beta.d1g.uk) andprojectmate.uft1.com(iframe submit origin). - CSP: On the overlay host, add
https://api.web3forms.comtoconnect-src(see CSP section below). - Screenshots: Not sent via Web3Forms JSON on the free tier (endpoint mode still supports them via
POST /issues).
When both web3forms and feedbackEndpoint are set, the overlay uses Web3Forms for Feedback submit.
- Keep required fields:
projectIdandappUrl - Enable at least one entry surface:
features.feedback: trueorfeatures.issues: true - Set one API base URL:
issuesEndpoint(preferred) orfeedbackEndpoint - Your API must accept
POST /issues(full issues UI also usesGET /issues)
ProjectMate.init({
projectId: "my-workspace",
appUrl: "https://projectmate.uft1.com/overlay/",
features: { feedback: true, issues: false },
issuesEndpoint: "https://your-api.example.com",
});Use a Cloudflare Worker with D1 + R2 to support moderated issue reporting with screenshots.
ProjectMate.init({
projectId: "my-tool",
appUrl: "https://projectmate.uft1.com/overlay/",
features: { feedback: true, issues: true },
issuesEndpoint: "https://projectmate-issues-api.example.workers.dev",
issueWorkflow: {
requireImageApproval: true,
},
});The overlay sends POST /issues for new reports and reads public lists from GET /issues?projectId=:id&view=open|resolved. Screenshot issues can stay pending until an admin approves them through moderation routes.
Optional. If any configured rule matches, the overlay opens automatically (also on hashchange / popstate).
hash: e.g."help"matches#help(leading#in the string is optional).query:{ name: "help", value: "1" }matches?help=1. Omitvalueto match whenever the param is present and non-empty.path: e.g."/support"— must start with/. DefaultpathMatch:"prefix"matches/supportand/support/…. Use"exact"for one pathname only.path: "/"+"prefix"is invalid (would match every page).
The default floating launcher is great for an always-visible "Help" affordance, but it can feel mismatched when the overlay is fullscreen. Hide it and open the overlay from a URL fragment or your own button:
<a href="#help">Open help</a>
<button onclick="ProjectMate.open()">Open help</button>
<script>
ProjectMate.init({
projectId: "my-tool",
appUrl: "https://projectmate.uft1.com/overlay/",
launcher: { hidden: true },
autoOpen: { hash: "help" },
});
</script>Programmatic API on the global, available right after <script src=".../embed.js"> loads (calls before bootstrap finishes are queued):
ProjectMate.open()— open the overlayProjectMate.close()— close the overlayProjectMate.toggle()— toggle stateProjectMate.isOpen()— current open state (false before bootstrap)ProjectMate.setSession(session | null)— push host auth into the iframe (PM_HOST_SESSION); passnullto clear. Safe beforeinitfinishes (queued). Session shape matchesprojectmate-hubshared-types (user,capabilities, optionalissuedAt).
- Single init: a second
ProjectMate.initis ignored (warning in the console). - Deferred bootstrap: the script waits for
DOMContentLoadedand prefersrequestIdleCallbackwhen available so it does not block first paint aggressively. - Isolation: launcher + overlay chrome use shadow DOM; the real UI runs in an iframe (
sandboxincludes scripts, same-origin, forms, popups as needed for the overlay app). - While open: background
document.bodysiblings getinert, scroll is locked, Escape closes, focus returns to the launcher when closed. - Back button at the launcher's corner: when the overlay opens, the floating launcher transforms into a back arrow (
←) at the same corner it was originally placed, so users can dismiss without travelling to the in-iframe Back button. Hidden whenlauncher.hidden: true. - Do not pass secrets in
init— the object is sent to the iframe viapostMessage(serialized JSON).
If you use CSP, you typically need at least:
script-src— include the origin that servesembed.js.frame-src(or a compatible directive in your policy) — include the overlay origin fromappUrl, or the browser may block the iframe.
The overlay app has its own CSP when you host it; tune connect-src there for issuesEndpoint (or feedbackEndpoint fallback), https://api.web3forms.com when using web3forms, and any other APIs the iframe calls.
Authoritative TypeScript + Zod definitions live in this repo at packages/shared-types/src/init-config.ts (field names, URLs, defaults).
| Path | Role |
|---|---|
apps/embed-sdk |
Vanilla embed.js — ProjectMate.init({...}), launcher, fullscreen iframe |
apps/overlay-app |
Svelte 5 + Vite + Tailwind — About, Report Issue, Updates, Issues/Resolved |
packages/shared-types |
Zod schemas + protocol types for host ↔ iframe |
apps/api |
Cloudflare Worker API for issues + moderation (D1 + R2) |
The site is built with pnpm run build:site, which writes site/out/:
index.html— marketing homepage (what / demo / basic use)demo.html— sample host page with paths rewritten for production (./embed.js,./overlay/…)embed.jsandoverlay/(full Vite output of the iframe app)
Deploy with npx wrangler pages deploy site/out --project-name projectmate, then attach the
custom domain (projectmate.uft1.com) in the Cloudflare dashboard under Workers & Pages →
projectmate → Custom domains. Node: 20.x.
If you only need the overlay bundle elsewhere (no landing page), you can still run pnpm run build:overlay and upload apps/overlay-app/dist only.
pnpm install
pnpm build
pnpm demoThis runs a static Vite server (see vite.demo.config.ts) and opens the homepage (index.html). From there, use Try the live demo → demo.html. The demo expects built artifacts at:
./apps/embed-sdk/dist/embed.js./apps/overlay-app/dist/index.html(and hashed assets beside it)
Use HTTP (the demo server), not file://, so the iframe and postMessage origins behave like production.
The legacy URL test.html redirects to demo.html.
- Overlay app only:
pnpm dev:overlay(Vite on port 5173). For cross-origin embed testing, pointappUrlin init athttp://localhost:5173/and allow that origin in your mental model forpostMessage(the embed validates the iframe origin againstnew URL(appUrl).origin).
- Shadow DOM: the launcher and overlay shell live in a shadow tree under a single
bodychild, which limits accidental style leakage from the host page. If a host cannot use shadow DOM, treat that as an integration edge case (e.g. open the overlay app in a separate window instead of embedding). - While the overlay is open, other direct children of
document.bodyare markedinertso background content is skipped for focus and assistive tech, in addition to scroll locking. The ProjectMate host node is excluded. PM_READY: the iframe posts this after validating config; the embed accepts it for a forward-compatible handshake.
For the static Vite build, start from something like: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://your-api.example https://api.web3forms.com and add any domains used by issuesEndpoint (or feedbackEndpoint fallback). Tighten frame-ancestors to known embedding origins when you can, instead of *.
Private / TBD.