feat: auth-fetch, shape registry, and data pipes (TASK-13, TASK-41, TASK-42)

TASK-13: rApp frontends now inject EncryptID bearer tokens via authFetch()
and gate mutations behind requireAuth() — rvote, rfiles, rmaps all protected.
Demo mode unaffected.

TASK-41: Dynamic shape registry replaces 300-line switch in canvas.html and
165-line if-chain in community-sync.ts. All 41 shape classes now co-locate
fromData()/applyData() with their existing toJSON(), making shape creation
and sync fully data-driven.

TASK-42: Data pipes between shapes via typed ports. Shapes declare
input/output PortDescriptors, arrows connect ports with type checking,
100ms debounce, and color tinting. AI shapes (prompt, image-gen, video-gen,
transcription) have initial port descriptors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-11 15:59:51 -07:00
parent 9bb00a8bab
commit c4717e3c68
51 changed files with 1166 additions and 469 deletions

View File

@ -35,6 +35,11 @@ export interface ShapeData {
tags?: string[];
// Whiteboard SVG drawing
svgMarkup?: string;
// Data pipe fields (arrow port connections)
sourcePort?: string;
targetPort?: string;
// Shape port values (for data piping)
ports?: Record<string, unknown>;
// Allow arbitrary shape-specific properties from toJSON()
[key: string]: unknown;
}
@ -850,172 +855,19 @@ export class CommunitySync extends EventTarget {
}
/**
* Update shape element without triggering change events
* Update shape element without triggering change events.
* Delegates to each shape's applyData() method (co-located with toJSON/fromData).
*/
#updateShapeElement(shape: FolkShape, data: ShapeData): void {
// Temporarily remove event listeners to avoid feedback loop
const isOurChange =
shape.x === data.x &&
shape.y === data.y &&
shape.width === data.width &&
shape.height === data.height &&
shape.rotation === data.rotation;
if (isOurChange && !("content" in data)) {
return; // No change needed
}
// Update position and size
if (shape.x !== data.x) shape.x = data.x;
if (shape.y !== data.y) shape.y = data.y;
if (shape.width !== data.width) shape.width = data.width;
if (shape.height !== data.height) shape.height = data.height;
if (shape.rotation !== data.rotation) shape.rotation = data.rotation;
// Update content for markdown shapes
if ("content" in data && "content" in shape) {
const shapeWithContent = shape as any;
if (shapeWithContent.content !== data.content) {
shapeWithContent.content = data.content;
}
}
// Update arrow-specific properties
if (data.type === "folk-arrow") {
const arrow = shape as any;
if (data.sourceId !== undefined && arrow.sourceId !== data.sourceId) {
arrow.sourceId = data.sourceId;
}
if (data.targetId !== undefined && arrow.targetId !== data.targetId) {
arrow.targetId = data.targetId;
}
if (data.color !== undefined && arrow.color !== data.color) {
arrow.color = data.color;
}
if (data.strokeWidth !== undefined && arrow.strokeWidth !== data.strokeWidth) {
arrow.strokeWidth = data.strokeWidth;
}
}
// Update wrapper-specific properties
if (data.type === "folk-wrapper") {
const wrapper = shape as any;
if (data.title !== undefined && wrapper.title !== data.title) {
wrapper.title = data.title;
}
if (data.icon !== undefined && wrapper.icon !== data.icon) {
wrapper.icon = data.icon;
}
if (data.primaryColor !== undefined && wrapper.primaryColor !== data.primaryColor) {
wrapper.primaryColor = data.primaryColor;
}
if (data.isMinimized !== undefined && wrapper.isMinimized !== data.isMinimized) {
wrapper.isMinimized = data.isMinimized;
}
if (data.isPinned !== undefined && wrapper.isPinned !== data.isPinned) {
wrapper.isPinned = data.isPinned;
}
if (data.tags !== undefined) {
wrapper.tags = data.tags;
}
}
// Update token mint properties
if (data.type === "folk-token-mint") {
const mint = shape as any;
if (data.tokenName !== undefined && mint.tokenName !== data.tokenName) mint.tokenName = data.tokenName;
if (data.tokenSymbol !== undefined && mint.tokenSymbol !== data.tokenSymbol) mint.tokenSymbol = data.tokenSymbol;
if (data.description !== undefined && mint.description !== data.description) mint.description = data.description;
if (data.totalSupply !== undefined && mint.totalSupply !== data.totalSupply) mint.totalSupply = data.totalSupply;
if (data.issuedSupply !== undefined && mint.issuedSupply !== data.issuedSupply) mint.issuedSupply = data.issuedSupply;
if (data.tokenColor !== undefined && mint.tokenColor !== data.tokenColor) mint.tokenColor = data.tokenColor;
if (data.tokenIcon !== undefined && mint.tokenIcon !== data.tokenIcon) mint.tokenIcon = data.tokenIcon;
}
// Update token ledger properties
if (data.type === "folk-token-ledger") {
const ledger = shape as any;
if (data.mintId !== undefined && ledger.mintId !== data.mintId) ledger.mintId = data.mintId;
if (data.entries !== undefined) ledger.entries = data.entries;
}
// Update choice-vote properties
if (data.type === "folk-choice-vote") {
const vote = shape as any;
if (data.title !== undefined && vote.title !== data.title) vote.title = data.title;
if (data.options !== undefined) vote.options = data.options;
if (data.mode !== undefined && vote.mode !== data.mode) vote.mode = data.mode;
if (data.budget !== undefined && vote.budget !== data.budget) vote.budget = data.budget;
if (data.votes !== undefined) vote.votes = data.votes;
}
// Update choice-rank properties
if (data.type === "folk-choice-rank") {
const rank = shape as any;
if (data.title !== undefined && rank.title !== data.title) rank.title = data.title;
if (data.options !== undefined) rank.options = data.options;
if (data.rankings !== undefined) rank.rankings = data.rankings;
}
// Update nested canvas properties
if (data.type === "folk-canvas") {
const canvas = shape as any;
if (data.sourceSlug !== undefined && canvas.sourceSlug !== data.sourceSlug) canvas.sourceSlug = data.sourceSlug;
if (data.sourceDID !== undefined && canvas.sourceDID !== data.sourceDID) canvas.sourceDID = data.sourceDID;
if (data.permissions !== undefined) canvas.permissions = data.permissions;
if (data.collapsed !== undefined && canvas.collapsed !== data.collapsed) canvas.collapsed = data.collapsed;
if (data.label !== undefined && canvas.label !== data.label) canvas.label = data.label;
}
// Update choice-spider properties
if (data.type === "folk-choice-spider") {
const spider = shape as any;
if (data.title !== undefined && spider.title !== data.title) spider.title = data.title;
if (data.options !== undefined) spider.options = data.options;
if (data.criteria !== undefined) spider.criteria = data.criteria;
if (data.scores !== undefined) spider.scores = data.scores;
}
// Update rApp embed properties
if (data.type === "folk-rapp") {
const rapp = shape as any;
if (data.moduleId !== undefined && rapp.moduleId !== data.moduleId) rapp.moduleId = data.moduleId;
if (data.spaceSlug !== undefined && rapp.spaceSlug !== data.spaceSlug) rapp.spaceSlug = data.spaceSlug;
}
// Update feed shape properties
if (data.type === "folk-feed") {
const feed = shape as any;
if (data.sourceLayer !== undefined && feed.sourceLayer !== data.sourceLayer) feed.sourceLayer = data.sourceLayer;
if (data.sourceModule !== undefined && feed.sourceModule !== data.sourceModule) feed.sourceModule = data.sourceModule;
if (data.feedId !== undefined && feed.feedId !== data.feedId) feed.feedId = data.feedId;
if (data.flowKind !== undefined && feed.flowKind !== data.flowKind) feed.flowKind = data.flowKind;
if (data.feedFilter !== undefined && feed.feedFilter !== data.feedFilter) feed.feedFilter = data.feedFilter;
if (data.maxItems !== undefined && feed.maxItems !== data.maxItems) feed.maxItems = data.maxItems;
if (data.refreshInterval !== undefined && feed.refreshInterval !== data.refreshInterval) feed.refreshInterval = data.refreshInterval;
}
// Update social-post properties
if (data.type === "folk-social-post") {
const post = shape as any;
if (data.platform !== undefined && post.platform !== data.platform) post.platform = data.platform;
if (data.postType !== undefined && post.postType !== data.postType) post.postType = data.postType;
if (data.mediaUrl !== undefined && post.mediaUrl !== data.mediaUrl) post.mediaUrl = data.mediaUrl;
if (data.mediaType !== undefined && post.mediaType !== data.mediaType) post.mediaType = data.mediaType;
if (data.scheduledAt !== undefined && post.scheduledAt !== data.scheduledAt) post.scheduledAt = data.scheduledAt;
if (data.status !== undefined && post.status !== data.status) post.status = data.status;
if (data.hashtags !== undefined) post.hashtags = data.hashtags;
if (data.stepNumber !== undefined && post.stepNumber !== data.stepNumber) post.stepNumber = data.stepNumber;
}
// Update workflow-block properties
if (data.type === "folk-workflow-block") {
const block = shape as any;
if (data.blockType !== undefined && block.blockType !== data.blockType) block.blockType = data.blockType;
if (data.label !== undefined && block.label !== data.label) block.label = data.label;
if (data.inputs !== undefined) block.inputs = data.inputs;
if (data.outputs !== undefined) block.outputs = data.outputs;
if (data.config !== undefined) block.config = data.config;
if (typeof (shape as any).applyData === "function") {
(shape as any).applyData(data);
} else {
// Fallback for shapes without applyData (e.g. wb-svg)
if (shape.x !== data.x) shape.x = data.x;
if (shape.y !== data.y) shape.y = data.y;
if (shape.width !== data.width) shape.width = data.width;
if (shape.height !== data.height) shape.height = data.height;
if (shape.rotation !== data.rotation) shape.rotation = data.rotation;
}
}

62
lib/data-types.ts Normal file
View File

@ -0,0 +1,62 @@
/**
* Data type system for shape-to-shape data piping.
*
* Shapes declare typed input/output ports. Arrows connect ports,
* flowing data from source outputs to target inputs with type checking.
*/
/** Supported data types for ports. */
export type DataType =
| "string"
| "number"
| "boolean"
| "image-url"
| "video-url"
| "text"
| "json"
| "trigger"
| "any";
/** Describes a single input or output port on a shape. */
export interface PortDescriptor {
name: string;
type: DataType;
direction: "input" | "output";
}
/** Type compatibility matrix: can `source` flow into `target`? */
export function isCompatible(source: DataType, target: DataType): boolean {
if (source === target) return true;
if (target === "any" || source === "any") return true;
// text and string are interchangeable
if ((source === "text" && target === "string") || (source === "string" && target === "text")) return true;
// URLs are strings
if ((source === "image-url" || source === "video-url") && (target === "string" || target === "text")) return true;
// strings can be image/video URLs (user knows best)
if ((source === "string" || source === "text") && (target === "image-url" || target === "video-url")) return true;
return false;
}
/** Color tint per data type for arrow visualization. */
export function dataTypeColor(type: DataType): string {
switch (type) {
case "text":
case "string":
return "#3b82f6"; // blue
case "number":
return "#f59e0b"; // amber
case "boolean":
return "#8b5cf6"; // purple
case "image-url":
return "#10b981"; // emerald
case "video-url":
return "#ef4444"; // red
case "json":
return "#6366f1"; // indigo
case "trigger":
return "#f97316"; // orange
case "any":
default:
return "#6b7280"; // gray
}
}

