feat: fix rApp canvas bugs + add widget data card mode

Fix zoom over iframes (transparent overlay captures wheel events),
eliminate black boxes (flex layout replaces calc height), and enable
resize handles (overlay lets pointerdown bubble to shape). Add widget
mode that fetches compact data from each module's API with 60s
auto-refresh, plus mode toggle button in header.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-03 16:26:33 -08:00
parent b6eeb41daf
commit 763567baea
2 changed files with 438 additions and 25 deletions

View File

@ -42,14 +42,23 @@ const MODULE_META: Record<string, { badge: string; color: string; name: string;
const styles = css`
:host {
display: flex;
flex-direction: column;
background: #1e293b;
border-radius: 10px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
min-width: 320px;
min-height: 240px;
min-width: 200px;
min-height: 120px;
overflow: hidden;
}
.rapp-wrapper {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
}
.rapp-header {
display: flex;
align-items: center;
@ -62,6 +71,7 @@ const styles = css`
cursor: move;
user-select: none;
border-radius: 10px 10px 0 0;
flex-shrink: 0;
}
.rapp-header-left {
@ -117,10 +127,23 @@ const styles = css`
}
.rapp-content {
width: 100%;
height: calc(100% - 34px);
flex: 1;
position: relative;
background: #0f172a;
overflow: hidden;
}
/* Transparent overlay intercepts wheel/pointer events so canvas zoom + selection work */
.rapp-overlay {
position: absolute;
inset: 0;
z-index: 10;
background: transparent;
cursor: move;
}
/* In editing mode, hide overlay so iframe becomes interactive */
:host(:state(editing)) .rapp-overlay {
display: none;
}
.rapp-iframe {
@ -289,6 +312,98 @@ const styles = css`
.rapp-status.connected {
background: #22c55e;
}
/* Widget mode styles */
.rapp-widget {
display: flex;
flex-direction: column;
height: 100%;
padding: 10px 12px;
color: #e2e8f0;
font-size: 13px;
cursor: pointer;
overflow: hidden;
}
.rapp-widget:hover {
background: rgba(255, 255, 255, 0.03);
}
.rapp-widget-stat {
font-size: 20px;
font-weight: 700;
color: #f8fafc;
margin-bottom: 6px;
}
.rapp-widget-divider {
border: none;
border-top: 1px solid rgba(255, 255, 255, 0.08);
margin: 6px 0;
}
.rapp-widget-list {
display: flex;
flex-direction: column;
gap: 3px;
flex: 1;
overflow: hidden;
}
.rapp-widget-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #cbd5e1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rapp-widget-row .bullet {
color: #64748b;
flex-shrink: 0;
}
.rapp-widget-row .label {
overflow: hidden;
text-overflow: ellipsis;
}
.rapp-widget-row .value {
margin-left: auto;
color: #94a3b8;
flex-shrink: 0;
}
.rapp-widget-empty {
color: #64748b;
font-size: 12px;
font-style: italic;
}
.rapp-widget-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
flex: 1;
overflow: hidden;
}
.rapp-widget-thumb {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: 4px;
background: #334155;
}
/* Mode toggle button active state */
.rapp-actions button.active {
opacity: 1;
background: rgba(0, 0, 0, 0.15);
}
`;
declare global {
@ -297,6 +412,120 @@ declare global {
}
}
// API endpoint config per module for widget mode
const WIDGET_API: Record<string, { path: string; transform: (data: any) => WidgetData }> = {
rinbox: {
path: "/api/mailboxes",
transform: (data) => {
const mailboxes = data?.mailboxes || [];
const totalThreads = mailboxes.reduce((sum: number, m: any) => sum + (m.threadCount || 0), 0);
return {
stat: `${totalThreads} thread${totalThreads !== 1 ? "s" : ""}`,
rows: mailboxes.slice(0, 3).map((m: any) => ({
label: m.name || m.slug,
value: `${m.threadCount || 0}`,
})),
};
},
},
rphotos: {
path: "/api/assets",
transform: (data) => {
const assets = data?.assets || [];
return {
stat: `${assets.length} photo${assets.length !== 1 ? "s" : ""}`,
thumbs: assets.slice(0, 4).map((a: any) => a.id),
};
},
},
rcal: {
path: "/api/events?upcoming=true&limit=3",
transform: (data) => {
const events = data?.results || [];
return {
stat: `${data?.count ?? events.length} event${(data?.count ?? events.length) !== 1 ? "s" : ""}`,
rows: events.slice(0, 3).map((ev: any) => ({
label: ev.title || ev.summary || "Untitled",
value: ev.start ? new Date(ev.start).toLocaleDateString(undefined, { month: "short", day: "numeric" }) : "",
})),
};
},
},
rwork: {
path: "/api/spaces",
transform: (data) => {
const spaces = Array.isArray(data) ? data : [];
const totalTasks = spaces.reduce((sum: number, s: any) => sum + (s.taskCount || 0), 0);
return {
stat: `${totalTasks} task${totalTasks !== 1 ? "s" : ""}`,
rows: spaces.slice(0, 3).map((s: any) => ({
label: s.name || s.slug,
value: `${s.taskCount || 0}`,
})),
};
},
},
rnotes: {
path: "/api/notebooks",
transform: (data) => {
const notebooks = data?.notebooks || [];
return {
stat: `${notebooks.length} notebook${notebooks.length !== 1 ? "s" : ""}`,
rows: notebooks.slice(0, 3).map((n: any) => ({
label: n.title || n.name || "Untitled",
})),
};
},
},
rfunds: {
path: "/api/flows",
transform: (data) => {
const flows = Array.isArray(data) ? data : [];
const active = flows.filter((f: any) => f.status === "active").length;
return {
stat: `${active} active flow${active !== 1 ? "s" : ""}`,
rows: flows.slice(0, 3).map((f: any) => ({
label: f.name || f.id,
value: f.status || "",
})),
};
},
},
rschedule: {
path: "/api/jobs",
transform: (data) => {
const jobs = data?.results || [];
return {
stat: `${data?.count ?? jobs.length} job${(data?.count ?? jobs.length) !== 1 ? "s" : ""}`,
rows: jobs.slice(0, 3).map((j: any) => ({
label: j.name || j.id,
value: j.enabled === false ? "paused" : "active",
})),
};
},
},
rnetwork: {
path: "/api/graph",
transform: (data) => {
const nodes = data?.nodes?.length ?? 0;
const edges = data?.edges?.length ?? 0;
return {
stat: `${nodes} node${nodes !== 1 ? "s" : ""}`,
rows: [
{ label: "Nodes", value: `${nodes}` },
{ label: "Edges", value: `${edges}` },
],
};
},
},
};
interface WidgetData {
stat: string;
rows?: { label: string; value?: string }[];
thumbs?: string[]; // rphotos asset IDs for thumbnail grid
}
export class FolkRApp extends FolkShape {
static override tagName = "folk-rapp";
@ -314,10 +543,13 @@ export class FolkRApp extends FolkShape {
#moduleId: string = "";
#spaceSlug: string = "";
#mode: "widget" | "iframe" = "widget";
#iframe: HTMLIFrameElement | null = null;
#contentEl: HTMLElement | null = null;
#messageHandler: ((e: MessageEvent) => void) | null = null;
#statusEl: HTMLElement | null = null;
#refreshTimer: ReturnType<typeof setInterval> | null = null;
#modeToggleBtn: HTMLButtonElement | null = null;
get moduleId() { return this.#moduleId; }
set moduleId(value: string) {
@ -325,7 +557,7 @@ export class FolkRApp extends FolkShape {
this.#moduleId = value;
this.requestUpdate("moduleId");
this.dispatchEvent(new CustomEvent("content-change"));
this.#loadModule();
this.#renderContent();
}
get spaceSlug() { return this.#spaceSlug; }
@ -334,7 +566,16 @@ export class FolkRApp extends FolkShape {
this.#spaceSlug = value;
this.requestUpdate("spaceSlug");
this.dispatchEvent(new CustomEvent("content-change"));
this.#loadModule();
this.#renderContent();
}
get mode() { return this.#mode; }
set mode(value: "widget" | "iframe") {
if (this.#mode === value) return;
this.#mode = value;
this.dispatchEvent(new CustomEvent("content-change"));
this.#updateModeToggle();
this.#renderContent();
}
override createRenderRoot() {
@ -343,6 +584,8 @@ export class FolkRApp extends FolkShape {
// Prefer JS-set properties (from newShape props); fall back to HTML attributes
if (!this.#moduleId) this.#moduleId = this.getAttribute("module-id") || "";
if (!this.#spaceSlug) this.#spaceSlug = this.getAttribute("space-slug") || "";
const attrMode = this.getAttribute("mode");
if (attrMode === "iframe" || attrMode === "widget") this.#mode = attrMode;
// Auto-derive spaceSlug from current URL if not explicitly provided (/{space}/canvas → space)
if (!this.#spaceSlug) {
@ -357,6 +600,7 @@ export class FolkRApp extends FolkShape {
const headerIcon = meta?.icon || "📱";
const wrapper = document.createElement("div");
wrapper.className = "rapp-wrapper";
wrapper.innerHTML = html`
<div class="header rapp-header" data-drag style="--rapp-color: ${headerColor}">
<div class="rapp-header-left">
@ -367,6 +611,7 @@ export class FolkRApp extends FolkShape {
<div class="rapp-switcher"></div>
</div>
<div class="rapp-actions">
<button class="mode-toggle-btn" title="Toggle widget/iframe mode">${this.#mode === "widget" ? "□" : "▪"}</button>
<button class="switch-btn" title="Switch module"></button>
<button class="open-tab-btn" title="Open in tab"></button>
<button class="close-btn" title="Close">×</button>
@ -383,11 +628,18 @@ export class FolkRApp extends FolkShape {
this.#contentEl = wrapper.querySelector(".rapp-content") as HTMLElement;
this.#statusEl = wrapper.querySelector(".rapp-status") as HTMLElement;
this.#modeToggleBtn = wrapper.querySelector(".mode-toggle-btn") as HTMLButtonElement;
const switchBtn = wrapper.querySelector(".switch-btn") as HTMLButtonElement;
const openTabBtn = wrapper.querySelector(".open-tab-btn") as HTMLButtonElement;
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
const switcherEl = wrapper.querySelector(".rapp-switcher") as HTMLElement;
// Mode toggle: widget ↔ iframe
this.#modeToggleBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.mode = this.#mode === "widget" ? "iframe" : "widget";
});
// Module switcher dropdown
this.#buildSwitcher(switcherEl);
switchBtn.addEventListener("click", (e) => {
@ -419,7 +671,7 @@ export class FolkRApp extends FolkShape {
// Load content
if (this.#moduleId) {
this.#loadModule();
this.#renderContent();
} else {
this.#showPicker();
}
@ -433,6 +685,10 @@ export class FolkRApp extends FolkShape {
window.removeEventListener("message", this.#messageHandler);
this.#messageHandler = null;
}
if (this.#refreshTimer) {
clearInterval(this.#refreshTimer);
this.#refreshTimer = null;
}
}
#buildSwitcher(switcherEl: HTMLElement) {
@ -507,10 +763,46 @@ export class FolkRApp extends FolkShape {
}
}
#loadModule() {
#updateModeToggle() {
if (this.#modeToggleBtn) {
this.#modeToggleBtn.textContent = this.#mode === "widget" ? "□" : "▪";
this.#modeToggleBtn.title = this.#mode === "widget" ? "Expand to iframe" : "Collapse to widget";
}
}
/** Derive the base module URL path, accounting for subdomain routing */
#getModulePath(): string {
if (!this.#spaceSlug) {
const pathParts = window.location.pathname.split("/").filter(Boolean);
if (pathParts.length >= 1) this.#spaceSlug = pathParts[0];
}
const space = this.#spaceSlug || "demo";
const hostname = window.location.hostname;
const onSubdomain = hostname.split(".").length >= 3 && hostname.startsWith(space + ".");
return onSubdomain ? `/${this.#moduleId}` : `/${space}/${this.#moduleId}`;
}
/** Route to the right render method based on current mode */
#renderContent() {
if (!this.#contentEl || !this.#moduleId) return;
// Clear refresh timer
if (this.#refreshTimer) {
clearInterval(this.#refreshTimer);
this.#refreshTimer = null;
}
// Update header
this.#updateHeader();
if (this.#mode === "widget") {
this.#loadWidget();
} else {
this.#loadIframe();
}
}
#updateHeader() {
const meta = MODULE_META[this.#moduleId];
const header = this.shadowRoot?.querySelector(".rapp-header") as HTMLElement;
if (header && meta) {
@ -522,6 +814,11 @@ export class FolkRApp extends FolkShape {
if (name) name.textContent = meta.name;
if (icon) icon.textContent = meta.icon;
}
}
/** Iframe mode: load the full rApp in an iframe with overlay for event capture */
#loadIframe() {
if (!this.#contentEl) return;
// Reset connection status
if (this.#statusEl) {
@ -529,26 +826,18 @@ export class FolkRApp extends FolkShape {
this.#statusEl.title = "Loading...";
}
const meta = MODULE_META[this.#moduleId];
// Show loading state
this.#contentEl.innerHTML = `
<div class="rapp-overlay"></div>
<div class="rapp-loading">
<div class="spinner"></div>
<span>Loading ${meta?.name || this.#moduleId}...</span>
</div>
`;
// Auto-derive space from URL if still missing
if (!this.#spaceSlug) {
const pathParts = window.location.pathname.split("/").filter(Boolean);
if (pathParts.length >= 1) this.#spaceSlug = pathParts[0];
}
const space = this.#spaceSlug || "demo";
// On subdomain URLs (jeff.rspace.online), the server prepends /{space}/ automatically,
// so the iframe should load /{moduleId} directly. Using /{space}/{moduleId} would
// double-prefix to /{space}/{space}/{moduleId} → 404.
const hostname = window.location.hostname;
const onSubdomain = hostname.split(".").length >= 3 && hostname.startsWith(space + ".");
const iframeUrl = onSubdomain ? `/${this.#moduleId}` : `/${space}/${this.#moduleId}`;
const iframeUrl = this.#getModulePath();
const iframe = document.createElement("iframe");
iframe.className = "rapp-iframe";
@ -557,17 +846,15 @@ export class FolkRApp extends FolkShape {
iframe.allow = "clipboard-write";
iframe.addEventListener("load", () => {
// Remove loading indicator
const loading = this.#contentEl?.querySelector(".rapp-loading");
if (loading) loading.remove();
// Send context to the newly loaded iframe
this.#sendContext();
});
iframe.addEventListener("error", () => {
if (this.#contentEl) {
this.#contentEl.innerHTML = `
<div class="rapp-overlay"></div>
<div class="rapp-error">
<span>Failed to load ${meta?.name || this.#moduleId}</span>
<button class="rapp-picker-item" style="justify-content: center; color: #94a3b8;">
@ -575,7 +862,7 @@ export class FolkRApp extends FolkShape {
</button>
</div>
`;
this.#contentEl.querySelector("button")?.addEventListener("click", () => this.#loadModule());
this.#contentEl.querySelector("button")?.addEventListener("click", () => this.#loadIframe());
}
});
@ -583,6 +870,130 @@ export class FolkRApp extends FolkShape {
this.#iframe = iframe;
}
/** Widget mode: fetch data from module API and render a compact card */
#loadWidget() {
if (!this.#contentEl) return;
this.#iframe = null;
// Reset status — widget mode doesn't use postMessage connection
if (this.#statusEl) {
this.#statusEl.classList.remove("connected");
this.#statusEl.title = "Widget mode";
}
const meta = MODULE_META[this.#moduleId];
// Show loading
this.#contentEl.innerHTML = `
<div class="rapp-loading">
<div class="spinner"></div>
<span>Loading ${meta?.name || this.#moduleId}...</span>
</div>
`;
// Fetch and render
this.#fetchAndRenderWidget();
// Auto-refresh every 60 seconds
this.#refreshTimer = setInterval(() => this.#fetchAndRenderWidget(), 60_000);
}
async #fetchAndRenderWidget() {
if (!this.#contentEl || this.#mode !== "widget") return;
const apiConfig = WIDGET_API[this.#moduleId];
if (!apiConfig) {
this.#renderWidgetFallback();
return;
}
const basePath = this.#getModulePath();
const url = `${basePath}${apiConfig.path}`;
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`${res.status}`);
const data = await res.json();
const widgetData = apiConfig.transform(data);
this.#renderWidgetCard(widgetData);
} catch {
this.#renderWidgetFallback();
}
}
#renderWidgetCard(data: WidgetData) {
if (!this.#contentEl) return;
const meta = MODULE_META[this.#moduleId];
const basePath = this.#getModulePath();
let listHtml = "";
if (data.thumbs && data.thumbs.length > 0) {
// Photo grid mode
const thumbItems = data.thumbs
.map((id) => `<img class="rapp-widget-thumb" src="${basePath}/api/assets/${id}/thumbnail" alt="" loading="lazy" />`)
.join("");
listHtml = `<div class="rapp-widget-grid">${thumbItems}</div>`;
} else if (data.rows && data.rows.length > 0) {
const rowItems = data.rows
.map((r) => `
<div class="rapp-widget-row">
<span class="bullet"></span>
<span class="label">${this.#escapeHtml(r.label)}</span>
${r.value ? `<span class="value">${this.#escapeHtml(r.value)}</span>` : ""}
</div>
`)
.join("");
listHtml = `
<hr class="rapp-widget-divider" />
<div class="rapp-widget-list">${rowItems}</div>
`;
} else {
listHtml = `<span class="rapp-widget-empty">No data yet</span>`;
}
this.#contentEl.innerHTML = `
<div class="rapp-widget" title="Click to open ${meta?.name || this.#moduleId}">
<div class="rapp-widget-stat">${this.#escapeHtml(data.stat)}</div>
${listHtml}
</div>
`;
// Click widget → navigate to full module
this.#contentEl.querySelector(".rapp-widget")?.addEventListener("click", () => {
if (this.#moduleId && this.#spaceSlug) {
window.location.href = rspaceNavUrl(this.#spaceSlug, this.#moduleId);
}
});
}
/** Fallback widget when no API config exists or fetch fails */
#renderWidgetFallback() {
if (!this.#contentEl) return;
const meta = MODULE_META[this.#moduleId];
this.#contentEl.innerHTML = `
<div class="rapp-widget" title="Click to open ${meta?.name || this.#moduleId}">
<div class="rapp-widget-stat">${meta?.icon || "📱"} ${meta?.name || this.#moduleId}</div>
<span class="rapp-widget-empty">Could not load data</span>
</div>
`;
this.#contentEl.querySelector(".rapp-widget")?.addEventListener("click", () => {
if (this.#moduleId && this.#spaceSlug) {
window.location.href = rspaceNavUrl(this.#spaceSlug, this.#moduleId);
}
});
}
#escapeHtml(str: string): string {
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}
#showPicker() {
if (!this.#contentEl) return;
@ -619,6 +1030,7 @@ export class FolkRApp extends FolkShape {
type: "folk-rapp",
moduleId: this.#moduleId,
spaceSlug: this.#spaceSlug,
mode: this.#mode,
};
}
}

View File

@ -3084,6 +3084,7 @@
shape = document.createElement("folk-rapp");
if (data.moduleId) shape.moduleId = data.moduleId;
shape.spaceSlug = data.spaceSlug || communitySlug;
if (data.mode) shape.mode = data.mode;
break;
case "folk-feed":
shape = document.createElement("folk-feed");