From c4717e3c682cd62266514d56f726a7fbbf19b80e Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 11 Mar 2026 15:59:51 -0700 Subject: [PATCH] feat: auth-fetch, shape registry, and data pipes (TASK-13, TASK-41, TASK-42) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/community-sync.ts | 180 +-------- lib/data-types.ts | 62 +++ lib/folk-arrow.ts | 112 +++++- lib/folk-blender.ts | 9 + lib/folk-booking.ts | 27 ++ lib/folk-budget.ts | 15 + lib/folk-calendar.ts | 20 + lib/folk-canvas.ts | 19 + lib/folk-chat.ts | 13 + lib/folk-choice-conviction.ts | 15 + lib/folk-choice-rank.ts | 15 + lib/folk-choice-spider.ts | 17 + lib/folk-choice-vote.ts | 19 + lib/folk-destination.ts | 23 ++ lib/folk-drawfast.ts | 9 + lib/folk-embed.ts | 13 + lib/folk-feed.ts | 25 +- lib/folk-freecad.ts | 9 + lib/folk-google-item.ts | 23 ++ lib/folk-image-gen.ts | 14 + lib/folk-image-studio.ts | 9 + lib/folk-itinerary.ts | 13 + lib/folk-kicad.ts | 9 + lib/folk-map.ts | 13 + lib/folk-markdown.ts | 13 + lib/folk-multisig-email.ts | 33 ++ lib/folk-obs-note.ts | 13 + lib/folk-packing-list.ts | 11 + lib/folk-piano.ts | 13 + lib/folk-prompt.ts | 14 + lib/folk-rapp.ts | 15 + lib/folk-shape.ts | 114 +++++- lib/folk-slide.ts | 13 + lib/folk-social-post.ts | 27 ++ lib/folk-spider-3d.ts | 27 ++ lib/folk-splat.ts | 13 + lib/folk-token-ledger.ts | 13 + lib/folk-token-mint.ts | 27 ++ lib/folk-transcription.ts | 13 + lib/folk-video-chat.ts | 11 + lib/folk-video-gen.ts | 15 + lib/folk-workflow-block.ts | 19 + lib/folk-wrapper.ts | 21 + lib/folk-zine-gen.ts | 9 + lib/index.ts | 4 + lib/shape-registry.ts | 67 ++++ .../rfiles/components/folk-file-browser.ts | 16 +- modules/rmaps/components/folk-map-viewer.ts | 7 +- .../rvote/components/folk-vote-dashboard.ts | 10 +- shared/auth-fetch.ts | 43 ++ website/canvas.html | 371 ++++-------------- 51 files changed, 1166 insertions(+), 469 deletions(-) create mode 100644 lib/data-types.ts create mode 100644 lib/shape-registry.ts create mode 100644 shared/auth-fetch.ts diff --git a/lib/community-sync.ts b/lib/community-sync.ts index 7a87b0b..5cdd32a 100644 --- a/lib/community-sync.ts +++ b/lib/community-sync.ts @@ -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; // 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; } } diff --git a/lib/data-types.ts b/lib/data-types.ts new file mode 100644 index 0000000..87b8269 --- /dev/null +++ b/lib/data-types.ts @@ -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 + } +} diff --git a/lib/folk-arrow.ts b/lib/folk-arrow.ts index 41dbb71..9eb716a 100644 --- a/lib/folk-arrow.ts +++ b/lib/folk-arrow.ts @@ -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 | 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): 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 = { 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): 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; } } diff --git a/lib/folk-blender.ts b/lib/folk-blender.ts index a89f9ba..27c2179 100644 --- a/lib/folk-blender.ts +++ b/lib/folk-blender.ts @@ -435,6 +435,11 @@ export class FolkBlender extends FolkShape { return div.innerHTML; } + static override fromData(data: Record): 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): void { + super.applyData(data); + } } diff --git a/lib/folk-booking.ts b/lib/folk-booking.ts index 1cd7ab2..e3fbf0c 100644 --- a/lib/folk-booking.ts +++ b/lib/folk-booking.ts @@ -342,6 +342,33 @@ export class FolkBooking extends FolkShape { this.#bodyEl.innerHTML = bodyHTML; } + static override fromData(data: Record): 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): 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(), diff --git a/lib/folk-budget.ts b/lib/folk-budget.ts index 3a804e5..df04c32 100644 --- a/lib/folk-budget.ts +++ b/lib/folk-budget.ts @@ -359,6 +359,21 @@ export class FolkBudget extends FolkShape { } } + static override fromData(data: Record): 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): 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(), diff --git a/lib/folk-calendar.ts b/lib/folk-calendar.ts index 81e9da7..e27a05d 100644 --- a/lib/folk-calendar.ts +++ b/lib/folk-calendar.ts @@ -436,6 +436,13 @@ export class FolkCalendar extends FolkShape { return div.innerHTML; } + static override fromData(data: Record): 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): 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) })); + } + } } diff --git a/lib/folk-canvas.ts b/lib/folk-canvas.ts index 3b2fb90..b3877bf 100644 --- a/lib/folk-canvas.ts +++ b/lib/folk-canvas.ts @@ -537,6 +537,25 @@ export class FolkCanvas extends FolkShape { this.#disconnect(); } + static override fromData(data: Record): 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): 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(), diff --git a/lib/folk-chat.ts b/lib/folk-chat.ts index 5767aef..c099d45 100644 --- a/lib/folk-chat.ts +++ b/lib/folk-chat.ts @@ -412,6 +412,12 @@ export class FolkChat extends FolkShape { return div.innerHTML; } + static override fromData(data: Record): 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): void { + super.applyData(data); + if ("roomId" in data && this.roomId !== data.roomId) { + this.roomId = data.roomId; + } + } } diff --git a/lib/folk-choice-conviction.ts b/lib/folk-choice-conviction.ts index 702a258..3fc742c 100644 --- a/lib/folk-choice-conviction.ts +++ b/lib/folk-choice-conviction.ts @@ -949,6 +949,14 @@ export class FolkChoiceConviction extends FolkShape { }); } + static override fromData(data: Record): 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): 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; + } } diff --git a/lib/folk-choice-rank.ts b/lib/folk-choice-rank.ts index 33287e9..13615bc 100644 --- a/lib/folk-choice-rank.ts +++ b/lib/folk-choice-rank.ts @@ -1077,6 +1077,14 @@ export class FolkChoiceRank extends FolkShape { }); } + static override fromData(data: Record): 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): 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; + } } diff --git a/lib/folk-choice-spider.ts b/lib/folk-choice-spider.ts index eac2fef..5c80af5 100644 --- a/lib/folk-choice-spider.ts +++ b/lib/folk-choice-spider.ts @@ -1089,6 +1089,15 @@ export class FolkChoiceSpider extends FolkShape { }); } + static override fromData(data: Record): 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): 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; + } } diff --git a/lib/folk-choice-vote.ts b/lib/folk-choice-vote.ts index 0e40c1a..f2d17d0 100644 --- a/lib/folk-choice-vote.ts +++ b/lib/folk-choice-vote.ts @@ -930,6 +930,16 @@ export class FolkChoiceVote extends FolkShape { }); } + static override fromData(data: Record): 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): 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; + } } diff --git a/lib/folk-destination.ts b/lib/folk-destination.ts index a104ebc..b0d28b7 100644 --- a/lib/folk-destination.ts +++ b/lib/folk-destination.ts @@ -265,6 +265,29 @@ export class FolkDestination extends FolkShape { this.#datesEl.innerHTML = result; } + static override fromData(data: Record): 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): 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(), diff --git a/lib/folk-drawfast.ts b/lib/folk-drawfast.ts index 7258ffe..145059f 100644 --- a/lib/folk-drawfast.ts +++ b/lib/folk-drawfast.ts @@ -442,6 +442,11 @@ export class FolkDrawfast extends FolkShape { link.click(); } + static override fromData(data: Record): 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): void { + super.applyData(data); + } } diff --git a/lib/folk-embed.ts b/lib/folk-embed.ts index bc2fb4e..15f3941 100644 --- a/lib/folk-embed.ts +++ b/lib/folk-embed.ts @@ -374,6 +374,12 @@ export class FolkEmbed extends FolkShape { } } + static override fromData(data: Record): 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): void { + super.applyData(data); + if ("url" in data && this.url !== data.url) { + this.url = data.url; + } + } } diff --git a/lib/folk-feed.ts b/lib/folk-feed.ts index 62463cf..52eb2bc 100644 --- a/lib/folk-feed.ts +++ b/lib/folk-feed.ts @@ -561,7 +561,30 @@ export class FolkFeed extends FolkShape { // ── Serialization ── - toJSON() { + static override fromData(data: Record): 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): 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", diff --git a/lib/folk-freecad.ts b/lib/folk-freecad.ts index 4554108..5b04546 100644 --- a/lib/folk-freecad.ts +++ b/lib/folk-freecad.ts @@ -367,6 +367,11 @@ export class FolkFreeCAD extends FolkShape { return div.innerHTML; } + static override fromData(data: Record): 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): void { + super.applyData(data); + } } diff --git a/lib/folk-google-item.ts b/lib/folk-google-item.ts index 239e4a2..61c0544 100644 --- a/lib/folk-google-item.ts +++ b/lib/folk-google-item.ts @@ -280,6 +280,18 @@ export class FolkGoogleItem extends FolkShape { return date.toLocaleDateString(); } + static override fromData(data: Record): 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): 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; + } } /** diff --git a/lib/folk-image-gen.ts b/lib/folk-image-gen.ts index 1ae43e8..9cc7e29 100644 --- a/lib/folk-image-gen.ts +++ b/lib/folk-image-gen.ts @@ -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): 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): void { + super.applyData(data); + } } diff --git a/lib/folk-image-studio.ts b/lib/folk-image-studio.ts index 671356f..d5a0b64 100644 --- a/lib/folk-image-studio.ts +++ b/lib/folk-image-studio.ts @@ -780,6 +780,11 @@ export class FolkImageStudio extends FolkShape { return div.innerHTML; } + static override fromData(data: Record): 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): void { + super.applyData(data); + } } diff --git a/lib/folk-itinerary.ts b/lib/folk-itinerary.ts index 107d6d9..98c3dea 100644 --- a/lib/folk-itinerary.ts +++ b/lib/folk-itinerary.ts @@ -351,6 +351,19 @@ export class FolkItinerary extends FolkShape { `; } + static override fromData(data: Record): 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): 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(), diff --git a/lib/folk-kicad.ts b/lib/folk-kicad.ts index 517578e..5ea6666 100644 --- a/lib/folk-kicad.ts +++ b/lib/folk-kicad.ts @@ -483,6 +483,11 @@ export class FolkKiCAD extends FolkShape { return div.innerHTML; } + static override fromData(data: Record): 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): void { + super.applyData(data); + } } diff --git a/lib/folk-map.ts b/lib/folk-map.ts index 9d11b06..07a23f3 100644 --- a/lib/folk-map.ts +++ b/lib/folk-map.ts @@ -484,6 +484,13 @@ export class FolkMap extends FolkShape { this.#mapMarkerInstances.set(marker.id, mapMarker); } + static override fromData(data: Record): 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): void { + super.applyData(data); + if (data.center !== undefined) this.center = data.center; + if (data.zoom !== undefined && this.zoom !== data.zoom) this.zoom = data.zoom; + } } diff --git a/lib/folk-markdown.ts b/lib/folk-markdown.ts index 5671363..cc00c53 100644 --- a/lib/folk-markdown.ts +++ b/lib/folk-markdown.ts @@ -321,6 +321,12 @@ export class FolkMarkdown extends FolkShape { }); } + static override fromData(data: Record): 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): void { + super.applyData(data); + if ("content" in data && this.content !== data.content) { + this.content = data.content; + } + } } diff --git a/lib/folk-multisig-email.ts b/lib/folk-multisig-email.ts index aface28..606ca64 100644 --- a/lib/folk-multisig-email.ts +++ b/lib/folk-multisig-email.ts @@ -227,6 +227,39 @@ export class FolkMultisigEmail extends FolkShape { private _pollInterval: ReturnType | null = null; + static override fromData(data: Record): 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): 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"]; } diff --git a/lib/folk-obs-note.ts b/lib/folk-obs-note.ts index d3a102b..a5c596f 100644 --- a/lib/folk-obs-note.ts +++ b/lib/folk-obs-note.ts @@ -634,6 +634,13 @@ export class FolkObsNote extends FolkShape { return div.innerHTML; } + static override fromData(data: Record): 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): 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; + } } diff --git a/lib/folk-packing-list.ts b/lib/folk-packing-list.ts index 2413873..368a6d2 100644 --- a/lib/folk-packing-list.ts +++ b/lib/folk-packing-list.ts @@ -333,6 +333,17 @@ export class FolkPackingList extends FolkShape { }); } + static override fromData(data: Record): FolkPackingList { + const shape = FolkShape.fromData(data) as FolkPackingList; + if (Array.isArray(data.items)) shape.items = data.items; + return shape; + } + + override applyData(data: Record): 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(), diff --git a/lib/folk-piano.ts b/lib/folk-piano.ts index 9c0b33f..a9d2c5b 100644 --- a/lib/folk-piano.ts +++ b/lib/folk-piano.ts @@ -280,6 +280,12 @@ export class FolkPiano extends FolkShape { this.#iframe.src = PIANO_URL; } + static override fromData(data: Record): 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): void { + super.applyData(data); + if ("isMinimized" in data && this.isMinimized !== data.isMinimized) { + this.isMinimized = data.isMinimized; + } + } } diff --git a/lib/folk-prompt.ts b/lib/folk-prompt.ts index c9922f7..15c3f93 100644 --- a/lib/folk-prompt.ts +++ b/lib/folk-prompt.ts @@ -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): 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): void { + super.applyData(data); + } } diff --git a/lib/folk-rapp.ts b/lib/folk-rapp.ts index 981efb2..366bc80 100644 --- a/lib/folk-rapp.ts +++ b/lib/folk-rapp.ts @@ -1027,6 +1027,21 @@ export class FolkRApp extends FolkShape { }); } + static override fromData(data: Record): 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): 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(), diff --git a/lib/folk-shape.ts b/lib/folk-shape.ts index 068fc88..e0ebd28 100644 --- a/lib/folk-shape.ts +++ b/lib/folk-shape.ts @@ -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(); + + /** 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 { - return { + const json: Record = { 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 = {}; + 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): 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): 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); + } + } } } diff --git a/lib/folk-slide.ts b/lib/folk-slide.ts index 2043200..5aa970f 100644 --- a/lib/folk-slide.ts +++ b/lib/folk-slide.ts @@ -106,6 +106,12 @@ export class FolkSlide extends FolkShape { return root; } + static override fromData(data: Record): 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): void { + super.applyData(data); + if ("label" in data && this.label !== data.label) { + this.label = data.label; + } + } } diff --git a/lib/folk-social-post.ts b/lib/folk-social-post.ts index bfbdcbb..a217eb2 100644 --- a/lib/folk-social-post.ts +++ b/lib/folk-social-post.ts @@ -876,6 +876,33 @@ export class FolkSocialPost extends FolkShape { return div.innerHTML; } + static override fromData(data: Record): 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): 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(), diff --git a/lib/folk-spider-3d.ts b/lib/folk-spider-3d.ts index 6ddc1b6..ec7572e 100644 --- a/lib/folk-spider-3d.ts +++ b/lib/folk-spider-3d.ts @@ -648,6 +648,20 @@ export class FolkSpider3D extends FolkShape { // ── Serialization ── + static override fromData(data: Record): 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): 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; + } } diff --git a/lib/folk-splat.ts b/lib/folk-splat.ts index 79b7541..fc80ddc 100644 --- a/lib/folk-splat.ts +++ b/lib/folk-splat.ts @@ -428,6 +428,12 @@ export class FolkSplat extends FolkShape { return div.innerHTML; } + static override fromData(data: Record): 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): void { + super.applyData(data); + if ("splatUrl" in data && this.splatUrl !== data.splatUrl) { + this.splatUrl = data.splatUrl; + } + } } diff --git a/lib/folk-token-ledger.ts b/lib/folk-token-ledger.ts index 284bf49..6d93d2d 100644 --- a/lib/folk-token-ledger.ts +++ b/lib/folk-token-ledger.ts @@ -487,6 +487,19 @@ export class FolkTokenLedger extends FolkShape { } } + static override fromData(data: Record): 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): 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(), diff --git a/lib/folk-token-mint.ts b/lib/folk-token-mint.ts index c12823f..a41c282 100644 --- a/lib/folk-token-mint.ts +++ b/lib/folk-token-mint.ts @@ -385,6 +385,33 @@ export class FolkTokenMint extends FolkShape { this.#progressEl.style.background = this.#tokenColor; } + static override fromData(data: Record): 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): 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(), diff --git a/lib/folk-transcription.ts b/lib/folk-transcription.ts index 3d752bd..9a510f3 100644 --- a/lib/folk-transcription.ts +++ b/lib/folk-transcription.ts @@ -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): 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): void { + super.applyData(data); + } } diff --git a/lib/folk-video-chat.ts b/lib/folk-video-chat.ts index 539ed1b..a8123ec 100644 --- a/lib/folk-video-chat.ts +++ b/lib/folk-video-chat.ts @@ -525,6 +525,12 @@ export class FolkVideoChat extends FolkShape { return div.innerHTML; } + static override fromData(data: Record): 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): void { + super.applyData(data); + if (data.roomId !== undefined && this.roomId !== data.roomId) this.roomId = data.roomId; + } } diff --git a/lib/folk-video-gen.ts b/lib/folk-video-gen.ts index 7befdbb..207988b 100644 --- a/lib/folk-video-gen.ts +++ b/lib/folk-video-gen.ts @@ -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): 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): void { + super.applyData(data); + } } diff --git a/lib/folk-workflow-block.ts b/lib/folk-workflow-block.ts index e469213..28a9b3b 100644 --- a/lib/folk-workflow-block.ts +++ b/lib/folk-workflow-block.ts @@ -568,6 +568,25 @@ export class FolkWorkflowBlock extends FolkShape { return div.innerHTML; } + static override fromData(data: Record): 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): 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(), diff --git a/lib/folk-wrapper.ts b/lib/folk-wrapper.ts index bf41780..1163e0e 100644 --- a/lib/folk-wrapper.ts +++ b/lib/folk-wrapper.ts @@ -471,6 +471,17 @@ export class FolkWrapper extends FolkShape { this.dispatchEvent(new CustomEvent("tags-change", { detail: { tags: this.#tags } })); } + static override fromData(data: Record): 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): 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; + } } diff --git a/lib/folk-zine-gen.ts b/lib/folk-zine-gen.ts index 27acb18..5747d9d 100644 --- a/lib/folk-zine-gen.ts +++ b/lib/folk-zine-gen.ts @@ -901,6 +901,11 @@ export class FolkZineGen extends FolkShape { return div.innerHTML; } + static override fromData(data: Record): 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): void { + super.applyData(data); + } } diff --git a/lib/index.ts b/lib/index.ts index 0dd9f1f..5a4d7fa 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -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"; diff --git a/lib/shape-registry.ts b/lib/shape-registry.ts new file mode 100644 index 0000000..097e865 --- /dev/null +++ b/lib/shape-registry.ts @@ -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(); + + /** 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(); diff --git a/modules/rfiles/components/folk-file-browser.ts b/modules/rfiles/components/folk-file-browser.ts index d4a6a8b..b65db7c 100644 --- a/modules/rfiles/components/folk-file-browser.ts +++ b/modules/rfiles/components/folk-file-browser.ts @@ -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 {} } diff --git a/modules/rmaps/components/folk-map-viewer.ts b/modules/rmaps/components/folk-map-viewer.ts index f801079..c570f51 100644 --- a/modules/rmaps/components/folk-map-viewer.ts +++ b/modules/rmaps/components/folk-map-viewer.ts @@ -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, "-"); diff --git a/modules/rvote/components/folk-vote-dashboard.ts b/modules/rvote/components/folk-vote-dashboard.ts index 494269e..6a666c2 100644 --- a/modules/rvote/components/folk-vote-dashboard.ts +++ b/modules/rvote/components/folk-vote-dashboard.ts @@ -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 }), diff --git a/shared/auth-fetch.ts b/shared/auth-fetch.ts new file mode 100644 index 0000000..8375150 --- /dev/null +++ b/shared/auth-fetch.ts @@ -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 `. + * Skips Content-Type for FormData (browser sets multipart boundary). + */ +export async function authFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + 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; +} diff --git a/website/canvas.html b/website/canvas.html index 98dde36..cc94442 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -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; }