View File

@ -2,6 +2,7 @@ import { getBoxToBoxArrow } from "perfect-arrows";
import { getStroke, type StrokeOptions } from "perfect-freehand";
import { FolkElement } from "./folk-element";
import { css } from "./tags";
import { isCompatible, dataTypeColor } from "./data-types";
// Point interface for bezier curves
interface Point {
@ -148,6 +149,29 @@ export class FolkArrow extends FolkElement {
#strokeWidth: number = 3;
#arrowStyle: ArrowStyle = "smooth";
// Data piping
#sourcePort: string = "";
#targetPort: string = "";
#pipeDebounce: ReturnType<typeof setTimeout> | null = null;
#portListener: ((e: Event) => void) | null = null;
get sourcePort() { return this.#sourcePort; }
set sourcePort(value: string) {
this.#sourcePort = value;
this.#setupPipe();
}
get targetPort() { return this.#targetPort; }
set targetPort(value: string) {
this.#targetPort = value;
this.#setupPipe();
}
/** True when this arrow connects ports (is a data pipe). */
get isPipe(): boolean {
return !!(this.#sourcePort && this.#targetPort);
}
#options: StrokeOptions = {
size: 7,
thinning: 0.5,
@ -262,6 +286,7 @@ export class FolkArrow extends FolkElement {
super.disconnectedCallback();
this.#resizeObserver.disconnect();
this.#stopPositionTracking();
this.#teardownPipe();
}
#animationFrameId: number | null = null;
@ -282,6 +307,63 @@ export class FolkArrow extends FolkElement {
}
}
/** Set up data pipe listener when both ports are connected. */
#setupPipe(): void {
this.#teardownPipe();
if (!this.#sourcePort || !this.#targetPort) {
// Reset arrow color when pipe is disconnected
this.#updateArrow();
return;
}
const srcEl = this.#sourceElement as any;
const tgtEl = this.#targetElement as any;
if (!srcEl || !tgtEl) return;
// Check type compatibility
const srcPort = srcEl.getPort?.(this.#sourcePort);
const tgtPort = tgtEl.getPort?.(this.#targetPort);
if (srcPort && tgtPort && !isCompatible(srcPort.type, tgtPort.type)) {
console.warn(`[FolkArrow] Type mismatch: ${srcPort.type} -> ${tgtPort.type}`);
return;
}
// Tint arrow color by data type
if (srcPort) {
this.#color = dataTypeColor(srcPort.type);
this.#updateArrow();
}
// Listen for port value changes on the source
this.#portListener = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (detail.name !== this.#sourcePort) return;
// Debounce to avoid rapid-fire updates
if (this.#pipeDebounce) clearTimeout(this.#pipeDebounce);
this.#pipeDebounce = setTimeout(() => {
if (tgtEl.setPortValue) {
tgtEl.setPortValue(this.#targetPort, detail.value);
}
}, 100);
};
srcEl.addEventListener("port-value-changed", this.#portListener);
}
/** Remove pipe listener. */
#teardownPipe(): void {
if (this.#portListener && this.#sourceElement) {
this.#sourceElement.removeEventListener("port-value-changed", this.#portListener);
this.#portListener = null;
}
if (this.#pipeDebounce) {
clearTimeout(this.#pipeDebounce);
this.#pipeDebounce = null;
}
}
#observeSource() {
if (this.#sourceElement) {
this.#resizeObserver.unobserve(this.#sourceElement);
@ -293,6 +375,7 @@ export class FolkArrow extends FolkElement {
this.#resizeObserver.observe(this.#sourceElement);
}
}
if (this.isPipe) this.#setupPipe();
}
#observeTarget() {
@ -306,6 +389,7 @@ export class FolkArrow extends FolkElement {
this.#resizeObserver.observe(this.#targetElement);
}
}
if (this.isPipe) this.#setupPipe();
}
#updateRects() {
@ -431,8 +515,21 @@ export class FolkArrow extends FolkElement {
return root;
}
static fromData(data: Record<string, any>): FolkArrow {
const arrow = document.createElement("folk-arrow") as FolkArrow;
arrow.id = data.id;
if (data.sourceId) arrow.sourceId = data.sourceId;
if (data.targetId) arrow.targetId = data.targetId;
if (data.color) arrow.color = data.color;
if (data.strokeWidth) arrow.strokeWidth = data.strokeWidth;
if (data.arrowStyle) arrow.arrowStyle = data.arrowStyle;
if (data.sourcePort) arrow.sourcePort = data.sourcePort;
if (data.targetPort) arrow.targetPort = data.targetPort;
return arrow;
}
toJSON() {
return {
const json: Record<string, unknown> = {
type: "folk-arrow",
id: this.id,
sourceId: this.sourceId,
@ -441,5 +538,18 @@ export class FolkArrow extends FolkElement {
strokeWidth: this.#strokeWidth,
arrowStyle: this.#arrowStyle,
};
if (this.#sourcePort) json.sourcePort = this.#sourcePort;
if (this.#targetPort) json.targetPort = this.#targetPort;
return json;
}
applyData(data: Record<string, any>): void {
if (data.sourceId !== undefined && this.sourceId !== data.sourceId) this.sourceId = data.sourceId;
if (data.targetId !== undefined && this.targetId !== data.targetId) this.targetId = data.targetId;
if (data.color !== undefined && this.#color !== data.color) this.color = data.color;
if (data.strokeWidth !== undefined && this.#strokeWidth !== data.strokeWidth) this.strokeWidth = data.strokeWidth;
if (data.arrowStyle !== undefined && this.#arrowStyle !== data.arrowStyle) this.arrowStyle = data.arrowStyle as ArrowStyle;
if (data.sourcePort !== undefined && this.#sourcePort !== data.sourcePort) this.sourcePort = data.sourcePort;
if (data.targetPort !== undefined && this.#targetPort !== data.targetPort) this.targetPort = data.targetPort;
}
}

View File

@ -435,6 +435,11 @@ export class FolkBlender extends FolkShape {
return div.innerHTML;
}
static override fromData(data: Record<string, any>): FolkBlender {
const shape = FolkShape.fromData(data) as FolkBlender;
return shape;
}
override toJSON() {
return {
...super.toJSON(),
@ -444,4 +449,8 @@ export class FolkBlender extends FolkShape {
blendUrl: this.#blendUrl,
};
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
}
}

View File

@ -342,6 +342,33 @@ export class FolkBooking extends FolkShape {
this.#bodyEl.innerHTML = bodyHTML;
}
static override fromData(data: Record<string, any>): FolkBooking {
const shape = FolkShape.fromData(data) as FolkBooking;
if (data.bookingType !== undefined) shape.bookingType = data.bookingType;
if (data.provider !== undefined) shape.provider = data.provider;
if (data.confirmationNumber !== undefined) shape.confirmationNumber = data.confirmationNumber;
if (data.details !== undefined) shape.details = data.details;
if (data.cost !== undefined) shape.cost = data.cost;
if (data.currency !== undefined) shape.currency = data.currency;
if (data.startDate !== undefined) shape.startDate = data.startDate;
if (data.endDate !== undefined) shape.endDate = data.endDate;
if (data.bookingStatus !== undefined) shape.bookingStatus = data.bookingStatus;
return shape;
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if (data.bookingType !== undefined && data.bookingType !== this.bookingType) this.bookingType = data.bookingType;
if (data.provider !== undefined && data.provider !== this.provider) this.provider = data.provider;
if (data.confirmationNumber !== undefined && data.confirmationNumber !== this.confirmationNumber) this.confirmationNumber = data.confirmationNumber;
if (data.details !== undefined && data.details !== this.details) this.details = data.details;
if (data.cost !== undefined && data.cost !== this.cost) this.cost = data.cost;
if (data.currency !== undefined && data.currency !== this.currency) this.currency = data.currency;
if (data.startDate !== undefined && data.startDate !== this.startDate) this.startDate = data.startDate;
if (data.endDate !== undefined && data.endDate !== this.endDate) this.endDate = data.endDate;
if (data.bookingStatus !== undefined && data.bookingStatus !== this.bookingStatus) this.bookingStatus = data.bookingStatus;
}
override toJSON() {
return {
...super.toJSON(),

View File

@ -359,6 +359,21 @@ export class FolkBudget extends FolkShape {
}
}
static override fromData(data: Record<string, any>): FolkBudget {
const shape = FolkShape.fromData(data) as FolkBudget;
if (data.budgetTotal != null) shape.budgetTotal = data.budgetTotal;
if (data.currency !== undefined) shape.currency = data.currency;
if (Array.isArray(data.expenses)) shape.expenses = data.expenses;
return shape;
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if (data.budgetTotal != null && data.budgetTotal !== this.budgetTotal) this.budgetTotal = data.budgetTotal;
if (data.currency !== undefined && data.currency !== this.currency) this.currency = data.currency;
if (Array.isArray(data.expenses) && JSON.stringify(data.expenses) !== JSON.stringify(this.expenses)) this.expenses = data.expenses;
}
override toJSON() {
return {
...super.toJSON(),

View File

@ -436,6 +436,13 @@ export class FolkCalendar extends FolkShape {
return div.innerHTML;
}
static override fromData(data: Record<string, any>): FolkCalendar {
const shape = FolkShape.fromData(data) as FolkCalendar;
if (data.selectedDate) shape.selectedDate = new Date(data.selectedDate);
if (data.events) shape.events = data.events.map((e: any) => ({ ...e, date: new Date(e.date) }));
return shape;
}
override toJSON() {
return {
...super.toJSON(),
@ -447,4 +454,17 @@ export class FolkCalendar extends FolkShape {
})),
};
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if (data.selectedDate !== undefined) {
const newDate = data.selectedDate ? new Date(data.selectedDate) : null;
const curTime = this.selectedDate?.getTime() ?? null;
const newTime = newDate?.getTime() ?? null;
if (curTime !== newTime) this.selectedDate = newDate;
}
if (data.events !== undefined) {
this.events = data.events.map((e: any) => ({ ...e, date: new Date(e.date) }));
}
}
}

View File

@ -537,6 +537,25 @@ export class FolkCanvas extends FolkShape {
this.#disconnect();
}
static override fromData(data: Record<string, any>): FolkCanvas {
const shape = FolkShape.fromData(data) as FolkCanvas;
if (data.sourceSlug !== undefined) shape.sourceSlug = data.sourceSlug;
if (data.sourceDID !== undefined) shape.sourceDID = data.sourceDID;
if (data.permissions !== undefined) shape.permissions = data.permissions;
if (typeof data.collapsed === "boolean") shape.collapsed = data.collapsed;
if (data.label !== undefined) shape.label = data.label;
return shape;
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if (data.sourceSlug !== undefined && data.sourceSlug !== this.sourceSlug) this.sourceSlug = data.sourceSlug;
if (data.sourceDID !== undefined && data.sourceDID !== this.sourceDID) this.sourceDID = data.sourceDID;
if (data.permissions !== undefined && JSON.stringify(data.permissions) !== JSON.stringify(this.permissions)) this.permissions = data.permissions;
if (typeof data.collapsed === "boolean" && data.collapsed !== this.collapsed) this.collapsed = data.collapsed;
if (data.label !== undefined && data.label !== this.label) this.label = data.label;
}
override toJSON() {
return {
...super.toJSON(),

View File

@ -412,6 +412,12 @@ export class FolkChat extends FolkShape {
return div.innerHTML;
}
static override fromData(data: Record<string, any>): FolkChat {
const shape = FolkShape.fromData(data) as FolkChat;
if (data.roomId) shape.roomId = data.roomId;
return shape;
}
override toJSON() {
return {
...super.toJSON(),
@ -420,4 +426,11 @@ export class FolkChat extends FolkShape {
messages: this.messages,
};
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if ("roomId" in data && this.roomId !== data.roomId) {
this.roomId = data.roomId;
}
}
}

View File

@ -949,6 +949,14 @@ export class FolkChoiceConviction extends FolkShape {
});
}
static override fromData(data: Record<string, any>): FolkChoiceConviction {
const el = FolkShape.fromData(data) as FolkChoiceConviction;
if (data.title !== undefined) el.title = data.title;
if (data.options !== undefined) el.options = data.options;
if (data.stakes !== undefined) el.stakes = data.stakes;
return el;
}
override toJSON() {
return {
...super.toJSON(),
@ -958,4 +966,11 @@ export class FolkChoiceConviction extends FolkShape {
stakes: this.#stakes,
};
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if (data.title !== undefined && this.#title !== data.title) this.title = data.title;
if (data.options !== undefined && JSON.stringify(this.#options) !== JSON.stringify(data.options)) this.options = data.options;
if (data.stakes !== undefined && JSON.stringify(this.#stakes) !== JSON.stringify(data.stakes)) this.stakes = data.stakes;
}
}

View File

@ -1077,6 +1077,14 @@ export class FolkChoiceRank extends FolkShape {
});
}
static override fromData(data: Record<string, any>): FolkChoiceRank {
const el = FolkShape.fromData(data) as FolkChoiceRank;
if (data.title !== undefined) el.title = data.title;
if (data.options !== undefined) el.options = data.options;
if (data.rankings !== undefined) el.rankings = data.rankings;
return el;
}
override toJSON() {
return {
...super.toJSON(),
@ -1086,4 +1094,11 @@ export class FolkChoiceRank extends FolkShape {
rankings: this.#rankings,
};
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if (data.title !== undefined && this.#title !== data.title) this.title = data.title;
if (data.options !== undefined && JSON.stringify(this.#options) !== JSON.stringify(data.options)) this.options = data.options;
if (data.rankings !== undefined && JSON.stringify(this.#rankings) !== JSON.stringify(data.rankings)) this.rankings = data.rankings;
}
}

View File

@ -1089,6 +1089,15 @@ export class FolkChoiceSpider extends FolkShape {
});
}
static override fromData(data: Record<string, any>): FolkChoiceSpider {
const el = FolkShape.fromData(data) as FolkChoiceSpider;
if (data.title !== undefined) el.title = data.title;
if (data.options !== undefined) el.options = data.options;
if (data.criteria !== undefined) el.criteria = data.criteria;
if (data.scores !== undefined) el.scores = data.scores;
return el;
}
override toJSON() {
return {
...super.toJSON(),
@ -1099,4 +1108,12 @@ export class FolkChoiceSpider extends FolkShape {
scores: this.#scores,
};
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if (data.title !== undefined && this.#title !== data.title) this.title = data.title;
if (data.options !== undefined && JSON.stringify(this.#options) !== JSON.stringify(data.options)) this.options = data.options;
if (data.criteria !== undefined && JSON.stringify(this.#criteria) !== JSON.stringify(data.criteria)) this.criteria = data.criteria;
if (data.scores !== undefined && JSON.stringify(this.#scores) !== JSON.stringify(data.scores)) this.scores = data.scores;
}
}

View File

@ -930,6 +930,16 @@ export class FolkChoiceVote extends FolkShape {
});
}
static override fromData(data: Record<string, any>): FolkChoiceVote {
const el = FolkShape.fromData(data) as FolkChoiceVote;
if (data.title !== undefined) el.title = data.title;
if (data.options !== undefined) el.options = data.options;
if (data.mode !== undefined) el.mode = data.mode;
if (data.budget != null) el.budget = data.budget;
if (data.votes !== undefined) el.votes = data.votes;
return el;
}
override toJSON() {
return {
...super.toJSON(),
@ -941,4 +951,13 @@ export class FolkChoiceVote extends FolkShape {
votes: this.#votes,
};
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if (data.title !== undefined && this.#title !== data.title) this.title = data.title;
if (data.options !== undefined && JSON.stringify(this.#options) !== JSON.stringify(data.options)) this.options = data.options;
if (data.mode !== undefined && this.#mode !== data.mode) this.mode = data.mode;
if (data.budget != null && this.#budget !== data.budget) this.budget = data.budget;
if (data.votes !== undefined && JSON.stringify(this.#votes) !== JSON.stringify(data.votes)) this.votes = data.votes;
}
}

View File

@ -265,6 +265,29 @@ export class FolkDestination extends FolkShape {
this.#datesEl.innerHTML = result;
}
static override fromData(data: Record<string, any>): FolkDestination {
const shape = FolkShape.fromData(data) as FolkDestination;
if (data.destName !== undefined) shape.destName = data.destName;
if (data.country !== undefined) shape.country = data.country;
if (data.lat != null) shape.lat = data.lat;
if (data.lng != null) shape.lng = data.lng;
if (data.arrivalDate !== undefined) shape.arrivalDate = data.arrivalDate;
if (data.departureDate !== undefined) shape.departureDate = data.departureDate;
if (data.notes !== undefined) shape.notes = data.notes;
return shape;
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if (data.destName !== undefined && data.destName !== this.destName) this.destName = data.destName;
if (data.country !== undefined && data.country !== this.country) this.country = data.country;
if (data.lat != null && data.lat !== this.lat) this.lat = data.lat;
if (data.lng != null && data.lng !== this.lng) this.lng = data.lng;
if (data.arrivalDate !== undefined && data.arrivalDate !== this.arrivalDate) this.arrivalDate = data.arrivalDate;
if (data.departureDate !== undefined && data.departureDate !== this.departureDate) this.departureDate = data.departureDate;
if (data.notes !== undefined && data.notes !== this.notes) this.notes = data.notes;
}
override toJSON() {
return {
...super.toJSON(),

View File

@ -442,6 +442,11 @@ export class FolkDrawfast extends FolkShape {
link.click();
}
static override fromData(data: Record<string, any>): FolkDrawfast {
const shape = FolkShape.fromData(data) as FolkDrawfast;
return shape;
}
override toJSON() {
return {
...super.toJSON(),
@ -454,4 +459,8 @@ export class FolkDrawfast extends FolkShape {
})),
};
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
}
}

View File

@ -374,6 +374,12 @@ export class FolkEmbed extends FolkShape {
}
}
static override fromData(data: Record<string, any>): FolkEmbed {
const shape = FolkShape.fromData(data) as FolkEmbed;
if (data.url) shape.url = data.url;
return shape;
}
override toJSON() {
return {
...super.toJSON(),
@ -381,4 +387,11 @@ export class FolkEmbed extends FolkShape {
url: this.url,
};
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if ("url" in data && this.url !== data.url) {
this.url = data.url;
}
}
}

View File

@ -561,7 +561,30 @@ export class FolkFeed extends FolkShape {
// ── Serialization ──
toJSON() {
static override fromData(data: Record<string, any>): FolkFeed {
const shape = FolkShape.fromData(data) as FolkFeed;
if (data.sourceLayer !== undefined) shape.sourceLayer = data.sourceLayer;
if (data.sourceModule !== undefined) shape.sourceModule = data.sourceModule;
if (data.feedId !== undefined) shape.feedId = data.feedId;
if (data.flowKind !== undefined) shape.flowKind = data.flowKind;
if (data.feedFilter !== undefined) shape.feedFilter = data.feedFilter;
if (data.maxItems !== undefined) shape.maxItems = data.maxItems;
if (data.refreshInterval !== undefined) shape.refreshInterval = data.refreshInterval;
return shape;
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if (data.sourceLayer !== undefined && data.sourceLayer !== this.sourceLayer) this.sourceLayer = data.sourceLayer;
if (data.sourceModule !== undefined && data.sourceModule !== this.sourceModule) this.sourceModule = data.sourceModule;
if (data.feedId !== undefined && data.feedId !== this.feedId) this.feedId = data.feedId;
if (data.flowKind !== undefined && data.flowKind !== this.flowKind) this.flowKind = data.flowKind;
if (data.feedFilter !== undefined && data.feedFilter !== this.feedFilter) this.feedFilter = data.feedFilter;
if (data.maxItems !== undefined && data.maxItems !== this.maxItems) this.maxItems = data.maxItems;
if (data.refreshInterval !== undefined && data.refreshInterval !== this.refreshInterval) this.refreshInterval = data.refreshInterval;
}
override toJSON() {
return {
...super.toJSON(),
type: "folk-feed",

View File

@ -367,6 +367,11 @@ export class FolkFreeCAD extends FolkShape {
return div.innerHTML;
}
static override fromData(data: Record<string, any>): FolkFreeCAD {
const shape = FolkShape.fromData(data) as FolkFreeCAD;
return shape;
}
override toJSON() {
return {
...super.toJSON(),
@ -376,4 +381,8 @@ export class FolkFreeCAD extends FolkShape {
stlUrl: this.#stlUrl,
};
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
}
}

View File

@ -280,6 +280,18 @@ export class FolkGoogleItem extends FolkShape {
return date.toLocaleDateString();
}
static override fromData(data: Record<string, any>): FolkGoogleItem {
const shape = FolkShape.fromData(data) as FolkGoogleItem;
if (data.itemId) shape.itemId = data.itemId;
if (data.service) shape.service = data.service;
if (data.title) shape.title = data.title;
if (data.preview) shape.preview = data.preview;
if (data.date !== undefined) shape.date = data.date;
if (data.thumbnailUrl) shape.thumbnailUrl = data.thumbnailUrl;
if (data.visibility) shape.visibility = data.visibility;
return shape;
}
override toJSON() {
return {
...super.toJSON(),
@ -293,6 +305,17 @@ export class FolkGoogleItem extends FolkShape {
visibility: this.visibility,
};
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if (data.itemId !== undefined && this.itemId !== data.itemId) this.itemId = data.itemId;
if (data.service !== undefined && this.service !== data.service) this.service = data.service;
if (data.title !== undefined && this.title !== data.title) this.title = data.title;
if (data.preview !== undefined && this.preview !== data.preview) this.preview = data.preview;
if (data.date !== undefined && this.date !== data.date) this.date = data.date;
if (data.thumbnailUrl !== undefined && this.thumbnailUrl !== data.thumbnailUrl) this.thumbnailUrl = data.thumbnailUrl;
if (data.visibility !== undefined && this.visibility !== data.visibility) this.visibility = data.visibility;
}
}
/**

View File

@ -206,6 +206,11 @@ declare global {
export class FolkImageGen extends FolkShape {
static override tagName = "folk-image-gen";
static override portDescriptors = [
{ name: "prompt", type: "text" as const, direction: "input" as const },
{ name: "image", type: "image-url" as const, direction: "output" as const },
];
static {
const sheet = new CSSStyleSheet();
const parentRules = Array.from(FolkShape.styles.cssRules)
@ -421,6 +426,11 @@ export class FolkImageGen extends FolkShape {
return div.innerHTML;
}
static override fromData(data: Record<string, any>): FolkImageGen {
const shape = FolkShape.fromData(data) as FolkImageGen;
return shape;
}
override toJSON() {
return {
...super.toJSON(),
@ -431,4 +441,8 @@ export class FolkImageGen extends FolkShape {
})),
};
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
}
}

View File

@ -780,6 +780,11 @@ export class FolkImageStudio extends FolkShape {
return div.innerHTML;
}
static override fromData(data: Record<string, any>): FolkImageStudio {
const el = FolkShape.fromData(data) as FolkImageStudio;
return el;
}
override toJSON() {
return {
...super.toJSON(),
@ -791,4 +796,8 @@ export class FolkImageStudio extends FolkShape {
results: this.#results,
};
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
}
}

View File

@ -351,6 +351,19 @@ export class FolkItinerary extends FolkShape {
`;
}
static override fromData(data: Record<string, any>): FolkItinerary {
const shape = FolkShape.fromData(data) as FolkItinerary;
if (data.tripTitle !== undefined) shape.tripTitle = data.tripTitle;
if (Array.isArray(data.items)) shape.items = data.items;
return shape;
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if (data.tripTitle !== undefined && data.tripTitle !== this.tripTitle) this.tripTitle = data.tripTitle;
if (Array.isArray(data.items) && JSON.stringify(data.items) !== JSON.stringify(this.items)) this.items = data.items;
}
override toJSON() {
return {
...super.toJSON(),

View File

@ -483,6 +483,11 @@ export class FolkKiCAD extends FolkShape {
return div.innerHTML;
}
static override fromData(data: Record<string, any>): FolkKiCAD {
const shape = FolkShape.fromData(data) as FolkKiCAD;
return shape;
}
override toJSON() {
return {
...super.toJSON(),
@ -494,4 +499,8 @@ export class FolkKiCAD extends FolkShape {
pdfUrl: this.#pdfUrl,
};
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
}
}

View File

@ -484,6 +484,13 @@ export class FolkMap extends FolkShape {
this.#mapMarkerInstances.set(marker.id, mapMarker);
}
static override fromData(data: Record<string, any>): FolkMap {
const shape = FolkShape.fromData(data) as FolkMap;
if (data.center) shape.center = data.center;
if (data.zoom !== undefined) shape.zoom = data.zoom;
return shape;
}
override toJSON() {
return {
...super.toJSON(),
@ -493,4 +500,10 @@ export class FolkMap extends FolkShape {
markers: this.markers,
};
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if (data.center !== undefined) this.center = data.center;
if (data.zoom !== undefined && this.zoom !== data.zoom) this.zoom = data.zoom;
}
}

View File

@ -321,6 +321,12 @@ export class FolkMarkdown extends FolkShape {
});
}
static override fromData(data: Record<string, any>): FolkMarkdown {
const shape = FolkShape.fromData(data) as FolkMarkdown;
if (data.content) shape.content = data.content;
return shape;
}
toJSON() {
return {
type: "folk-markdown",
@ -333,4 +339,11 @@ export class FolkMarkdown extends FolkShape {
content: this.content,
};
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if ("content" in data && this.content !== data.content) {
this.content = data.content;
}
}
}

View File

@ -227,6 +227,39 @@ export class FolkMultisigEmail extends FolkShape {
private _pollInterval: ReturnType<typeof setInterval> | null = null;
static override fromData(data: Record<string, any>): FolkMultisigEmail {
const shape = FolkShape.fromData(data) as FolkMultisigEmail;
if (data.mailboxSlug !== undefined) shape.mailboxSlug = data.mailboxSlug;
if (Array.isArray(data.toAddresses)) shape.toAddresses = data.toAddresses;
if (Array.isArray(data.ccAddresses)) shape.ccAddresses = data.ccAddresses;
if (data.subject !== undefined) shape.subject = data.subject;
if (data.bodyText !== undefined) shape.bodyText = data.bodyText;
if (data.bodyHtml !== undefined) shape.bodyHtml = data.bodyHtml;
if (data.replyToThreadId !== undefined) shape.replyToThreadId = data.replyToThreadId;
if (data.replyType !== undefined) shape.replyType = data.replyType;
if (data.approvalId !== undefined) shape.approvalId = data.approvalId;
if (data.status !== undefined) shape.status = data.status;
if (typeof data.requiredSignatures === "number") shape.requiredSignatures = data.requiredSignatures;
if (Array.isArray(data.signatures)) shape.signatures = data.signatures;
return shape;
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if (data.mailboxSlug !== undefined && data.mailboxSlug !== this.mailboxSlug) this.mailboxSlug = data.mailboxSlug;
if (Array.isArray(data.toAddresses) && JSON.stringify(data.toAddresses) !== JSON.stringify(this.toAddresses)) this.toAddresses = data.toAddresses;
if (Array.isArray(data.ccAddresses) && JSON.stringify(data.ccAddresses) !== JSON.stringify(this.ccAddresses)) this.ccAddresses = data.ccAddresses;
if (data.subject !== undefined && data.subject !== this.subject) this.subject = data.subject;
if (data.bodyText !== undefined && data.bodyText !== this.bodyText) this.bodyText = data.bodyText;
if (data.bodyHtml !== undefined && data.bodyHtml !== this.bodyHtml) this.bodyHtml = data.bodyHtml;
if (data.replyToThreadId !== undefined && data.replyToThreadId !== this.replyToThreadId) this.replyToThreadId = data.replyToThreadId;
if (data.replyType !== undefined && data.replyType !== this.replyType) this.replyType = data.replyType;
if (data.approvalId !== undefined && data.approvalId !== this.approvalId) this.approvalId = data.approvalId;
if (data.status !== undefined && data.status !== this.status) this.status = data.status;
if (typeof data.requiredSignatures === "number" && data.requiredSignatures !== this.requiredSignatures) this.requiredSignatures = data.requiredSignatures;
if (Array.isArray(data.signatures) && JSON.stringify(data.signatures) !== JSON.stringify(this.signatures)) this.signatures = data.signatures;
}
static get observedAttributes() {
return [...super.observedAttributes, "mailbox-slug", "subject", "status", "approval-id"];
}

View File

@ -634,6 +634,13 @@ export class FolkObsNote extends FolkShape {
return div.innerHTML;
}
static override fromData(data: Record<string, any>): FolkObsNote {
const shape = FolkShape.fromData(data) as FolkObsNote;
if (data.title) shape.title = data.title;
if (data.content) shape.content = data.content;
return shape;
}
override toJSON() {
return {
...super.toJSON(),
@ -642,4 +649,10 @@ export class FolkObsNote extends FolkShape {
content: this.content,
};
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if (data.title !== undefined && this.title !== data.title) this.title = data.title;
if (data.content !== undefined && this.content !== data.content) this.content = data.content;
}
}

View File

@ -333,6 +333,17 @@ export class FolkPackingList extends FolkShape {
});
}
static override fromData(data: Record<string, any>): FolkPackingList {
const shape = FolkShape.fromData(data) as FolkPackingList;
if (Array.isArray(data.items)) shape.items = data.items;
return shape;
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if (Array.isArray(data.items) && JSON.stringify(data.items) !== JSON.stringify(this.items)) this.items = data.items;
}
override toJSON() {
return {
...super.toJSON(),

View File

@ -280,6 +280,12 @@ export class FolkPiano extends FolkShape {
this.#iframe.src = PIANO_URL;
}
static override fromData(data: Record<string, any>): FolkPiano {
const shape = FolkShape.fromData(data) as FolkPiano;
if (data.isMinimized != null) shape.isMinimized = data.isMinimized;
return shape;
}
override toJSON() {
return {
...super.toJSON(),
@ -287,4 +293,11 @@ export class FolkPiano extends FolkShape {
isMinimized: this.isMinimized,
};
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if ("isMinimized" in data && this.isMinimized !== data.isMinimized) {
this.isMinimized = data.isMinimized;
}
}
}

View File

@ -323,6 +323,11 @@ declare global {
export class FolkPrompt extends FolkShape {
static override tagName = "folk-prompt";
static override portDescriptors = [
{ name: "context", type: "text" as const, direction: "input" as const },
{ name: "response", type: "text" as const, direction: "output" as const },
];
static {
const sheet = new CSSStyleSheet();
const parentRules = Array.from(FolkShape.styles.cssRules)
@ -669,6 +674,11 @@ export class FolkPrompt extends FolkShape {
return div.innerHTML;
}
static override fromData(data: Record<string, any>): FolkPrompt {
const shape = FolkShape.fromData(data) as FolkPrompt;
return shape;
}
override toJSON() {
return {
...super.toJSON(),
@ -682,4 +692,8 @@ export class FolkPrompt extends FolkShape {
})),
};
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
}
}

View File

@ -1027,6 +1027,21 @@ export class FolkRApp extends FolkShape {
});
}
static override fromData(data: Record<string, any>): FolkRApp {
const shape = FolkShape.fromData(data) as FolkRApp;
if (data.moduleId !== undefined) shape.moduleId = data.moduleId;
if (data.spaceSlug !== undefined) shape.spaceSlug = data.spaceSlug;
if (data.mode === "widget" || data.mode === "iframe") shape.mode = data.mode;
return shape;
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if (data.moduleId !== undefined && data.moduleId !== this.moduleId) this.moduleId = data.moduleId;
if (data.spaceSlug !== undefined && data.spaceSlug !== this.spaceSlug) this.spaceSlug = data.spaceSlug;
if ((data.mode === "widget" || data.mode === "iframe") && data.mode !== this.mode) this.mode = data.mode;
}
override toJSON() {
return {
...super.toJSON(),

View File

@ -8,6 +8,7 @@ import type { Point } from "./types";
import { MAX_Z_INDEX } from "./utils";
import { Vector } from "./Vector";
import type { PropertyValues } from "@lit/reactive-element";
import type { PortDescriptor } from "./data-types";
const resizeManager = new ResizeManager();
@ -795,12 +796,65 @@ export class FolkShape extends FolkElement {
}
// ── Data Ports (for shape-to-shape data piping) ──
/** Port descriptors — subclasses override to declare their inputs/outputs. */
static portDescriptors: PortDescriptor[] = [];
#ports = new Map<string, unknown>();
/** Initialize ports from the class's static descriptors. */
initPorts(): void {
const descriptors = (this.constructor as typeof FolkShape).portDescriptors;
for (const desc of descriptors) {
if (!this.#ports.has(desc.name)) {
this.#ports.set(desc.name, undefined);
}
}
}
/** Get a port descriptor by name. */
getPort(name: string): PortDescriptor | undefined {
return (this.constructor as typeof FolkShape).portDescriptors.find(p => p.name === name);
}
/** Set a port value and dispatch a change event. */
setPortValue(name: string, value: unknown): void {
const prev = this.#ports.get(name);
if (prev === value) return;
this.#ports.set(name, value);
this.dispatchEvent(new CustomEvent("port-value-changed", {
bubbles: true,
detail: { name, value, previous: prev },
}));
}
/** Get a port's current value. */
getPortValue(name: string): unknown {
return this.#ports.get(name);
}
/** Set a port value without dispatching events (for sync restore). */
setPortValueSilent(name: string, value: unknown): void {
this.#ports.set(name, value);
}
/** Get all input port descriptors. */
getInputPorts(): PortDescriptor[] {
return (this.constructor as typeof FolkShape).portDescriptors.filter(p => p.direction === "input");
}
/** Get all output port descriptors. */
getOutputPorts(): PortDescriptor[] {
return (this.constructor as typeof FolkShape).portDescriptors.filter(p => p.direction === "output");
}
/**
* Serialize shape to JSON for Automerge sync
* Subclasses should override and call super.toJSON()
*/
toJSON(): Record<string, unknown> {
return {
const json: Record<string, unknown> = {
type: "folk-shape",
id: this.id,
x: this.x,
@ -809,5 +863,63 @@ export class FolkShape extends FolkElement {
height: this.height,
rotation: this.rotation,
};
// Include port values when ports have data
if (this.#ports.size > 0) {
const portValues: Record<string, unknown> = {};
let hasValues = false;
for (const [name, value] of this.#ports) {
if (value !== undefined) {
portValues[name] = value;
hasValues = true;
}
}
if (hasValues) json.ports = portValues;
}
return json;
}
/**
* Create a new shape element from Automerge data.
* Subclasses should override to handle their own custom properties.
*/
static fromData(data: Record<string, any>): FolkShape {
const shape = document.createElement(data.type || "folk-shape") as FolkShape;
shape.id = data.id;
const x = (typeof data.x === "number" && Number.isFinite(data.x)) ? data.x : 100;
const y = (typeof data.y === "number" && Number.isFinite(data.y)) ? data.y : 100;
const w = (typeof data.width === "number" && Number.isFinite(data.width) && data.width > 0) ? data.width : 300;
const h = (typeof data.height === "number" && Number.isFinite(data.height) && data.height > 0) ? data.height : 200;
shape.x = x;
shape.y = y;
shape.width = w;
shape.height = h;
if (typeof data.rotation === "number" && Number.isFinite(data.rotation)) {
shape.rotation = data.rotation;
}
return shape;
}
/**
* Apply sync data to an existing shape.
* Subclasses should override and call super.applyData(data).
*/
applyData(data: Record<string, any>): void {
if (this.x !== data.x) this.x = data.x;
if (this.y !== data.y) this.y = data.y;
if (this.width !== data.width) this.width = data.width;
if (this.height !== data.height) this.height = data.height;
if (this.rotation !== data.rotation) this.rotation = data.rotation;
// Restore port values without dispatching events (avoids sync loops)
if (data.ports && typeof data.ports === "object") {
for (const [name, value] of Object.entries(data.ports)) {
this.setPortValueSilent(name, value);
}
}
}
}

View File

@ -106,6 +106,12 @@ export class FolkSlide extends FolkShape {
return root;
}
static override fromData(data: Record<string, any>): FolkSlide {
const shape = FolkShape.fromData(data) as FolkSlide;
if (data.label) shape.label = data.label;
return shape;
}
override toJSON() {
return {
...super.toJSON(),
@ -113,4 +119,11 @@ export class FolkSlide extends FolkShape {
label: this.label,
};
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if ("label" in data && this.label !== data.label) {
this.label = data.label;
}
}
}

View File

@ -876,6 +876,33 @@ export class FolkSocialPost extends FolkShape {
return div.innerHTML;
}
static override fromData(data: Record<string, any>): FolkSocialPost {
const shape = FolkShape.fromData(data) as FolkSocialPost;
if (data.platform) shape.platform = data.platform;
if (data.postType) shape.postType = data.postType;
if (data.content !== undefined) shape.content = data.content;
if (data.mediaUrl !== undefined) shape.mediaUrl = data.mediaUrl;
if (data.mediaType !== undefined) shape.mediaType = data.mediaType;
if (data.scheduledAt !== undefined) shape.scheduledAt = data.scheduledAt;
if (data.status) shape.status = data.status;
if (Array.isArray(data.hashtags)) shape.hashtags = data.hashtags;
if (typeof data.stepNumber === "number") shape.stepNumber = data.stepNumber;
return shape;
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if (data.platform !== undefined && data.platform !== this.platform) this.platform = data.platform;
if (data.postType !== undefined && data.postType !== this.postType) this.postType = data.postType;
if (data.content !== undefined && data.content !== this.content) this.content = data.content;
if (data.mediaUrl !== undefined && data.mediaUrl !== this.mediaUrl) this.mediaUrl = data.mediaUrl;
if (data.mediaType !== undefined && data.mediaType !== this.mediaType) this.mediaType = data.mediaType;
if (data.scheduledAt !== undefined && data.scheduledAt !== this.scheduledAt) this.scheduledAt = data.scheduledAt;
if (data.status !== undefined && data.status !== this.status) this.status = data.status;
if (Array.isArray(data.hashtags) && JSON.stringify(data.hashtags) !== JSON.stringify(this.hashtags)) this.hashtags = data.hashtags;
if (typeof data.stepNumber === "number" && data.stepNumber !== this.stepNumber) this.stepNumber = data.stepNumber;
}
override toJSON() {
return {
...super.toJSON(),

View File

@ -648,6 +648,20 @@ export class FolkSpider3D extends FolkShape {
// ── Serialization ──
static override fromData(data: Record<string, any>): FolkSpider3D {
const el = FolkShape.fromData(data) as FolkSpider3D;
if (data.title !== undefined) el.title = data.title;
if (data.axes !== undefined) el.axes = data.axes;
if (data.datasets !== undefined) el.datasets = data.datasets;
if (data.tiltX != null) el.tiltX = data.tiltX;
if (data.tiltY != null) el.tiltY = data.tiltY;
if (data.layerSpacing != null) el.layerSpacing = data.layerSpacing;
if (data.showOverlapHeight != null) el.showOverlapHeight = data.showOverlapHeight;
if (data.mode !== undefined) el.mode = data.mode;
if (data.space !== undefined) el.space = data.space;
return el;
}
override toJSON() {
return {
...super.toJSON(),
@ -663,4 +677,17 @@ export class FolkSpider3D extends FolkShape {
space: this.#space,
};
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if (data.title !== undefined && this.#title !== data.title) this.title = data.title;
if (data.axes !== undefined && JSON.stringify(this.#axes) !== JSON.stringify(data.axes)) this.axes = data.axes;
if (data.datasets !== undefined && JSON.stringify(this.#datasets) !== JSON.stringify(data.datasets)) this.datasets = data.datasets;
if (data.tiltX != null && this.#tiltX !== data.tiltX) this.tiltX = data.tiltX;
if (data.tiltY != null && this.#tiltY !== data.tiltY) this.tiltY = data.tiltY;
if (data.layerSpacing != null && this.#layerSpacing !== data.layerSpacing) this.layerSpacing = data.layerSpacing;
if (data.showOverlapHeight != null && this.#showOverlapHeight !== data.showOverlapHeight) this.showOverlapHeight = data.showOverlapHeight;
if (data.mode !== undefined && this.#mode !== data.mode) this.mode = data.mode;
if (data.space !== undefined && this.#space !== data.space) this.space = data.space;
}
}

View File

@ -428,6 +428,12 @@ export class FolkSplat extends FolkShape {
return div.innerHTML;
}
static override fromData(data: Record<string, any>): FolkSplat {
const shape = FolkShape.fromData(data) as FolkSplat;
if (data.splatUrl) shape.splatUrl = data.splatUrl;
return shape;
}
override toJSON() {
return {
...super.toJSON(),
@ -435,4 +441,11 @@ export class FolkSplat extends FolkShape {
splatUrl: this.#splatUrl,
};
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if ("splatUrl" in data && this.splatUrl !== data.splatUrl) {
this.splatUrl = data.splatUrl;
}
}
}

View File

@ -487,6 +487,19 @@ export class FolkTokenLedger extends FolkShape {
}
}
static override fromData(data: Record<string, any>): FolkTokenLedger {
const shape = FolkShape.fromData(data) as FolkTokenLedger;
if (data.mintId !== undefined) shape.mintId = data.mintId;
if (Array.isArray(data.entries)) shape.entries = data.entries;
return shape;
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if (data.mintId !== undefined && data.mintId !== this.mintId) this.mintId = data.mintId;
if (Array.isArray(data.entries) && JSON.stringify(data.entries) !== JSON.stringify(this.entries)) this.entries = data.entries;
}
override toJSON() {
return {
...super.toJSON(),

View File

@ -385,6 +385,33 @@ export class FolkTokenMint extends FolkShape {
this.#progressEl.style.background = this.#tokenColor;
}
static override fromData(data: Record<string, any>): FolkTokenMint {
const shape = FolkShape.fromData(data) as FolkTokenMint;
if (data.tokenName !== undefined) shape.tokenName = data.tokenName;
if (data.tokenSymbol !== undefined) shape.tokenSymbol = data.tokenSymbol;
if (data.description !== undefined) shape.description = data.description;
if (data.totalSupply != null) shape.totalSupply = data.totalSupply;
if (data.issuedSupply != null) shape.issuedSupply = data.issuedSupply;
if (data.tokenColor !== undefined) shape.tokenColor = data.tokenColor;
if (data.tokenIcon !== undefined) shape.tokenIcon = data.tokenIcon;
if (data.createdBy !== undefined) shape.createdBy = data.createdBy;
if (data.createdAt !== undefined) shape.createdAt = data.createdAt;
return shape;
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if (data.tokenName !== undefined && data.tokenName !== this.tokenName) this.tokenName = data.tokenName;
if (data.tokenSymbol !== undefined && data.tokenSymbol !== this.tokenSymbol) this.tokenSymbol = data.tokenSymbol;
if (data.description !== undefined && data.description !== this.description) this.description = data.description;
if (data.totalSupply != null && data.totalSupply !== this.totalSupply) this.totalSupply = data.totalSupply;
if (data.issuedSupply != null && data.issuedSupply !== this.issuedSupply) this.issuedSupply = data.issuedSupply;
if (data.tokenColor !== undefined && data.tokenColor !== this.tokenColor) this.tokenColor = data.tokenColor;
if (data.tokenIcon !== undefined && data.tokenIcon !== this.tokenIcon) this.tokenIcon = data.tokenIcon;
if (data.createdBy !== undefined && data.createdBy !== this.createdBy) this.createdBy = data.createdBy;
if (data.createdAt !== undefined && data.createdAt !== this.createdAt) this.createdAt = data.createdAt;
}
override toJSON() {
return {
...super.toJSON(),

View File

@ -227,6 +227,10 @@ declare global {
export class FolkTranscription extends FolkShape {
static override tagName = "folk-transcription";
static override portDescriptors = [
{ name: "transcript", type: "text" as const, direction: "output" as const },
];
static {
const sheet = new CSSStyleSheet();
const parentRules = Array.from(FolkShape.styles.cssRules)
@ -504,6 +508,11 @@ export class FolkTranscription extends FolkShape {
return div.innerHTML;
}
static override fromData(data: Record<string, any>): FolkTranscription {
const shape = FolkShape.fromData(data) as FolkTranscription;
return shape;
}
override toJSON() {
return {
...super.toJSON(),
@ -514,4 +523,8 @@ export class FolkTranscription extends FolkShape {
})),
};
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
}
}

View File

@ -525,6 +525,12 @@ export class FolkVideoChat extends FolkShape {
return div.innerHTML;
}
static override fromData(data: Record<string, any>): FolkVideoChat {
const shape = FolkShape.fromData(data) as FolkVideoChat;
if (data.roomId) shape.roomId = data.roomId;
return shape;
}
override toJSON() {
return {
...super.toJSON(),
@ -533,4 +539,9 @@ export class FolkVideoChat extends FolkShape {
isJoined: this.#isJoined,
};
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if (data.roomId !== undefined && this.roomId !== data.roomId) this.roomId = data.roomId;
}
}

View File

@ -289,6 +289,12 @@ declare global {
export class FolkVideoGen extends FolkShape {
static override tagName = "folk-video-gen";
static override portDescriptors = [
{ name: "prompt", type: "text" as const, direction: "input" as const },
{ name: "image", type: "image-url" as const, direction: "input" as const },
{ name: "video", type: "video-url" as const, direction: "output" as const },
];
static {
const sheet = new CSSStyleSheet();
const parentRules = Array.from(FolkShape.styles.cssRules)
@ -555,6 +561,11 @@ export class FolkVideoGen extends FolkShape {
return div.innerHTML;
}
static override fromData(data: Record<string, any>): FolkVideoGen {
const shape = FolkShape.fromData(data) as FolkVideoGen;
return shape;
}
override toJSON() {
return {
...super.toJSON(),
@ -566,4 +577,8 @@ export class FolkVideoGen extends FolkShape {
})),
};
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
}
}

View File

@ -568,6 +568,25 @@ export class FolkWorkflowBlock extends FolkShape {
return div.innerHTML;
}
static override fromData(data: Record<string, any>): FolkWorkflowBlock {
const shape = FolkShape.fromData(data) as FolkWorkflowBlock;
if (data.blockType) shape.blockType = data.blockType;
if (data.label !== undefined) shape.label = data.label;
if (Array.isArray(data.inputs)) shape.inputs = data.inputs;
if (Array.isArray(data.outputs)) shape.outputs = data.outputs;
if (data.config !== undefined) shape.#config = data.config;
return shape;
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if (data.blockType !== undefined && data.blockType !== this.blockType) this.blockType = data.blockType;
if (data.label !== undefined && data.label !== this.label) this.label = data.label;
if (Array.isArray(data.inputs) && JSON.stringify(data.inputs) !== JSON.stringify(this.inputs)) this.inputs = data.inputs;
if (Array.isArray(data.outputs) && JSON.stringify(data.outputs) !== JSON.stringify(this.outputs)) this.outputs = data.outputs;
if (data.config !== undefined && JSON.stringify(data.config) !== JSON.stringify(this.#config)) this.#config = data.config;
}
override toJSON() {
return {
...super.toJSON(),

View File

@ -471,6 +471,17 @@ export class FolkWrapper extends FolkShape {
this.dispatchEvent(new CustomEvent("tags-change", { detail: { tags: this.#tags } }));
}
static override fromData(data: Record<string, any>): FolkWrapper {
const shape = FolkShape.fromData(data) as FolkWrapper;
if (data.title) shape.title = data.title;
if (data.icon) shape.icon = data.icon;
if (data.primaryColor) shape.primaryColor = data.primaryColor;
if (data.isMinimized) shape.isMinimized = data.isMinimized;
if (data.isPinned) shape.isPinned = data.isPinned;
if (data.tags) shape.tags = data.tags;
return shape;
}
toJSON() {
return {
type: "folk-wrapper",
@ -488,4 +499,14 @@ export class FolkWrapper extends FolkShape {
tags: this.#tags,
};
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if (data.title !== undefined && this.title !== data.title) this.title = data.title;
if (data.icon !== undefined && this.icon !== data.icon) this.icon = data.icon;
if (data.primaryColor !== undefined && this.primaryColor !== data.primaryColor) this.primaryColor = data.primaryColor;
if (data.isMinimized !== undefined && this.isMinimized !== data.isMinimized) this.isMinimized = data.isMinimized;
if (data.isPinned !== undefined && this.isPinned !== data.isPinned) this.isPinned = data.isPinned;
if (data.tags !== undefined) this.tags = data.tags;
}
}

View File

@ -901,6 +901,11 @@ export class FolkZineGen extends FolkShape {
return div.innerHTML;
}
static override fromData(data: Record<string, any>): FolkZineGen {
const shape = FolkShape.fromData(data) as FolkZineGen;
return shape;
}
override toJSON() {
return {
...super.toJSON(),
@ -913,4 +918,8 @@ export class FolkZineGen extends FolkShape {
currentPage: this.#currentPage,
};
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
}
}

View File

@ -85,6 +85,10 @@ export * from "./folk-rapp";
// Feed Shape (inter-layer data flow)
export * from "./folk-feed";
// Data Types & Shape Registry
export * from "./data-types";
export * from "./shape-registry";
// Sync
export * from "./community-sync";
export * from "./presence";

67
lib/shape-registry.ts Normal file
View File

@ -0,0 +1,67 @@
/**
* Dynamic shape registry replaces the 300-line switch in canvas.html
* and the 165-line if-chain in community-sync.ts.
*
* Each shape class registers itself with `fromData()` and `applyData()`,
* so creation and sync are fully data-driven.
*/
import type { FolkShape } from "./folk-shape";
import type { ShapeData } from "./community-sync";
export interface ShapeRegistration {
tagName: string;
/** The custom element class (must have fromData/applyData) */
elementClass: typeof HTMLElement & {
fromData?(data: ShapeData): HTMLElement;
};
}
class ShapeRegistry {
#registrations = new Map<string, ShapeRegistration>();
/** Register a shape type. */
register(tagName: string, elementClass: ShapeRegistration["elementClass"]): void {
this.#registrations.set(tagName, { tagName, elementClass });
}
/** Get registration for a tag name. */
getRegistration(tagName: string): ShapeRegistration | undefined {
return this.#registrations.get(tagName);
}
/** Create a new element from ShapeData using the class's static fromData(). */
createElement(data: ShapeData): HTMLElement | null {
const reg = this.#registrations.get(data.type);
if (!reg) return null;
if (typeof reg.elementClass.fromData === "function") {
return reg.elementClass.fromData(data);
}
// Fallback: basic createElement
const el = document.createElement(data.type);
el.id = data.id;
return el;
}
/** Update an existing element using its instance applyData(). */
updateElement(shape: any, data: ShapeData): void {
if (typeof shape.applyData === "function") {
shape.applyData(data);
}
}
/** List all registered tag names. */
listAll(): string[] {
return Array.from(this.#registrations.keys());
}
/** Check if a type is registered. */
has(tagName: string): boolean {
return this.#registrations.has(tagName);
}
}
/** Singleton registry instance. */
export const shapeRegistry = new ShapeRegistry();

View File

@ -8,6 +8,7 @@
import { filesSchema, type FilesDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine";
import { authFetch, requireAuth } from "../../../shared/auth-fetch";
class FolkFileBrowser extends HTMLElement {
private shadow: ShadowRoot;
@ -259,6 +260,7 @@ class FolkFileBrowser extends HTMLElement {
alert("Upload is disabled in demo mode.");
return;
}
if (!requireAuth("upload files")) return;
const form = this.shadow.querySelector("#upload-form") as HTMLFormElement;
if (!form) return;
@ -274,7 +276,7 @@ class FolkFileBrowser extends HTMLElement {
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/files`, { method: "POST", body: formData });
const res = await authFetch(`${base}/api/files`, { method: "POST", body: formData });
if (res.ok) {
form.reset();
this.loadFiles();
@ -292,10 +294,11 @@ class FolkFileBrowser extends HTMLElement {
alert("Delete is disabled in demo mode.");
return;
}
if (!requireAuth("delete files")) return;
if (!confirm("Delete this file?")) return;
try {
const base = this.getApiBase();
await fetch(`${base}/api/files/${fileId}`, { method: "DELETE" });
await authFetch(`${base}/api/files/${fileId}`, { method: "DELETE" });
this.loadFiles();
} catch {}
}
@ -305,9 +308,10 @@ class FolkFileBrowser extends HTMLElement {
alert("Sharing is disabled in demo mode.");
return;
}
if (!requireAuth("share files")) return;
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/files/${fileId}/share`, {
const res = await authFetch(`${base}/api/files/${fileId}/share`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ expires_in_hours: 72 }),
@ -329,6 +333,7 @@ class FolkFileBrowser extends HTMLElement {
alert("Creating cards is disabled in demo mode.");
return;
}
if (!requireAuth("create cards")) return;
const form = this.shadow.querySelector("#card-form") as HTMLFormElement;
if (!form) return;
@ -340,7 +345,7 @@ class FolkFileBrowser extends HTMLElement {
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/cards`, {
const res = await authFetch(`${base}/api/cards`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, body, card_type: cardType, shared_space: this.space }),
@ -357,10 +362,11 @@ class FolkFileBrowser extends HTMLElement {
alert("Deleting cards is disabled in demo mode.");
return;
}
if (!requireAuth("delete cards")) return;
if (!confirm("Delete this card?")) return;
try {
const base = this.getApiBase();
await fetch(`${base}/api/cards/${cardId}`, { method: "DELETE" });
await authFetch(`${base}/api/cards/${cardId}`, { method: "DELETE" });
this.loadCards();
} catch {}
}

View File

@ -14,6 +14,8 @@ import { loadRoomHistory, saveRoomVisit, updateRoomThumbnail } from "./map-room-
import { MapPushManager } from "./map-push";
import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js";
import { requireAuth } from "../../../shared/auth-fetch";
import { getUsername } from "../../../shared/components/rstack-identity";
// MapLibre loaded via CDN — use window access with type assertion
@ -166,7 +168,9 @@ class FolkMapViewer extends HTMLElement {
private ensureUserProfile(): boolean {
if (this.userName) return true;
const name = prompt("Your display name for this room:");
// Use EncryptID username if authenticated
const identityName = getUsername();
const name = identityName || prompt("Your display name for this room:");
if (!name?.trim()) return false;
this.userName = name.trim();
localStorage.setItem("rmaps_user", JSON.stringify({
@ -959,6 +963,7 @@ class FolkMapViewer extends HTMLElement {
}
private createRoom() {
if (!requireAuth("create map room")) return;
const name = prompt("Room name (slug):");
if (!name?.trim()) return;
const slug = name.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-");

View File

@ -9,6 +9,7 @@ import { proposalSchema, type ProposalDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js";
import { authFetch, requireAuth } from "../../../shared/auth-fetch";
interface VoteSpace {
slug: string;
@ -303,9 +304,10 @@ class FolkVoteDashboard extends HTMLElement {
this.render();
return;
}
if (!requireAuth("cast vote")) return;
try {
const base = this.getApiBase();
await fetch(`${base}/api/proposals/${proposalId}/vote`, {
await authFetch(`${base}/api/proposals/${proposalId}/vote`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ weight }),
@ -330,9 +332,10 @@ class FolkVoteDashboard extends HTMLElement {
this.render();
return;
}
if (!requireAuth("cast final vote")) return;
try {
const base = this.getApiBase();
await fetch(`${base}/api/proposals/${proposalId}/final-vote`, {
await authFetch(`${base}/api/proposals/${proposalId}/final-vote`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ vote }),
@ -363,9 +366,10 @@ class FolkVoteDashboard extends HTMLElement {
this.render();
return;
}
if (!requireAuth("create proposal")) return;
try {
const base = this.getApiBase();
await fetch(`${base}/api/proposals`, {
await authFetch(`${base}/api/proposals`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ space_slug: this.selectedSpace?.slug, title, description }),

43
shared/auth-fetch.ts Normal file
View File

@ -0,0 +1,43 @@
/**
* Authenticated fetch helpers for rApp frontends.
*
* Wraps the native `fetch()` to inject the EncryptID bearer token,
* and provides a `requireAuth()` gate that shows the auth modal when needed.
*/
import { getAccessToken, isAuthenticated } from "./components/rstack-identity";
/**
* Fetch wrapper that injects `Authorization: Bearer <token>`.
* Skips Content-Type for FormData (browser sets multipart boundary).
*/
export async function authFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const token = getAccessToken();
const headers = new Headers(init?.headers);
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
// Don't override Content-Type for FormData — browser sets multipart boundary
if (init?.body instanceof FormData) {
headers.delete("Content-Type");
}
return fetch(input, { ...init, headers });
}
/**
* Check authentication and show the auth modal if not authenticated.
* Returns `true` if authenticated, `false` if not (modal shown).
*/
export function requireAuth(actionLabel?: string): boolean {
if (isAuthenticated()) return true;
const identityEl = document.querySelector("rstack-identity") as any;
if (identityEl?.showAuthModal) {
identityEl.showAuthModal();
}
return false;
}

View File

@ -2516,7 +2516,8 @@
MiCanvasBridge,
installSelectionTransforms,
TriageManager,
MiTriagePanel
MiTriagePanel,
shapeRegistry
} from "@lib";
import { RStackIdentity } from "@shared/components/rstack-identity";
import { RStackAppSwitcher } from "@shared/components/rstack-app-switcher";
@ -2635,6 +2636,48 @@
FolkRApp.define();
FolkFeed.define();
// Register all shapes with the shape registry
shapeRegistry.register("folk-shape", FolkShape);
shapeRegistry.register("folk-markdown", FolkMarkdown);
shapeRegistry.register("folk-wrapper", FolkWrapper);
shapeRegistry.register("folk-arrow", FolkArrow);
shapeRegistry.register("folk-slide", FolkSlide);
shapeRegistry.register("folk-chat", FolkChat);
shapeRegistry.register("folk-google-item", FolkGoogleItem);
shapeRegistry.register("folk-piano", FolkPiano);
shapeRegistry.register("folk-embed", FolkEmbed);
shapeRegistry.register("folk-calendar", FolkCalendar);
shapeRegistry.register("folk-map", FolkMap);
shapeRegistry.register("folk-image-gen", FolkImageGen);
shapeRegistry.register("folk-video-gen", FolkVideoGen);
shapeRegistry.register("folk-prompt", FolkPrompt);
shapeRegistry.register("folk-zine-gen", FolkZineGen);
shapeRegistry.register("folk-transcription", FolkTranscription);
shapeRegistry.register("folk-video-chat", FolkVideoChat);
shapeRegistry.register("folk-obs-note", FolkObsNote);
shapeRegistry.register("folk-workflow-block", FolkWorkflowBlock);
shapeRegistry.register("folk-itinerary", FolkItinerary);
shapeRegistry.register("folk-destination", FolkDestination);
shapeRegistry.register("folk-budget", FolkBudget);
shapeRegistry.register("folk-packing-list", FolkPackingList);
shapeRegistry.register("folk-booking", FolkBooking);
shapeRegistry.register("folk-token-mint", FolkTokenMint);
shapeRegistry.register("folk-token-ledger", FolkTokenLedger);
shapeRegistry.register("folk-choice-vote", FolkChoiceVote);
shapeRegistry.register("folk-choice-rank", FolkChoiceRank);
shapeRegistry.register("folk-choice-spider", FolkChoiceSpider);
shapeRegistry.register("folk-spider-3d", FolkSpider3D);
shapeRegistry.register("folk-choice-conviction", FolkChoiceConviction);
shapeRegistry.register("folk-social-post", FolkSocialPost);
shapeRegistry.register("folk-splat", FolkSplat);
shapeRegistry.register("folk-blender", FolkBlender);
shapeRegistry.register("folk-drawfast", FolkDrawfast);
shapeRegistry.register("folk-freecad", FolkFreeCAD);
shapeRegistry.register("folk-kicad", FolkKiCAD);
shapeRegistry.register("folk-canvas", FolkCanvas);
shapeRegistry.register("folk-rapp", FolkRApp);
shapeRegistry.register("folk-feed", FolkFeed);
// Zoom and pan state — declared early to avoid TDZ errors
// (event handlers reference these before awaits yield execution)
let scale = 1;
@ -3439,304 +3482,46 @@
// 'deleted' is handled by shape-removed (element is removed from DOM)
});
// Create a shape element from data
// Create a shape element from data (via shape registry)
function newShapeElement(data) {
let shape;
switch (data.type) {
case "folk-arrow":
shape = document.createElement("folk-arrow");
if (data.sourceId) shape.sourceId = data.sourceId;
if (data.targetId) shape.targetId = data.targetId;
if (data.color) shape.color = data.color;
if (data.strokeWidth) shape.strokeWidth = data.strokeWidth;
if (data.arrowStyle) shape.arrowStyle = data.arrowStyle;
shape.id = data.id;
return shape; // Arrows don't have position/size
case "folk-wrapper":
shape = document.createElement("folk-wrapper");
if (data.title) shape.title = data.title;
if (data.icon) shape.icon = data.icon;
if (data.primaryColor) shape.primaryColor = data.primaryColor;
if (data.isMinimized) shape.isMinimized = data.isMinimized;
if (data.isPinned) shape.isPinned = data.isPinned;
if (data.tags) shape.tags = data.tags;
break;
case "folk-slide":
shape = document.createElement("folk-slide");
if (data.label) shape.label = data.label;
break;
case "folk-chat":
shape = document.createElement("folk-chat");
if (data.roomId) shape.roomId = data.roomId;
break;
case "folk-google-item":
shape = document.createElement("folk-google-item");
if (data.itemId) shape.itemId = data.itemId;
if (data.service) shape.service = data.service;
if (data.title) shape.title = data.title;
if (data.preview) shape.preview = data.preview;
if (data.date) shape.date = data.date;
if (data.thumbnailUrl) shape.thumbnailUrl = data.thumbnailUrl;
if (data.visibility) shape.visibility = data.visibility;
break;
case "folk-piano":
shape = document.createElement("folk-piano");
if (data.isMinimized) shape.isMinimized = data.isMinimized;
break;
case "folk-embed":
shape = document.createElement("folk-embed");
if (data.url) shape.url = data.url;
break;
case "folk-calendar":
shape = document.createElement("folk-calendar");
if (data.selectedDate) shape.selectedDate = new Date(data.selectedDate);
if (data.events) {
shape.events = data.events.map(e => ({
...e,
date: new Date(e.date)
}));
}
break;
case "folk-map":
shape = document.createElement("folk-map");
if (data.center) shape.center = data.center;
if (data.zoom) shape.zoom = data.zoom;
// Note: markers would need to be handled separately
break;
case "folk-image-gen":
shape = document.createElement("folk-image-gen");
// Images history would need to be restored from data.images
break;
case "folk-video-gen":
shape = document.createElement("folk-video-gen");
// Videos history would need to be restored from data.videos
break;
case "folk-prompt":
shape = document.createElement("folk-prompt");
// Messages history would need to be restored from data.messages
break;
case "folk-zine-gen":
shape = document.createElement("folk-zine-gen");
break;
case "folk-transcription":
shape = document.createElement("folk-transcription");
// Transcript would need to be restored from data.segments
break;
case "folk-video-chat":
shape = document.createElement("folk-video-chat");
if (data.roomId) shape.roomId = data.roomId;
break;
case "folk-obs-note":
shape = document.createElement("folk-obs-note");
if (data.title) shape.title = data.title;
if (data.content) shape.content = data.content;
break;
case "folk-workflow-block":
shape = document.createElement("folk-workflow-block");
if (data.blockType) shape.blockType = data.blockType;
if (data.label) shape.label = data.label;
if (data.inputs) shape.inputs = data.inputs;
if (data.outputs) shape.outputs = data.outputs;
break;
case "folk-itinerary":
shape = document.createElement("folk-itinerary");
if (data.tripTitle) shape.tripTitle = data.tripTitle;
if (data.items) shape.items = data.items;
break;
case "folk-destination":
shape = document.createElement("folk-destination");
if (data.destName) shape.destName = data.destName;
if (data.country) shape.country = data.country;
if (data.lat != null) shape.lat = data.lat;
if (data.lng != null) shape.lng = data.lng;
if (data.arrivalDate) shape.arrivalDate = data.arrivalDate;
if (data.departureDate) shape.departureDate = data.departureDate;
if (data.notes) shape.notes = data.notes;
break;
case "folk-budget":
shape = document.createElement("folk-budget");
if (data.budgetTotal != null) shape.budgetTotal = data.budgetTotal;
if (data.currency) shape.currency = data.currency;
if (data.expenses) shape.expenses = data.expenses;
break;
case "folk-packing-list":
shape = document.createElement("folk-packing-list");
if (data.items) shape.items = data.items;
break;
case "folk-booking":
shape = document.createElement("folk-booking");
if (data.bookingType) shape.bookingType = data.bookingType;
if (data.provider) shape.provider = data.provider;
if (data.confirmationNumber) shape.confirmationNumber = data.confirmationNumber;
if (data.details) shape.details = data.details;
if (data.cost != null) shape.cost = data.cost;
if (data.currency) shape.currency = data.currency;
if (data.startDate) shape.startDate = data.startDate;
if (data.endDate) shape.endDate = data.endDate;
if (data.bookingStatus) shape.bookingStatus = data.bookingStatus;
break;
case "folk-token-mint":
shape = document.createElement("folk-token-mint");
if (data.tokenName) shape.tokenName = data.tokenName;
if (data.tokenSymbol) shape.tokenSymbol = data.tokenSymbol;
if (data.description) shape.description = data.description;
if (data.totalSupply != null) shape.totalSupply = data.totalSupply;
if (data.issuedSupply != null) shape.issuedSupply = data.issuedSupply;
if (data.tokenColor) shape.tokenColor = data.tokenColor;
if (data.tokenIcon) shape.tokenIcon = data.tokenIcon;
if (data.createdBy) shape.createdBy = data.createdBy;
if (data.createdAt) shape.createdAt = data.createdAt;
break;
case "folk-token-ledger":
shape = document.createElement("folk-token-ledger");
if (data.mintId) shape.mintId = data.mintId;
if (data.entries) shape.entries = data.entries;
break;
case "folk-choice-vote":
shape = document.createElement("folk-choice-vote");
if (data.title) shape.title = data.title;
if (data.options) shape.options = data.options;
if (data.mode) shape.mode = data.mode;
if (data.budget != null) shape.budget = data.budget;
if (data.votes) shape.votes = data.votes;
break;
case "folk-choice-rank":
shape = document.createElement("folk-choice-rank");
if (data.title) shape.title = data.title;
if (data.options) shape.options = data.options;
if (data.rankings) shape.rankings = data.rankings;
break;
case "folk-choice-spider":
shape = document.createElement("folk-choice-spider");
if (data.title) shape.title = data.title;
if (data.options) shape.options = data.options;
if (data.criteria) shape.criteria = data.criteria;
if (data.scores) shape.scores = data.scores;
break;
case "folk-spider-3d":
shape = document.createElement("folk-spider-3d");
if (data.title) shape.title = data.title;
if (data.axes) shape.axes = data.axes;
if (data.datasets) shape.datasets = data.datasets;
if (data.tiltX != null) shape.tiltX = data.tiltX;
if (data.tiltY != null) shape.tiltY = data.tiltY;
if (data.layerSpacing != null) shape.layerSpacing = data.layerSpacing;
if (data.showOverlapHeight != null) shape.showOverlapHeight = data.showOverlapHeight;
if (data.mode) shape.mode = data.mode;
if (data.space) shape.space = data.space;
break;
case "folk-choice-conviction":
shape = document.createElement("folk-choice-conviction");
if (data.title) shape.title = data.title;
if (data.options) shape.options = data.options;
if (data.stakes) shape.stakes = data.stakes;
break;
case "folk-social-post":
shape = document.createElement("folk-social-post");
if (data.platform) shape.platform = data.platform;
if (data.postType) shape.postType = data.postType;
if (data.content) shape.content = data.content;
if (data.mediaUrl) shape.mediaUrl = data.mediaUrl;
if (data.mediaType) shape.mediaType = data.mediaType;
if (data.scheduledAt) shape.scheduledAt = data.scheduledAt;
if (data.status) shape.status = data.status;
if (data.hashtags) shape.hashtags = data.hashtags;
if (data.stepNumber) shape.stepNumber = data.stepNumber;
break;
case "folk-splat":
shape = document.createElement("folk-splat");
if (data.splatUrl) shape.splatUrl = data.splatUrl;
break;
case "folk-blender":
shape = document.createElement("folk-blender");
break;
case "folk-drawfast":
shape = document.createElement("folk-drawfast");
break;
case "folk-freecad":
shape = document.createElement("folk-freecad");
break;
case "folk-kicad":
shape = document.createElement("folk-kicad");
break;
case "folk-multisig-email":
shape = document.createElement("folk-multisig-email");
if (data.mailboxSlug) shape.mailboxSlug = data.mailboxSlug;
if (data.toAddresses) shape.toAddresses = data.toAddresses;
if (data.ccAddresses) shape.ccAddresses = data.ccAddresses;
if (data.subject) shape.subject = data.subject;
if (data.bodyText) shape.bodyText = data.bodyText;
if (data.bodyHtml) shape.bodyHtml = data.bodyHtml;
if (data.replyToThreadId) shape.replyToThreadId = data.replyToThreadId;
if (data.replyType) shape.replyType = data.replyType;
if (data.approvalId) shape.approvalId = data.approvalId;
if (data.status) shape.status = data.status;
if (data.requiredSignatures != null) shape.requiredSignatures = data.requiredSignatures;
if (data.signatures) shape.signatures = data.signatures;
break;
case "folk-canvas":
shape = document.createElement("folk-canvas");
shape.parentSlug = communitySlug; // pass parent context for nest-from
if (data.sourceSlug) shape.sourceSlug = data.sourceSlug;
if (data.sourceDID) shape.sourceDID = data.sourceDID;
if (data.permissions) shape.permissions = data.permissions;
if (data.collapsed != null) shape.collapsed = data.collapsed;
if (data.label) shape.label = data.label;
break;
case "folk-rapp":
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");
if (data.sourceLayer) shape.sourceLayer = data.sourceLayer;
if (data.sourceModule) shape.sourceModule = data.sourceModule;
if (data.feedId) shape.feedId = data.feedId;
if (data.flowKind) shape.flowKind = data.flowKind;
if (data.feedFilter) shape.feedFilter = data.feedFilter;
if (data.maxItems) shape.maxItems = data.maxItems;
if (data.refreshInterval) shape.refreshInterval = data.refreshInterval;
break;
case "wb-svg": {
// Whiteboard SVG drawing — render as a folk-shape with inline SVG
if (!data.svgMarkup) return null;
let vb = data.svgViewBox;
// Old format: x/y/width/height are 0 — compute bounds from SVG
if (!data.width || !data.height || !vb) {
const bounds = computeWbBounds(data.svgMarkup);
if (!bounds) return null;
data.x = bounds.x;
data.y = bounds.y;
data.width = bounds.width;
data.height = bounds.height;
vb = bounds.viewBox;
}
shape = createWbShapeElement(data.svgMarkup, vb);
break;
// Special case: whiteboard SVG drawings
if (data.type === "wb-svg") {
if (!data.svgMarkup) return null;
let vb = data.svgViewBox;
if (!data.width || !data.height || !vb) {
const bounds = computeWbBounds(data.svgMarkup);
if (!bounds) return null;
data.x = bounds.x;
data.y = bounds.y;
data.width = bounds.width;
data.height = bounds.height;
vb = bounds.viewBox;
}
case "folk-markdown":
default:
shape = document.createElement("folk-markdown");
if (data.content) shape.content = data.content;
break;
const shape = createWbShapeElement(data.svgMarkup, vb);
shape.id = data.id;
shape.x = data.x;
shape.y = data.y;
shape.width = data.width;
shape.height = data.height;
if (typeof data.rotation === "number" && Number.isFinite(data.rotation)) shape.rotation = data.rotation;
return shape;
}
shape.id = data.id;
// Default unknown types to folk-markdown
if (!shapeRegistry.has(data.type)) {
data = { ...data, type: "folk-markdown" };
}
// Validate coordinates — use defaults only when data is missing/invalid
const x = (typeof data.x === "number" && Number.isFinite(data.x)) ? data.x : (console.warn(`[Canvas] Shape ${data.id}: invalid x=${data.x}, using default`), 100);
const y = (typeof data.y === "number" && Number.isFinite(data.y)) ? data.y : (console.warn(`[Canvas] Shape ${data.id}: invalid y=${data.y}, using default`), 100);
const w = (typeof data.width === "number" && Number.isFinite(data.width) && data.width > 0) ? data.width : (console.warn(`[Canvas] Shape ${data.id}: invalid width=${data.width}, using default`), 300);
const h = (typeof data.height === "number" && Number.isFinite(data.height) && data.height > 0) ? data.height : (console.warn(`[Canvas] Shape ${data.id}: invalid height=${data.height}, using default`), 200);
const shape = shapeRegistry.createElement(data);
if (!shape) return null;
shape.x = x;
shape.y = y;
shape.width = w;
shape.height = h;
if (typeof data.rotation === "number" && Number.isFinite(data.rotation)) shape.rotation = data.rotation;
// Context-specific overrides that depend on canvas state
if (data.type === "folk-canvas" && shape.parentSlug !== undefined) {
shape.parentSlug = communitySlug;
}
if (data.type === "folk-rapp") {
shape.spaceSlug = data.spaceSlug || communitySlug;
}
return shape;
}