diff --git a/lib/folk-blender.ts b/lib/folk-blender.ts
new file mode 100644
index 0000000..7b8c0df
--- /dev/null
+++ b/lib/folk-blender.ts
@@ -0,0 +1,444 @@
+import { FolkShape } from "./folk-shape";
+import { css, html } from "./tags";
+
+const styles = css`
+ :host {
+ background: white;
+ border-radius: 8px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ min-width: 380px;
+ min-height: 480px;
+ }
+
+ .header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 12px;
+ background: linear-gradient(135deg, #ea580c, #f59e0b);
+ color: white;
+ border-radius: 8px 8px 0 0;
+ font-size: 12px;
+ font-weight: 600;
+ cursor: move;
+ }
+
+ .header-title {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ }
+
+ .header-actions {
+ display: flex;
+ gap: 4px;
+ }
+
+ .header-actions button {
+ background: transparent;
+ border: none;
+ color: white;
+ cursor: pointer;
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-size: 14px;
+ }
+
+ .header-actions button:hover {
+ background: rgba(255, 255, 255, 0.2);
+ }
+
+ .content {
+ display: flex;
+ flex-direction: column;
+ height: calc(100% - 36px);
+ overflow: hidden;
+ }
+
+ .prompt-area {
+ padding: 12px;
+ border-bottom: 1px solid #e2e8f0;
+ }
+
+ .prompt-input {
+ width: 100%;
+ padding: 10px 12px;
+ border: 2px solid #e2e8f0;
+ border-radius: 8px;
+ font-size: 13px;
+ resize: none;
+ outline: none;
+ font-family: inherit;
+ }
+
+ .prompt-input:focus {
+ border-color: #ea580c;
+ }
+
+ .controls {
+ display: flex;
+ gap: 8px;
+ margin-top: 8px;
+ }
+
+ .generate-btn {
+ flex: 1;
+ padding: 8px 16px;
+ background: linear-gradient(135deg, #ea580c, #f59e0b);
+ color: white;
+ border: none;
+ border-radius: 6px;
+ font-size: 13px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: opacity 0.2s;
+ }
+
+ .generate-btn:hover {
+ opacity: 0.9;
+ }
+
+ .generate-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+
+ .tabs {
+ display: flex;
+ border-bottom: 1px solid #e2e8f0;
+ }
+
+ .tab {
+ flex: 1;
+ padding: 8px;
+ text-align: center;
+ font-size: 12px;
+ font-weight: 600;
+ color: #64748b;
+ border: none;
+ background: none;
+ cursor: pointer;
+ border-bottom: 2px solid transparent;
+ transition: all 0.2s;
+ }
+
+ .tab:hover {
+ color: #ea580c;
+ }
+
+ .tab.active {
+ color: #ea580c;
+ border-bottom-color: #ea580c;
+ }
+
+ .preview-area {
+ flex: 1;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ }
+
+ .render-preview {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 12px;
+ overflow: hidden;
+ }
+
+ .render-preview img {
+ max-width: 100%;
+ max-height: 100%;
+ border-radius: 8px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ }
+
+ .code-area {
+ flex: 1;
+ overflow: auto;
+ padding: 12px;
+ }
+
+ .code-area pre {
+ margin: 0;
+ padding: 12px;
+ background: #1e293b;
+ color: #e2e8f0;
+ border-radius: 6px;
+ font-size: 11px;
+ line-height: 1.5;
+ overflow-x: auto;
+ white-space: pre-wrap;
+ word-break: break-all;
+ }
+
+ .download-row {
+ display: flex;
+ gap: 8px;
+ padding: 8px 12px;
+ border-top: 1px solid #e2e8f0;
+ }
+
+ .download-btn {
+ padding: 6px 12px;
+ background: #334155;
+ color: white;
+ border: none;
+ border-radius: 6px;
+ font-size: 12px;
+ cursor: pointer;
+ text-decoration: none;
+ }
+
+ .download-btn:hover {
+ background: #475569;
+ }
+
+ .placeholder {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ color: #94a3b8;
+ text-align: center;
+ gap: 8px;
+ }
+
+ .placeholder-icon {
+ font-size: 48px;
+ opacity: 0.5;
+ }
+
+ .loading {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 24px;
+ gap: 12px;
+ }
+
+ .spinner {
+ width: 32px;
+ height: 32px;
+ border: 3px solid #e2e8f0;
+ border-top-color: #ea580c;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+ }
+
+ @keyframes spin {
+ to { transform: rotate(360deg); }
+ }
+
+ .error {
+ color: #ef4444;
+ padding: 12px;
+ background: #fef2f2;
+ border-radius: 6px;
+ font-size: 13px;
+ margin: 12px;
+ }
+`;
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "folk-blender": FolkBlender;
+ }
+}
+
+export class FolkBlender extends FolkShape {
+ static override tagName = "folk-blender";
+
+ static {
+ const sheet = new CSSStyleSheet();
+ const parentRules = Array.from(FolkShape.styles.cssRules)
+ .map((r) => r.cssText)
+ .join("\n");
+ const childRules = Array.from(styles.cssRules)
+ .map((r) => r.cssText)
+ .join("\n");
+ sheet.replaceSync(`${parentRules}\n${childRules}`);
+ this.styles = sheet;
+ }
+
+ #isLoading = false;
+ #error: string | null = null;
+ #renderUrl: string | null = null;
+ #script: string | null = null;
+ #blendUrl: string | null = null;
+ #activeTab: "preview" | "code" = "preview";
+ #promptInput: HTMLTextAreaElement | null = null;
+ #generateBtn: HTMLButtonElement | null = null;
+ #previewArea: HTMLElement | null = null;
+ #codeArea: HTMLElement | null = null;
+ #downloadRow: HTMLElement | null = null;
+
+ override createRenderRoot() {
+ const root = super.createRenderRoot();
+
+ const wrapper = document.createElement("div");
+ wrapper.innerHTML = html`
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ \u{1F9CA}
+ Describe a 3D scene and click Generate
+
+
+
+
// Blender Python script will appear here
+
+
+
+
+ `;
+
+ const slot = root.querySelector("slot");
+ const containerDiv = slot?.parentElement as HTMLElement;
+ if (containerDiv) {
+ containerDiv.replaceWith(wrapper);
+ }
+
+ this.#promptInput = wrapper.querySelector(".prompt-input");
+ this.#generateBtn = wrapper.querySelector(".generate-btn");
+ this.#previewArea = wrapper.querySelector(".render-preview");
+ this.#codeArea = wrapper.querySelector(".code-area");
+ this.#downloadRow = wrapper.querySelector(".download-row");
+ const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
+
+ // Tab switching
+ wrapper.querySelectorAll(".tab").forEach((tab) => {
+ tab.addEventListener("click", (e) => {
+ e.stopPropagation();
+ const tabName = (tab as HTMLElement).dataset.tab as "preview" | "code";
+ this.#activeTab = tabName;
+ wrapper.querySelectorAll(".tab").forEach((t) => t.classList.remove("active"));
+ tab.classList.add("active");
+ if (this.#previewArea) this.#previewArea.style.display = tabName === "preview" ? "flex" : "none";
+ if (this.#codeArea) this.#codeArea.style.display = tabName === "code" ? "block" : "none";
+ });
+ });
+
+ this.#generateBtn?.addEventListener("click", (e) => {
+ e.stopPropagation();
+ this.#generate();
+ });
+
+ this.#promptInput?.addEventListener("keydown", (e) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ this.#generate();
+ }
+ });
+ this.#promptInput?.addEventListener("pointerdown", (e) => e.stopPropagation());
+
+ closeBtn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ this.dispatchEvent(new CustomEvent("close"));
+ });
+
+ return root;
+ }
+
+ async #generate() {
+ const prompt = this.#promptInput?.value.trim();
+ if (!prompt || this.#isLoading) return;
+
+ this.#isLoading = true;
+ this.#error = null;
+ if (this.#generateBtn) this.#generateBtn.disabled = true;
+
+ if (this.#previewArea) {
+ this.#previewArea.innerHTML = '';
+ }
+
+ try {
+ const res = await fetch("/api/blender-gen", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ prompt }),
+ });
+
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({ error: res.statusText }));
+ throw new Error(err.error || "Generation failed");
+ }
+
+ const data = await res.json();
+ this.#renderUrl = data.render_url || null;
+ this.#script = data.script || null;
+ this.#blendUrl = data.blend_url || null;
+
+ this.#renderResult();
+ } catch (err) {
+ this.#error = err instanceof Error ? err.message : "Generation failed";
+ if (this.#previewArea) {
+ this.#previewArea.innerHTML = `${this.#escapeHtml(this.#error)}
`;
+ }
+ } finally {
+ this.#isLoading = false;
+ if (this.#generateBtn) this.#generateBtn.disabled = false;
+ }
+ }
+
+ #renderResult() {
+ if (this.#previewArea) {
+ if (this.#renderUrl) {
+ this.#previewArea.innerHTML = `
`;
+ } else {
+ this.#previewArea.innerHTML = '\u2705Script generated (see Script tab)
';
+ }
+ }
+
+ if (this.#codeArea && this.#script) {
+ this.#codeArea.innerHTML = `${this.#escapeHtml(this.#script)}`;
+ }
+
+ if (this.#downloadRow && this.#blendUrl) {
+ this.#downloadRow.style.display = "flex";
+ const blendLink = this.#downloadRow.querySelector(".blend-dl") as HTMLAnchorElement;
+ if (blendLink) blendLink.href = this.#blendUrl;
+ }
+ }
+
+ #escapeHtml(text: string): string {
+ const div = document.createElement("div");
+ div.textContent = text;
+ return div.innerHTML;
+ }
+
+ override toJSON() {
+ return {
+ ...super.toJSON(),
+ type: "folk-blender",
+ renderUrl: this.#renderUrl,
+ script: this.#script,
+ blendUrl: this.#blendUrl,
+ };
+ }
+}
diff --git a/lib/folk-drawfast.ts b/lib/folk-drawfast.ts
new file mode 100644
index 0000000..f621992
--- /dev/null
+++ b/lib/folk-drawfast.ts
@@ -0,0 +1,456 @@
+import { FolkShape } from "./folk-shape";
+import { css, html } from "./tags";
+
+const styles = css`
+ :host {
+ background: white;
+ border-radius: 8px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ min-width: 360px;
+ min-height: 420px;
+ }
+
+ .header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 12px;
+ background: linear-gradient(135deg, #f97316, #eab308);
+ color: white;
+ border-radius: 8px 8px 0 0;
+ font-size: 12px;
+ font-weight: 600;
+ cursor: move;
+ }
+
+ .header-title {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ }
+
+ .header-actions {
+ display: flex;
+ gap: 4px;
+ }
+
+ .header-actions button {
+ background: transparent;
+ border: none;
+ color: white;
+ cursor: pointer;
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-size: 14px;
+ }
+
+ .header-actions button:hover {
+ background: rgba(255, 255, 255, 0.2);
+ }
+
+ .content {
+ display: flex;
+ flex-direction: column;
+ height: calc(100% - 36px);
+ overflow: hidden;
+ }
+
+ .toolbar-row {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 12px;
+ border-bottom: 1px solid #e2e8f0;
+ flex-wrap: wrap;
+ }
+
+ .tool-btn {
+ width: 32px;
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 2px solid #e2e8f0;
+ border-radius: 6px;
+ background: white;
+ cursor: pointer;
+ font-size: 14px;
+ transition: all 0.15s;
+ }
+
+ .tool-btn:hover {
+ border-color: #f97316;
+ }
+
+ .tool-btn.active {
+ border-color: #f97316;
+ background: #fff7ed;
+ }
+
+ .color-swatch {
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ border: 2px solid #e2e8f0;
+ cursor: pointer;
+ transition: transform 0.1s;
+ }
+
+ .color-swatch:hover {
+ transform: scale(1.2);
+ }
+
+ .color-swatch.active {
+ border-color: #0f172a;
+ transform: scale(1.15);
+ }
+
+ .size-slider {
+ width: 80px;
+ accent-color: #f97316;
+ }
+
+ .size-label {
+ font-size: 11px;
+ color: #64748b;
+ min-width: 20px;
+ }
+
+ .canvas-area {
+ flex: 1;
+ position: relative;
+ cursor: crosshair;
+ overflow: hidden;
+ }
+
+ .canvas-area canvas {
+ width: 100%;
+ height: 100%;
+ display: block;
+ }
+
+ .export-btn {
+ padding: 6px 12px;
+ background: #f97316;
+ color: white;
+ border: none;
+ border-radius: 6px;
+ font-size: 12px;
+ font-weight: 600;
+ cursor: pointer;
+ }
+
+ .export-btn:hover {
+ opacity: 0.9;
+ }
+`;
+
+const COLORS = ["#0f172a", "#ef4444", "#f97316", "#eab308", "#22c55e", "#3b82f6", "#8b5cf6", "#ec4899", "#ffffff"];
+
+interface Stroke {
+ points: { x: number; y: number; pressure: number }[];
+ color: string;
+ size: number;
+ tool: string;
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "folk-drawfast": FolkDrawfast;
+ }
+}
+
+export class FolkDrawfast extends FolkShape {
+ static override tagName = "folk-drawfast";
+
+ static {
+ const sheet = new CSSStyleSheet();
+ const parentRules = Array.from(FolkShape.styles.cssRules)
+ .map((r) => r.cssText)
+ .join("\n");
+ const childRules = Array.from(styles.cssRules)
+ .map((r) => r.cssText)
+ .join("\n");
+ sheet.replaceSync(`${parentRules}\n${childRules}`);
+ this.styles = sheet;
+ }
+
+ #strokes: Stroke[] = [];
+ #currentStroke: Stroke | null = null;
+ #canvas: HTMLCanvasElement | null = null;
+ #ctx: CanvasRenderingContext2D | null = null;
+ #color = "#0f172a";
+ #brushSize = 4;
+ #tool = "pen"; // pen | eraser
+ #isDrawing = false;
+
+ get strokes() {
+ return this.#strokes;
+ }
+
+ override createRenderRoot() {
+ const root = super.createRenderRoot();
+
+ const wrapper = document.createElement("div");
+ wrapper.innerHTML = html`
+
+
+
+
+
+
+ ${COLORS.map((c) => ``).join("")}
+
+
+ 4
+
+
+
+
+
+
+
+ `;
+
+ const slot = root.querySelector("slot");
+ const containerDiv = slot?.parentElement as HTMLElement;
+ if (containerDiv) {
+ containerDiv.replaceWith(wrapper);
+ }
+
+ this.#canvas = wrapper.querySelector(".canvas-area canvas") as HTMLCanvasElement;
+ this.#ctx = this.#canvas.getContext("2d");
+
+ const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
+ const exportBtn = wrapper.querySelector(".export-png-btn") as HTMLButtonElement;
+ const sizeSlider = wrapper.querySelector(".size-slider") as HTMLInputElement;
+ const sizeLabel = wrapper.querySelector(".size-label") as HTMLElement;
+ const canvasArea = wrapper.querySelector(".canvas-area") as HTMLElement;
+
+ // Tool buttons
+ wrapper.querySelectorAll(".tool-btn").forEach((btn) => {
+ btn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ const tool = (btn as HTMLElement).dataset.tool;
+ if (tool === "clear") {
+ this.#strokes = [];
+ this.#redraw();
+ return;
+ }
+ this.#tool = tool || "pen";
+ wrapper.querySelectorAll(".tool-btn").forEach((b) => b.classList.remove("active"));
+ if (tool !== "clear") btn.classList.add("active");
+ });
+ });
+
+ // Color swatches
+ wrapper.querySelectorAll(".color-swatch").forEach((swatch) => {
+ swatch.addEventListener("click", (e) => {
+ e.stopPropagation();
+ this.#color = (swatch as HTMLElement).dataset.color || "#0f172a";
+ wrapper.querySelectorAll(".color-swatch").forEach((s) => s.classList.remove("active"));
+ swatch.classList.add("active");
+ // Switch to pen when picking a color
+ this.#tool = "pen";
+ wrapper.querySelectorAll(".tool-btn").forEach((b) => b.classList.remove("active"));
+ wrapper.querySelector('[data-tool="pen"]')?.classList.add("active");
+ });
+ });
+
+ // Size slider
+ sizeSlider.addEventListener("input", (e) => {
+ e.stopPropagation();
+ this.#brushSize = parseInt(sizeSlider.value);
+ sizeLabel.textContent = sizeSlider.value;
+ });
+ sizeSlider.addEventListener("pointerdown", (e) => e.stopPropagation());
+
+ // Drawing events
+ this.#canvas.addEventListener("pointerdown", (e) => {
+ e.stopPropagation();
+ e.preventDefault();
+ this.#isDrawing = true;
+ this.#canvas!.setPointerCapture(e.pointerId);
+ const pos = this.#getCanvasPos(e);
+ this.#currentStroke = {
+ points: [{ ...pos, pressure: e.pressure || 0.5 }],
+ color: this.#tool === "eraser" ? "#ffffff" : this.#color,
+ size: this.#tool === "eraser" ? this.#brushSize * 3 : this.#brushSize,
+ tool: this.#tool,
+ };
+ });
+
+ this.#canvas.addEventListener("pointermove", (e) => {
+ if (!this.#isDrawing || !this.#currentStroke) return;
+ e.stopPropagation();
+ const pos = this.#getCanvasPos(e);
+ this.#currentStroke.points.push({ ...pos, pressure: e.pressure || 0.5 });
+ this.#drawStroke(this.#currentStroke);
+ });
+
+ const endDraw = (e: PointerEvent) => {
+ if (!this.#isDrawing) return;
+ this.#isDrawing = false;
+ if (this.#currentStroke && this.#currentStroke.points.length > 0) {
+ this.#strokes.push(this.#currentStroke);
+ this.dispatchEvent(new CustomEvent("stroke-complete", {
+ detail: { stroke: this.#currentStroke },
+ }));
+ }
+ this.#currentStroke = null;
+ };
+
+ this.#canvas.addEventListener("pointerup", endDraw);
+ this.#canvas.addEventListener("pointerleave", endDraw);
+
+ // Export
+ exportBtn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ this.#exportPNG();
+ });
+
+ closeBtn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ this.dispatchEvent(new CustomEvent("close"));
+ });
+
+ // Size canvas on next frame
+ requestAnimationFrame(() => {
+ this.#resizeCanvas(canvasArea);
+ this.#redraw();
+ });
+
+ // Watch for resize
+ const ro = new ResizeObserver(() => {
+ this.#resizeCanvas(canvasArea);
+ this.#redraw();
+ });
+ ro.observe(canvasArea);
+
+ return root;
+ }
+
+ #resizeCanvas(container: HTMLElement) {
+ if (!this.#canvas) return;
+ const rect = container.getBoundingClientRect();
+ if (rect.width > 0 && rect.height > 0) {
+ this.#canvas.width = rect.width * devicePixelRatio;
+ this.#canvas.height = rect.height * devicePixelRatio;
+ this.#canvas.style.width = rect.width + "px";
+ this.#canvas.style.height = rect.height + "px";
+ if (this.#ctx) {
+ this.#ctx.scale(devicePixelRatio, devicePixelRatio);
+ }
+ }
+ }
+
+ #getCanvasPos(e: PointerEvent): { x: number; y: number } {
+ const rect = this.#canvas!.getBoundingClientRect();
+ return {
+ x: e.clientX - rect.left,
+ y: e.clientY - rect.top,
+ };
+ }
+
+ #drawStroke(stroke: Stroke) {
+ if (!this.#ctx || stroke.points.length < 2) return;
+ const ctx = this.#ctx;
+
+ ctx.lineCap = "round";
+ ctx.lineJoin = "round";
+ ctx.strokeStyle = stroke.color;
+
+ // Draw last segment for live drawing
+ const len = stroke.points.length;
+ const p0 = stroke.points[len - 2];
+ const p1 = stroke.points[len - 1];
+ const pressure = (p0.pressure + p1.pressure) / 2;
+
+ ctx.lineWidth = stroke.size * (0.5 + pressure);
+ if (stroke.tool === "eraser") {
+ ctx.globalCompositeOperation = "destination-out";
+ ctx.lineWidth = stroke.size * 3;
+ } else {
+ ctx.globalCompositeOperation = "source-over";
+ }
+
+ ctx.beginPath();
+ ctx.moveTo(p0.x, p0.y);
+ ctx.lineTo(p1.x, p1.y);
+ ctx.stroke();
+
+ ctx.globalCompositeOperation = "source-over";
+ }
+
+ #redraw() {
+ if (!this.#ctx || !this.#canvas) return;
+ const ctx = this.#ctx;
+ const w = this.#canvas.width / devicePixelRatio;
+ const h = this.#canvas.height / devicePixelRatio;
+
+ ctx.clearRect(0, 0, w, h);
+
+ // Fill white background
+ ctx.fillStyle = "#ffffff";
+ ctx.fillRect(0, 0, w, h);
+
+ for (const stroke of this.#strokes) {
+ if (stroke.points.length < 2) continue;
+
+ ctx.lineCap = "round";
+ ctx.lineJoin = "round";
+ ctx.strokeStyle = stroke.color;
+
+ if (stroke.tool === "eraser") {
+ ctx.globalCompositeOperation = "destination-out";
+ } else {
+ ctx.globalCompositeOperation = "source-over";
+ }
+
+ for (let i = 1; i < stroke.points.length; i++) {
+ const p0 = stroke.points[i - 1];
+ const p1 = stroke.points[i];
+ const pressure = (p0.pressure + p1.pressure) / 2;
+ ctx.lineWidth = stroke.size * (0.5 + pressure);
+
+ ctx.beginPath();
+ ctx.moveTo(p0.x, p0.y);
+ ctx.lineTo(p1.x, p1.y);
+ ctx.stroke();
+ }
+ }
+
+ ctx.globalCompositeOperation = "source-over";
+ }
+
+ #exportPNG() {
+ if (!this.#canvas) return;
+ const dataUrl = this.#canvas.toDataURL("image/png");
+ const link = document.createElement("a");
+ link.download = `drawfast-${Date.now()}.png`;
+ link.href = dataUrl;
+ link.click();
+ }
+
+ override toJSON() {
+ return {
+ ...super.toJSON(),
+ type: "folk-drawfast",
+ strokes: this.#strokes.map((s) => ({
+ points: s.points,
+ color: s.color,
+ size: s.size,
+ tool: s.tool,
+ })),
+ };
+ }
+}
diff --git a/lib/folk-freecad.ts b/lib/folk-freecad.ts
new file mode 100644
index 0000000..491947b
--- /dev/null
+++ b/lib/folk-freecad.ts
@@ -0,0 +1,378 @@
+import { FolkShape } from "./folk-shape";
+import { css, html } from "./tags";
+
+const styles = css`
+ :host {
+ background: white;
+ border-radius: 8px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ min-width: 360px;
+ min-height: 440px;
+ }
+
+ .header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 12px;
+ background: linear-gradient(135deg, #0891b2, #06b6d4);
+ color: white;
+ border-radius: 8px 8px 0 0;
+ font-size: 12px;
+ font-weight: 600;
+ cursor: move;
+ }
+
+ .header-title {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ }
+
+ .header-actions {
+ display: flex;
+ gap: 4px;
+ }
+
+ .header-actions button {
+ background: transparent;
+ border: none;
+ color: white;
+ cursor: pointer;
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-size: 14px;
+ }
+
+ .header-actions button:hover {
+ background: rgba(255, 255, 255, 0.2);
+ }
+
+ .content {
+ display: flex;
+ flex-direction: column;
+ height: calc(100% - 36px);
+ overflow: hidden;
+ }
+
+ .prompt-area {
+ padding: 12px;
+ border-bottom: 1px solid #e2e8f0;
+ }
+
+ .prompt-input {
+ width: 100%;
+ padding: 10px 12px;
+ border: 2px solid #e2e8f0;
+ border-radius: 8px;
+ font-size: 13px;
+ resize: none;
+ outline: none;
+ font-family: inherit;
+ }
+
+ .prompt-input:focus {
+ border-color: #0891b2;
+ }
+
+ .controls {
+ display: flex;
+ gap: 8px;
+ margin-top: 8px;
+ }
+
+ .generate-btn {
+ flex: 1;
+ padding: 8px 16px;
+ background: linear-gradient(135deg, #0891b2, #06b6d4);
+ color: white;
+ border: none;
+ border-radius: 6px;
+ font-size: 13px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: opacity 0.2s;
+ }
+
+ .generate-btn:hover {
+ opacity: 0.9;
+ }
+
+ .generate-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+
+ .preview-area {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 12px;
+ overflow: hidden;
+ }
+
+ .preview-area img {
+ max-width: 100%;
+ max-height: 100%;
+ border-radius: 8px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ }
+
+ .export-row {
+ display: flex;
+ gap: 8px;
+ padding: 8px 12px;
+ border-top: 1px solid #e2e8f0;
+ }
+
+ .export-btn {
+ padding: 6px 12px;
+ background: #334155;
+ color: white;
+ border: none;
+ border-radius: 6px;
+ font-size: 12px;
+ cursor: pointer;
+ text-decoration: none;
+ }
+
+ .export-btn:hover {
+ background: #475569;
+ }
+
+ .export-btn.primary {
+ background: #0891b2;
+ }
+
+ .export-btn.primary:hover {
+ background: #0e7490;
+ }
+
+ .placeholder {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ color: #94a3b8;
+ text-align: center;
+ gap: 8px;
+ }
+
+ .placeholder-icon {
+ font-size: 48px;
+ opacity: 0.5;
+ }
+
+ .loading {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 24px;
+ gap: 12px;
+ }
+
+ .spinner {
+ width: 32px;
+ height: 32px;
+ border: 3px solid #e2e8f0;
+ border-top-color: #0891b2;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+ }
+
+ @keyframes spin {
+ to { transform: rotate(360deg); }
+ }
+
+ .error {
+ color: #ef4444;
+ padding: 12px;
+ background: #fef2f2;
+ border-radius: 6px;
+ font-size: 13px;
+ margin: 12px;
+ }
+`;
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "folk-freecad": FolkFreeCAD;
+ }
+}
+
+export class FolkFreeCAD extends FolkShape {
+ static override tagName = "folk-freecad";
+
+ static {
+ const sheet = new CSSStyleSheet();
+ const parentRules = Array.from(FolkShape.styles.cssRules)
+ .map((r) => r.cssText)
+ .join("\n");
+ const childRules = Array.from(styles.cssRules)
+ .map((r) => r.cssText)
+ .join("\n");
+ sheet.replaceSync(`${parentRules}\n${childRules}`);
+ this.styles = sheet;
+ }
+
+ #isLoading = false;
+ #error: string | null = null;
+ #previewUrl: string | null = null;
+ #stepUrl: string | null = null;
+ #stlUrl: string | null = null;
+ #promptInput: HTMLTextAreaElement | null = null;
+ #generateBtn: HTMLButtonElement | null = null;
+ #previewArea: HTMLElement | null = null;
+ #exportRow: HTMLElement | null = null;
+
+ override createRenderRoot() {
+ const root = super.createRenderRoot();
+
+ const wrapper = document.createElement("div");
+ wrapper.innerHTML = html`
+
+
+
+
+
+
+
+
+
+
+ \u{1F4D0}
+ Describe a part and click Generate
+
+
+
+
+ `;
+
+ const slot = root.querySelector("slot");
+ const containerDiv = slot?.parentElement as HTMLElement;
+ if (containerDiv) {
+ containerDiv.replaceWith(wrapper);
+ }
+
+ this.#promptInput = wrapper.querySelector(".prompt-input");
+ this.#generateBtn = wrapper.querySelector(".generate-btn");
+ this.#previewArea = wrapper.querySelector(".preview-area");
+ this.#exportRow = wrapper.querySelector(".export-row");
+ const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
+
+ this.#generateBtn?.addEventListener("click", (e) => {
+ e.stopPropagation();
+ this.#generate();
+ });
+
+ this.#promptInput?.addEventListener("keydown", (e) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ this.#generate();
+ }
+ });
+ this.#promptInput?.addEventListener("pointerdown", (e) => e.stopPropagation());
+
+ closeBtn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ this.dispatchEvent(new CustomEvent("close"));
+ });
+
+ return root;
+ }
+
+ async #generate() {
+ const prompt = this.#promptInput?.value.trim();
+ if (!prompt || this.#isLoading) return;
+
+ this.#isLoading = true;
+ this.#error = null;
+ if (this.#generateBtn) this.#generateBtn.disabled = true;
+
+ if (this.#previewArea) {
+ this.#previewArea.innerHTML = '';
+ }
+
+ try {
+ const res = await fetch("/api/freecad/generate", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ prompt }),
+ });
+
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({ error: res.statusText }));
+ throw new Error(err.error || "Generation failed");
+ }
+
+ const data = await res.json();
+ this.#previewUrl = data.preview_url || null;
+ this.#stepUrl = data.step_url || null;
+ this.#stlUrl = data.stl_url || null;
+
+ this.#renderResult();
+ } catch (err) {
+ this.#error = err instanceof Error ? err.message : "Generation failed";
+ if (this.#previewArea) {
+ this.#previewArea.innerHTML = `${this.#escapeHtml(this.#error)}
`;
+ }
+ } finally {
+ this.#isLoading = false;
+ if (this.#generateBtn) this.#generateBtn.disabled = false;
+ }
+ }
+
+ #renderResult() {
+ if (this.#previewArea) {
+ if (this.#previewUrl) {
+ this.#previewArea.innerHTML = `
`;
+ } else {
+ this.#previewArea.innerHTML = '\u2705Model generated! Download files below.
';
+ }
+ }
+
+ if (this.#exportRow && (this.#stepUrl || this.#stlUrl)) {
+ this.#exportRow.style.display = "flex";
+ const stepLink = this.#exportRow.querySelector(".step-dl") as HTMLAnchorElement;
+ const stlLink = this.#exportRow.querySelector(".stl-dl") as HTMLAnchorElement;
+ if (stepLink && this.#stepUrl) {
+ stepLink.href = this.#stepUrl;
+ stepLink.style.display = "";
+ }
+ if (stlLink && this.#stlUrl) {
+ stlLink.href = this.#stlUrl;
+ stlLink.style.display = "";
+ }
+ }
+ }
+
+ #escapeHtml(text: string): string {
+ const div = document.createElement("div");
+ div.textContent = text;
+ return div.innerHTML;
+ }
+
+ override toJSON() {
+ return {
+ ...super.toJSON(),
+ type: "folk-freecad",
+ previewUrl: this.#previewUrl,
+ stepUrl: this.#stepUrl,
+ stlUrl: this.#stlUrl,
+ };
+ }
+}
diff --git a/lib/folk-kicad.ts b/lib/folk-kicad.ts
new file mode 100644
index 0000000..74ed220
--- /dev/null
+++ b/lib/folk-kicad.ts
@@ -0,0 +1,496 @@
+import { FolkShape } from "./folk-shape";
+import { css, html } from "./tags";
+
+const styles = css`
+ :host {
+ background: white;
+ border-radius: 8px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ min-width: 380px;
+ min-height: 460px;
+ }
+
+ .header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 12px;
+ background: linear-gradient(135deg, #059669, #34d399);
+ color: white;
+ border-radius: 8px 8px 0 0;
+ font-size: 12px;
+ font-weight: 600;
+ cursor: move;
+ }
+
+ .header-title {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ }
+
+ .header-actions {
+ display: flex;
+ gap: 4px;
+ }
+
+ .header-actions button {
+ background: transparent;
+ border: none;
+ color: white;
+ cursor: pointer;
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-size: 14px;
+ }
+
+ .header-actions button:hover {
+ background: rgba(255, 255, 255, 0.2);
+ }
+
+ .content {
+ display: flex;
+ flex-direction: column;
+ height: calc(100% - 36px);
+ overflow: hidden;
+ }
+
+ .prompt-area {
+ padding: 12px;
+ border-bottom: 1px solid #e2e8f0;
+ }
+
+ .prompt-input {
+ width: 100%;
+ padding: 10px 12px;
+ border: 2px solid #e2e8f0;
+ border-radius: 8px;
+ font-size: 13px;
+ resize: none;
+ outline: none;
+ font-family: inherit;
+ }
+
+ .prompt-input:focus {
+ border-color: #059669;
+ }
+
+ .component-input {
+ width: 100%;
+ padding: 8px 10px;
+ border: 2px solid #e2e8f0;
+ border-radius: 6px;
+ font-size: 12px;
+ outline: none;
+ margin-top: 8px;
+ font-family: inherit;
+ }
+
+ .component-input:focus {
+ border-color: #059669;
+ }
+
+ .controls {
+ display: flex;
+ gap: 8px;
+ margin-top: 8px;
+ }
+
+ .generate-btn {
+ flex: 1;
+ padding: 8px 16px;
+ background: linear-gradient(135deg, #059669, #34d399);
+ color: white;
+ border: none;
+ border-radius: 6px;
+ font-size: 13px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: opacity 0.2s;
+ }
+
+ .generate-btn:hover {
+ opacity: 0.9;
+ }
+
+ .generate-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+
+ .tabs {
+ display: flex;
+ border-bottom: 1px solid #e2e8f0;
+ }
+
+ .tab {
+ flex: 1;
+ padding: 8px;
+ text-align: center;
+ font-size: 12px;
+ font-weight: 600;
+ color: #64748b;
+ border: none;
+ background: none;
+ cursor: pointer;
+ border-bottom: 2px solid transparent;
+ transition: all 0.2s;
+ }
+
+ .tab:hover {
+ color: #059669;
+ }
+
+ .tab.active {
+ color: #059669;
+ border-bottom-color: #059669;
+ }
+
+ .preview-area {
+ flex: 1;
+ overflow: auto;
+ padding: 12px;
+ }
+
+ .preview-area img {
+ max-width: 100%;
+ border-radius: 8px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ }
+
+ .drc-results {
+ font-size: 12px;
+ color: #334155;
+ }
+
+ .drc-results .pass {
+ color: #059669;
+ font-weight: 600;
+ }
+
+ .drc-results .fail {
+ color: #ef4444;
+ font-weight: 600;
+ }
+
+ .export-row {
+ display: flex;
+ gap: 8px;
+ padding: 8px 12px;
+ border-top: 1px solid #e2e8f0;
+ flex-wrap: wrap;
+ }
+
+ .export-btn {
+ padding: 6px 12px;
+ background: #334155;
+ color: white;
+ border: none;
+ border-radius: 6px;
+ font-size: 12px;
+ cursor: pointer;
+ text-decoration: none;
+ }
+
+ .export-btn:hover {
+ background: #475569;
+ }
+
+ .export-btn.primary {
+ background: #059669;
+ }
+
+ .export-btn.primary:hover {
+ background: #047857;
+ }
+
+ .placeholder {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ color: #94a3b8;
+ text-align: center;
+ gap: 8px;
+ }
+
+ .placeholder-icon {
+ font-size: 48px;
+ opacity: 0.5;
+ }
+
+ .loading {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 24px;
+ gap: 12px;
+ }
+
+ .spinner {
+ width: 32px;
+ height: 32px;
+ border: 3px solid #e2e8f0;
+ border-top-color: #059669;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+ }
+
+ @keyframes spin {
+ to { transform: rotate(360deg); }
+ }
+
+ .error {
+ color: #ef4444;
+ padding: 12px;
+ background: #fef2f2;
+ border-radius: 6px;
+ font-size: 13px;
+ }
+`;
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "folk-kicad": FolkKiCAD;
+ }
+}
+
+export class FolkKiCAD extends FolkShape {
+ static override tagName = "folk-kicad";
+
+ static {
+ const sheet = new CSSStyleSheet();
+ const parentRules = Array.from(FolkShape.styles.cssRules)
+ .map((r) => r.cssText)
+ .join("\n");
+ const childRules = Array.from(styles.cssRules)
+ .map((r) => r.cssText)
+ .join("\n");
+ sheet.replaceSync(`${parentRules}\n${childRules}`);
+ this.styles = sheet;
+ }
+
+ #isLoading = false;
+ #error: string | null = null;
+ #schematicSvg: string | null = null;
+ #boardSvg: string | null = null;
+ #gerberUrl: string | null = null;
+ #bomUrl: string | null = null;
+ #pdfUrl: string | null = null;
+ #drcResults: any = null;
+ #activeTab: "schematic" | "board" | "drc" = "schematic";
+ #promptInput: HTMLTextAreaElement | null = null;
+ #componentInput: HTMLInputElement | null = null;
+ #generateBtn: HTMLButtonElement | null = null;
+ #previewArea: HTMLElement | null = null;
+ #exportRow: HTMLElement | null = null;
+
+ override createRenderRoot() {
+ const root = super.createRenderRoot();
+
+ const wrapper = document.createElement("div");
+ wrapper.innerHTML = html`
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ \u{1F50C}
+ Describe a PCB design and click Generate
+
+
+
+
+ `;
+
+ const slot = root.querySelector("slot");
+ const containerDiv = slot?.parentElement as HTMLElement;
+ if (containerDiv) {
+ containerDiv.replaceWith(wrapper);
+ }
+
+ this.#promptInput = wrapper.querySelector(".prompt-input");
+ this.#componentInput = wrapper.querySelector(".component-input");
+ this.#generateBtn = wrapper.querySelector(".generate-btn");
+ this.#previewArea = wrapper.querySelector(".preview-area");
+ this.#exportRow = wrapper.querySelector(".export-row");
+ const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
+
+ // Tab switching
+ wrapper.querySelectorAll(".tab").forEach((tab) => {
+ tab.addEventListener("click", (e) => {
+ e.stopPropagation();
+ const tabName = (tab as HTMLElement).dataset.tab as "schematic" | "board" | "drc";
+ this.#activeTab = tabName;
+ wrapper.querySelectorAll(".tab").forEach((t) => t.classList.remove("active"));
+ tab.classList.add("active");
+ this.#renderPreview();
+ });
+ });
+
+ this.#generateBtn?.addEventListener("click", (e) => {
+ e.stopPropagation();
+ this.#generate();
+ });
+
+ this.#promptInput?.addEventListener("keydown", (e) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ this.#generate();
+ }
+ });
+ this.#promptInput?.addEventListener("pointerdown", (e) => e.stopPropagation());
+ this.#componentInput?.addEventListener("pointerdown", (e) => e.stopPropagation());
+
+ closeBtn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ this.dispatchEvent(new CustomEvent("close"));
+ });
+
+ return root;
+ }
+
+ async #generate() {
+ const prompt = this.#promptInput?.value.trim();
+ if (!prompt || this.#isLoading) return;
+
+ const components = this.#componentInput?.value
+ .split(",")
+ .map((c) => c.trim())
+ .filter(Boolean) || [];
+
+ this.#isLoading = true;
+ this.#error = null;
+ if (this.#generateBtn) this.#generateBtn.disabled = true;
+
+ if (this.#previewArea) {
+ this.#previewArea.innerHTML = '';
+ }
+
+ try {
+ // Step 1: Create project
+ const createRes = await fetch("/api/kicad/create_project", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ prompt, components }),
+ });
+
+ if (!createRes.ok) {
+ const err = await createRes.json().catch(() => ({ error: createRes.statusText }));
+ throw new Error(err.error || "PCB generation failed");
+ }
+
+ const data = await createRes.json();
+ this.#schematicSvg = data.schematic_svg || null;
+ this.#boardSvg = data.board_svg || null;
+ this.#gerberUrl = data.gerber_url || null;
+ this.#bomUrl = data.bom_url || null;
+ this.#pdfUrl = data.pdf_url || null;
+ this.#drcResults = data.drc || null;
+
+ this.#renderPreview();
+ this.#showExports();
+ } catch (err) {
+ this.#error = err instanceof Error ? err.message : "Generation failed";
+ if (this.#previewArea) {
+ this.#previewArea.innerHTML = `${this.#escapeHtml(this.#error)}
`;
+ }
+ } finally {
+ this.#isLoading = false;
+ if (this.#generateBtn) this.#generateBtn.disabled = false;
+ }
+ }
+
+ #renderPreview() {
+ if (!this.#previewArea) return;
+
+ switch (this.#activeTab) {
+ case "schematic":
+ if (this.#schematicSvg) {
+ this.#previewArea.innerHTML = `
`;
+ } else {
+ this.#previewArea.innerHTML = '\u{1F4CB}Schematic will appear here
';
+ }
+ break;
+ case "board":
+ if (this.#boardSvg) {
+ this.#previewArea.innerHTML = `
`;
+ } else {
+ this.#previewArea.innerHTML = '\u{1F4DF}Board layout will appear here
';
+ }
+ break;
+ case "drc":
+ if (this.#drcResults) {
+ const violations = this.#drcResults.violations || [];
+ const passed = violations.length === 0;
+ this.#previewArea.innerHTML = `
+
+
${passed
+ ? '\u2705 DRC Passed'
+ : `\u274C ${violations.length} Violation(s)`
+ }
+ ${violations.map((v: any) => `
\u2022 ${this.#escapeHtml(v.message || v)}
`).join("")}
+
+ `;
+ } else {
+ this.#previewArea.innerHTML = '\u2705DRC results will appear here
';
+ }
+ break;
+ }
+ }
+
+ #showExports() {
+ if (this.#exportRow && (this.#gerberUrl || this.#bomUrl || this.#pdfUrl)) {
+ this.#exportRow.style.display = "flex";
+ const gerberLink = this.#exportRow.querySelector(".gerber-dl") as HTMLAnchorElement;
+ const bomLink = this.#exportRow.querySelector(".bom-dl") as HTMLAnchorElement;
+ const pdfLink = this.#exportRow.querySelector(".pdf-dl") as HTMLAnchorElement;
+ if (gerberLink) gerberLink.href = this.#gerberUrl || "#";
+ if (bomLink) bomLink.href = this.#bomUrl || "#";
+ if (pdfLink) pdfLink.href = this.#pdfUrl || "#";
+ }
+ }
+
+ #escapeHtml(text: string): string {
+ const div = document.createElement("div");
+ div.textContent = text;
+ return div.innerHTML;
+ }
+
+ override toJSON() {
+ return {
+ ...super.toJSON(),
+ type: "folk-kicad",
+ schematicSvg: this.#schematicSvg,
+ boardSvg: this.#boardSvg,
+ gerberUrl: this.#gerberUrl,
+ bomUrl: this.#bomUrl,
+ pdfUrl: this.#pdfUrl,
+ };
+ }
+}
diff --git a/lib/folk-splat.ts b/lib/folk-splat.ts
new file mode 100644
index 0000000..57dc9a6
--- /dev/null
+++ b/lib/folk-splat.ts
@@ -0,0 +1,438 @@
+import { FolkShape } from "./folk-shape";
+import { css, html } from "./tags";
+
+const styles = css`
+ :host {
+ background: #0f172a;
+ border-radius: 8px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
+ min-width: 360px;
+ min-height: 400px;
+ }
+
+ .header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 12px;
+ background: linear-gradient(135deg, #7c3aed, #2563eb);
+ color: white;
+ border-radius: 8px 8px 0 0;
+ font-size: 12px;
+ font-weight: 600;
+ cursor: move;
+ }
+
+ .header-title {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ }
+
+ .header-actions {
+ display: flex;
+ gap: 4px;
+ }
+
+ .header-actions button {
+ background: transparent;
+ border: none;
+ color: white;
+ cursor: pointer;
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-size: 14px;
+ }
+
+ .header-actions button:hover {
+ background: rgba(255, 255, 255, 0.2);
+ }
+
+ .content {
+ display: flex;
+ flex-direction: column;
+ height: calc(100% - 36px);
+ overflow: hidden;
+ }
+
+ .controls {
+ padding: 10px 12px;
+ border-bottom: 1px solid #1e293b;
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ }
+
+ .splat-url-input {
+ flex: 1;
+ padding: 8px 10px;
+ border: 2px solid #334155;
+ border-radius: 6px;
+ background: #1e293b;
+ color: #e2e8f0;
+ font-size: 12px;
+ outline: none;
+ }
+
+ .splat-url-input:focus {
+ border-color: #7c3aed;
+ }
+
+ .load-btn {
+ padding: 8px 14px;
+ background: linear-gradient(135deg, #7c3aed, #2563eb);
+ color: white;
+ border: none;
+ border-radius: 6px;
+ font-size: 12px;
+ font-weight: 600;
+ cursor: pointer;
+ }
+
+ .load-btn:hover {
+ opacity: 0.9;
+ }
+
+ .viewer-area {
+ flex: 1;
+ position: relative;
+ background: #0a0a0a;
+ overflow: hidden;
+ }
+
+ .viewer-area canvas {
+ width: 100%;
+ height: 100%;
+ display: block;
+ }
+
+ .placeholder {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ color: #64748b;
+ text-align: center;
+ gap: 8px;
+ }
+
+ .placeholder-icon {
+ font-size: 48px;
+ opacity: 0.5;
+ }
+
+ .gallery-list {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 8px;
+ padding: 10px;
+ overflow-y: auto;
+ max-height: 200px;
+ }
+
+ .gallery-item {
+ padding: 8px;
+ background: #1e293b;
+ border-radius: 6px;
+ cursor: pointer;
+ color: #cbd5e1;
+ font-size: 11px;
+ text-align: center;
+ border: 2px solid transparent;
+ transition: border-color 0.2s;
+ }
+
+ .gallery-item:hover {
+ border-color: #7c3aed;
+ }
+
+ .gallery-item .title {
+ font-weight: 600;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .gallery-item .meta {
+ font-size: 10px;
+ color: #64748b;
+ margin-top: 2px;
+ }
+
+ .loading {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 24px;
+ gap: 12px;
+ color: #94a3b8;
+ }
+
+ .spinner {
+ width: 32px;
+ height: 32px;
+ border: 3px solid #334155;
+ border-top-color: #7c3aed;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+ }
+
+ @keyframes spin {
+ to { transform: rotate(360deg); }
+ }
+
+ .error {
+ color: #ef4444;
+ padding: 12px;
+ background: #1c1017;
+ border-radius: 6px;
+ font-size: 13px;
+ margin: 10px;
+ }
+
+ .upload-btn {
+ padding: 8px 14px;
+ background: #334155;
+ color: #e2e8f0;
+ border: none;
+ border-radius: 6px;
+ font-size: 12px;
+ cursor: pointer;
+ }
+
+ .upload-btn:hover {
+ background: #475569;
+ }
+`;
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "folk-splat": FolkSplat;
+ }
+}
+
+export class FolkSplat extends FolkShape {
+ static override tagName = "folk-splat";
+
+ static {
+ const sheet = new CSSStyleSheet();
+ const parentRules = Array.from(FolkShape.styles.cssRules)
+ .map((r) => r.cssText)
+ .join("\n");
+ const childRules = Array.from(styles.cssRules)
+ .map((r) => r.cssText)
+ .join("\n");
+ sheet.replaceSync(`${parentRules}\n${childRules}`);
+ this.styles = sheet;
+ }
+
+ #splatUrl = "";
+ #isLoading = false;
+ #error: string | null = null;
+ #viewer: any = null;
+ #viewerCanvas: HTMLCanvasElement | null = null;
+ #gallerySplats: any[] = [];
+ #urlInput: HTMLInputElement | null = null;
+
+ get splatUrl() {
+ return this.#splatUrl;
+ }
+ set splatUrl(v: string) {
+ this.#splatUrl = v;
+ }
+
+ override createRenderRoot() {
+ const root = super.createRenderRoot();
+
+ const wrapper = document.createElement("div");
+ wrapper.innerHTML = html`
+
+
+
+
+
+
+
+
+
+ \u{1F52E}
+ Enter a splat URL or browse the gallery
+
+
+
+ `;
+
+ const slot = root.querySelector("slot");
+ const containerDiv = slot?.parentElement as HTMLElement;
+ if (containerDiv) {
+ containerDiv.replaceWith(wrapper);
+ }
+
+ this.#urlInput = wrapper.querySelector(".splat-url-input");
+ const loadBtn = wrapper.querySelector(".load-btn") as HTMLButtonElement;
+ const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
+ const galleryBtn = wrapper.querySelector(".gallery-btn") as HTMLButtonElement;
+ const galleryList = wrapper.querySelector(".gallery-list") as HTMLElement;
+ const viewerArea = wrapper.querySelector(".viewer-area") as HTMLElement;
+
+ loadBtn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ const url = this.#urlInput?.value.trim();
+ if (url) this.#loadSplat(url, viewerArea);
+ });
+
+ this.#urlInput?.addEventListener("keydown", (e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ const url = this.#urlInput?.value.trim();
+ if (url) this.#loadSplat(url, viewerArea);
+ }
+ });
+ this.#urlInput?.addEventListener("pointerdown", (e) => e.stopPropagation());
+
+ galleryBtn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ const isVisible = galleryList.style.display !== "none";
+ galleryList.style.display = isVisible ? "none" : "grid";
+ if (!isVisible && this.#gallerySplats.length === 0) {
+ this.#loadGallery(galleryList, viewerArea);
+ }
+ });
+
+ closeBtn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ this.dispatchEvent(new CustomEvent("close"));
+ });
+
+ // If splatUrl was set before render
+ if (this.#splatUrl) {
+ this.#loadSplat(this.#splatUrl, viewerArea);
+ }
+
+ return root;
+ }
+
+ async #loadGallery(container: HTMLElement, viewerArea: HTMLElement) {
+ container.innerHTML = '';
+ try {
+ const spaceSlug = this.#getSpaceSlug();
+ const res = await fetch(`/${spaceSlug}/rsplat/api/splats?limit=20`);
+ if (!res.ok) throw new Error("Failed to load splats");
+ const data = await res.json();
+ this.#gallerySplats = data.splats || [];
+
+ if (this.#gallerySplats.length === 0) {
+ container.innerHTML = 'No splats in this space yet
';
+ return;
+ }
+
+ container.innerHTML = this.#gallerySplats.map((s) => `
+
+
${this.#escapeHtml(s.title)}
+
${s.file_format} \u2022 ${(s.file_size_bytes / 1024 / 1024).toFixed(1)}MB
+
+ `).join("");
+
+ container.querySelectorAll(".gallery-item").forEach((item) => {
+ item.addEventListener("click", (e) => {
+ e.stopPropagation();
+ const slug = (item as HTMLElement).dataset.slug;
+ const splat = this.#gallerySplats.find((s) => s.slug === slug);
+ if (splat) {
+ const spaceSlug = this.#getSpaceSlug();
+ const url = `/${spaceSlug}/rsplat/api/splats/${splat.slug}/${splat.slug}.${splat.file_format}`;
+ if (this.#urlInput) this.#urlInput.value = url;
+ container.style.display = "none";
+ this.#loadSplat(url, viewerArea);
+ }
+ });
+ });
+ } catch (err) {
+ container.innerHTML = `Failed to load gallery
`;
+ }
+ }
+
+ async #loadSplat(url: string, viewerArea: HTMLElement) {
+ this.#splatUrl = url;
+ this.#isLoading = true;
+ this.#error = null;
+
+ viewerArea.innerHTML = '';
+
+ try {
+ // Use Three.js + GaussianSplats3D via CDN importmap (not bundled)
+ const threeId = "three";
+ const gs3dId = "@mkkellogg/gaussian-splats-3d";
+ const THREE = await (Function("id", "return import(id)")(threeId) as Promise);
+ const GS3D = await (Function("id", "return import(id)")(gs3dId) as Promise);
+
+ viewerArea.innerHTML = "";
+
+ const canvas = document.createElement("canvas");
+ canvas.style.width = "100%";
+ canvas.style.height = "100%";
+ viewerArea.appendChild(canvas);
+
+ const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
+ renderer.setSize(viewerArea.clientWidth, viewerArea.clientHeight);
+
+ const viewer = new GS3D.Viewer({
+ renderer,
+ cameraUp: [0, -1, 0],
+ initialCameraPosition: [0, 0, 5],
+ initialCameraLookAt: [0, 0, 0],
+ });
+
+ await viewer.addSplatScene(url);
+ this.#viewer = viewer;
+ this.#viewerCanvas = canvas;
+
+ // Simple animation loop
+ const animate = () => {
+ if (!this.#viewer) return;
+ (viewer as any).update();
+ (viewer as any).render();
+ requestAnimationFrame(animate);
+ };
+ animate();
+ } catch (err) {
+ // Fallback: show as iframe pointing to splat viewer page
+ console.warn("[folk-splat] Three.js not available, using iframe fallback");
+ const spaceSlug = this.#getSpaceSlug();
+ const slug = url.split("/").filter(Boolean).pop()?.split(".")[0] || "";
+ viewerArea.innerHTML = ``;
+ } finally {
+ this.#isLoading = false;
+ }
+ }
+
+ #getSpaceSlug(): string {
+ const pathParts = window.location.pathname.split("/").filter(Boolean);
+ return pathParts[0] || "demo";
+ }
+
+ #escapeHtml(text: string): string {
+ const div = document.createElement("div");
+ div.textContent = text;
+ return div.innerHTML;
+ }
+
+ override toJSON() {
+ return {
+ ...super.toJSON(),
+ type: "folk-splat",
+ splatUrl: this.#splatUrl,
+ };
+ }
+}
diff --git a/lib/index.ts b/lib/index.ts
index 9519134..ac2d22e 100644
--- a/lib/index.ts
+++ b/lib/index.ts
@@ -39,6 +39,13 @@ export * from "./folk-video-gen";
export * from "./folk-prompt";
export * from "./folk-transcription";
+// Creative Tools Shapes
+export * from "./folk-splat";
+export * from "./folk-blender";
+export * from "./folk-drawfast";
+export * from "./folk-freecad";
+export * from "./folk-kicad";
+
// Advanced Shapes
export * from "./folk-video-chat";
export * from "./folk-obs-note";
diff --git a/modules/providers/components/folk-provider-directory.ts b/modules/providers/components/folk-provider-directory.ts
deleted file mode 100644
index bd44eb6..0000000
--- a/modules/providers/components/folk-provider-directory.ts
+++ /dev/null
@@ -1,182 +0,0 @@
-/**
- * — browseable provider directory.
- * Shows a grid of provider cards with search, capability filter, and proximity sorting.
- */
-
-class FolkProviderDirectory extends HTMLElement {
- private shadow: ShadowRoot;
- private providers: any[] = [];
- private capabilities: string[] = [];
- private selectedCap = "";
- private searchQuery = "";
- private userLat: number | null = null;
- private userLng: number | null = null;
-
- constructor() {
- super();
- this.shadow = this.attachShadow({ mode: "open" });
- }
-
- connectedCallback() {
- this.render();
- this.loadProviders();
- }
-
- private getApiBase(): string {
- const path = window.location.pathname;
- const parts = path.split("/").filter(Boolean);
- return parts.length >= 2 ? `/${parts[0]}/providers` : "/demo/providers";
- }
-
- private async loadProviders() {
- try {
- const params = new URLSearchParams();
- if (this.selectedCap) params.set("capability", this.selectedCap);
- if (this.userLat && this.userLng) {
- params.set("lat", String(this.userLat));
- params.set("lng", String(this.userLng));
- }
- params.set("limit", "100");
-
- const res = await fetch(`${this.getApiBase()}/api/providers?${params}`);
- const data = await res.json();
- this.providers = data.providers || [];
-
- // Collect unique capabilities
- const capSet = new Set();
- for (const p of this.providers) {
- for (const cap of (p.capabilities || [])) capSet.add(cap);
- }
- this.capabilities = Array.from(capSet).sort();
-
- this.render();
- } catch (e) {
- console.error("Failed to load providers:", e);
- }
- }
-
- private render() {
- const filtered = this.providers.filter((p) => {
- if (this.searchQuery) {
- const q = this.searchQuery.toLowerCase();
- const name = (p.name || "").toLowerCase();
- const city = (p.location?.city || "").toLowerCase();
- const country = (p.location?.country || "").toLowerCase();
- if (!name.includes(q) && !city.includes(q) && !country.includes(q)) return false;
- }
- return true;
- });
-
- this.shadow.innerHTML = `
-
-
-
- Provider Directory
-
-
-
-
- ${this.capabilities.length > 0 ? `
-
- All
- ${this.capabilities.map((cap) => `
- ${cap}
- `).join("")}
-
` : ""}
-
- ${filtered.length === 0 ? `No providers found
` : `
-
- ${filtered.map((p) => `
-
-
-
- ${(p.capabilities || []).map((cap: string) => `${this.esc(cap)}`).join("")}
-
- ${p.turnaround?.standard_days ? `
\u23F1 ${p.turnaround.standard_days} days standard${p.turnaround.rush_days ? ` / ${p.turnaround.rush_days} days rush (+${p.turnaround.rush_surcharge_pct || 0}%)` : ""}
` : ""}
-
-
- `).join("")}
-
`}
- `;
-
- // Event listeners
- this.shadow.querySelector(".search")?.addEventListener("input", (e) => {
- this.searchQuery = (e.target as HTMLInputElement).value;
- this.render();
- });
-
- this.shadow.querySelector(".locate-btn")?.addEventListener("click", () => {
- if (this.userLat) {
- this.userLat = null;
- this.userLng = null;
- this.loadProviders();
- } else {
- navigator.geolocation?.getCurrentPosition(
- (pos) => {
- this.userLat = pos.coords.latitude;
- this.userLng = pos.coords.longitude;
- this.loadProviders();
- },
- () => { console.warn("Geolocation denied"); }
- );
- }
- });
-
- this.shadow.querySelectorAll(".cap[data-cap]").forEach((el) => {
- el.addEventListener("click", () => {
- this.selectedCap = (el as HTMLElement).dataset.cap || "";
- this.loadProviders();
- });
- });
- }
-
- private esc(s: string): string {
- const d = document.createElement("div");
- d.textContent = s;
- return d.innerHTML;
- }
-}
-
-customElements.define("folk-provider-directory", FolkProviderDirectory);
diff --git a/modules/providers/components/providers.css b/modules/providers/components/providers.css
deleted file mode 100644
index 49892d2..0000000
--- a/modules/providers/components/providers.css
+++ /dev/null
@@ -1,6 +0,0 @@
-/* Providers module theme */
-body[data-theme="light"] main {
- background: #0f172a;
- min-height: calc(100vh - 56px);
- padding: 0;
-}
diff --git a/modules/providers/db/schema.sql b/modules/providers/db/schema.sql
deleted file mode 100644
index 48c805f..0000000
--- a/modules/providers/db/schema.sql
+++ /dev/null
@@ -1,70 +0,0 @@
--- Provider Registry schema (inside rSpace shared DB, schema: providers)
--- Uses earth_distance extension for proximity queries (lighter than PostGIS)
-
-CREATE EXTENSION IF NOT EXISTS "cube";
-CREATE EXTENSION IF NOT EXISTS "earthdistance";
-
-CREATE TABLE IF NOT EXISTS providers.providers (
- id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
- name VARCHAR(200) NOT NULL,
- description TEXT,
-
- -- Location
- lat DOUBLE PRECISION NOT NULL,
- lng DOUBLE PRECISION NOT NULL,
- address TEXT,
- city VARCHAR(100),
- region VARCHAR(100),
- country CHAR(2),
- service_radius_km DOUBLE PRECISION DEFAULT 0,
- offers_shipping BOOLEAN DEFAULT FALSE,
-
- -- Capabilities & substrates (arrays for containment queries)
- capabilities TEXT[] NOT NULL DEFAULT '{}',
- substrates TEXT[] NOT NULL DEFAULT '{}',
-
- -- Turnaround
- standard_days INTEGER,
- rush_days INTEGER,
- rush_surcharge_pct DOUBLE PRECISION DEFAULT 0,
-
- -- Pricing (JSONB -- keyed by capability)
- pricing JSONB DEFAULT '{}',
-
- -- Community membership
- communities TEXT[] NOT NULL DEFAULT '{}',
-
- -- Contact
- contact_email VARCHAR(255),
- contact_phone VARCHAR(50),
- contact_website VARCHAR(500),
-
- -- Payment
- wallet VARCHAR(255),
-
- -- Reputation
- jobs_completed INTEGER DEFAULT 0,
- avg_rating DOUBLE PRECISION,
- member_since TIMESTAMPTZ DEFAULT NOW(),
-
- -- Status
- active BOOLEAN DEFAULT TRUE,
-
- created_at TIMESTAMPTZ DEFAULT NOW(),
- updated_at TIMESTAMPTZ DEFAULT NOW()
-);
-
-CREATE INDEX IF NOT EXISTS idx_providers_location
- ON providers.providers USING gist (ll_to_earth(lat, lng));
-
-CREATE INDEX IF NOT EXISTS idx_providers_capabilities
- ON providers.providers USING gin (capabilities);
-
-CREATE INDEX IF NOT EXISTS idx_providers_substrates
- ON providers.providers USING gin (substrates);
-
-CREATE INDEX IF NOT EXISTS idx_providers_communities
- ON providers.providers USING gin (communities);
-
-CREATE INDEX IF NOT EXISTS idx_providers_active
- ON providers.providers (active) WHERE active = TRUE;
diff --git a/modules/providers/mod.ts b/modules/providers/mod.ts
deleted file mode 100644
index 70694bd..0000000
--- a/modules/providers/mod.ts
+++ /dev/null
@@ -1,370 +0,0 @@
-/**
- * Providers module — local provider directory.
- *
- * Ported from /opt/apps/provider-registry/ (Express + pg → Hono + postgres.js).
- * Uses earthdistance extension for proximity queries.
- */
-
-import { Hono } from "hono";
-import { readFileSync } from "node:fs";
-import { resolve } from "node:path";
-import { sql } from "../../shared/db/pool";
-import { renderShell } from "../../server/shell";
-import { getModuleInfoList } from "../../shared/module";
-import type { RSpaceModule } from "../../shared/module";
-import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
-
-const routes = new Hono();
-
-// ── DB initialization ──
-const SCHEMA_SQL = readFileSync(resolve(import.meta.dir, "db/schema.sql"), "utf-8");
-
-async function initDB() {
- try {
- await sql.unsafe(SCHEMA_SQL);
- console.log("[Providers] DB schema initialized");
- } catch (e) {
- console.error("[Providers] DB init error:", e);
- }
-}
-
-initDB();
-
-// ── Seed data (if empty) ──
-async function seedIfEmpty() {
- const count = await sql.unsafe("SELECT count(*) FROM providers.providers");
- if (parseInt(count[0].count) > 0) return;
-
- const providers = [
- { name: "Radiant Hall Press", lat: 40.4732, lng: -79.9535, city: "Pittsburgh", region: "PA", country: "US", caps: ["risograph","saddle-stitch","perfect-bind","laser-print","fold"], subs: ["paper-80gsm","paper-100gsm","paper-100gsm-recycled","paper-160gsm-cover"], radius: 25, shipping: true, days: 3, rush: 1, rushPct: 50, email: "hello@radianthallpress.com", website: "https://radianthallpress.com", community: "pittsburgh.mycofi.earth" },
- { name: "Tiny Splendor", lat: 37.7799, lng: -122.2822, city: "Oakland", region: "CA", country: "US", caps: ["risograph","saddle-stitch","fold"], subs: ["paper-80gsm","paper-100gsm-recycled"], radius: 30, shipping: true, days: 5, rush: 2, rushPct: 40, email: "print@tinysplendor.com", website: "https://tinysplendor.com", community: "oakland.mycofi.earth" },
- { name: "People's Print Shop", lat: 40.7282, lng: -73.7949, city: "New York", region: "NY", country: "US", caps: ["risograph","screen-print","saddle-stitch"], subs: ["paper-80gsm","paper-100gsm","fabric-cotton"], radius: 15, shipping: true, days: 4, rush: 2, rushPct: 50, email: "hello@peoplesprintshop.com", website: "https://peoplesprintshop.com", community: "nyc.mycofi.earth" },
- { name: "Colour Code Press", lat: 51.5402, lng: -0.1449, city: "London", region: "England", country: "GB", caps: ["risograph","perfect-bind","fold","laser-print"], subs: ["paper-80gsm","paper-100gsm","paper-160gsm-cover"], radius: 20, shipping: true, days: 5, rush: 2, rushPct: 50, email: "info@colourcodepress.com", website: "https://colourcodepress.com", community: "london.mycofi.earth" },
- { name: "Druckwerkstatt Berlin", lat: 52.5200, lng: 13.4050, city: "Berlin", region: "Berlin", country: "DE", caps: ["risograph","screen-print","saddle-stitch","fold"], subs: ["paper-80gsm","paper-100gsm-recycled","paper-160gsm-cover"], radius: 20, shipping: true, days: 4, rush: 1, rushPct: 60, email: "hallo@druckwerkstatt.de", website: "https://druckwerkstatt.de", community: "berlin.mycofi.earth" },
- { name: "Kinko Printing Collective", lat: 35.6762, lng: 139.6503, city: "Tokyo", region: "Tokyo", country: "JP", caps: ["risograph","saddle-stitch","fold","perfect-bind"], subs: ["paper-80gsm","paper-100gsm","washi-paper"], radius: 30, shipping: true, days: 5, rush: 2, rushPct: 50, email: "info@kinkoprint.jp", website: "https://kinkoprint.jp", community: "tokyo.mycofi.earth" },
- ];
-
- for (const p of providers) {
- await sql.unsafe(
- `INSERT INTO providers.providers (
- name, lat, lng, city, region, country,
- capabilities, substrates, service_radius_km, offers_shipping,
- standard_days, rush_days, rush_surcharge_pct,
- contact_email, contact_website, communities
- ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)`,
- [p.name, p.lat, p.lng, p.city, p.region, p.country,
- p.caps, p.subs, p.radius, p.shipping,
- p.days, p.rush, p.rushPct, p.email, p.website, [p.community]]
- );
- }
- console.log("[Providers] Seeded 6 providers");
-}
-
-initDB().then(seedIfEmpty).catch(() => {});
-
-// ── Transform DB row → API response ──
-function toProviderResponse(row: Record) {
- return {
- id: row.id,
- name: row.name,
- description: row.description,
- location: {
- lat: row.lat,
- lng: row.lng,
- address: row.address,
- city: row.city,
- region: row.region,
- country: row.country,
- service_radius_km: row.service_radius_km,
- offers_shipping: row.offers_shipping,
- },
- capabilities: row.capabilities,
- substrates: row.substrates,
- turnaround: {
- standard_days: row.standard_days,
- rush_days: row.rush_days,
- rush_surcharge_pct: row.rush_surcharge_pct,
- },
- pricing: row.pricing,
- communities: row.communities,
- contact: {
- email: row.contact_email,
- phone: row.contact_phone,
- website: row.contact_website,
- },
- wallet: row.wallet,
- reputation: {
- jobs_completed: row.jobs_completed,
- avg_rating: row.avg_rating,
- member_since: row.member_since,
- },
- active: row.active,
- ...(row.distance_km !== undefined && { distance_km: parseFloat(row.distance_km as string) }),
- };
-}
-
-// ── GET /api/providers — List/search providers ──
-routes.get("/api/providers", async (c) => {
- const { capability, substrate, community, lat, lng, radius_km, active, limit = "50", offset = "0" } = c.req.query();
-
- const conditions: string[] = [];
- const params: any[] = [];
- let paramIdx = 1;
-
- if (active !== "false") {
- conditions.push("active = TRUE");
- }
-
- if (capability) {
- const caps = capability.split(",");
- conditions.push(`capabilities @> $${paramIdx}`);
- params.push(caps);
- paramIdx++;
- }
-
- if (substrate) {
- const subs = substrate.split(",");
- conditions.push(`substrates && $${paramIdx}`);
- params.push(subs);
- paramIdx++;
- }
-
- if (community) {
- conditions.push(`$${paramIdx} = ANY(communities)`);
- params.push(community);
- paramIdx++;
- }
-
- let distanceSelect = "";
- let orderBy = "ORDER BY name";
- if (lat && lng) {
- const latNum = parseFloat(lat);
- const lngNum = parseFloat(lng);
- distanceSelect = `, round((earth_distance(ll_to_earth(lat, lng), ll_to_earth($${paramIdx}, $${paramIdx + 1})) / 1000)::numeric, 1) as distance_km`;
- params.push(latNum, lngNum);
-
- if (radius_km) {
- conditions.push(`earth_distance(ll_to_earth(lat, lng), ll_to_earth($${paramIdx}, $${paramIdx + 1})) <= $${paramIdx + 2} * 1000`);
- params.push(parseFloat(radius_km));
- paramIdx += 3;
- } else {
- paramIdx += 2;
- }
- orderBy = "ORDER BY distance_km ASC";
- }
-
- const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
- const limitNum = Math.min(parseInt(limit) || 50, 100);
- const offsetNum = parseInt(offset) || 0;
-
- const [result, countResult] = await Promise.all([
- sql.unsafe(`SELECT *${distanceSelect} FROM providers.providers ${where} ${orderBy} LIMIT ${limitNum} OFFSET ${offsetNum}`, params),
- sql.unsafe(`SELECT count(*) FROM providers.providers ${where}`, params),
- ]);
-
- return c.json({
- providers: result.map(toProviderResponse),
- total: parseInt(countResult[0].count as string),
- limit: limitNum,
- offset: offsetNum,
- });
-});
-
-// ── GET /api/providers/match — Match providers for an artifact ──
-routes.get("/api/providers/match", async (c) => {
- const { capabilities, substrates, lat, lng, community } = c.req.query();
-
- if (!capabilities || !lat || !lng) {
- return c.json({ error: "Required query params: capabilities (comma-separated), lat, lng" }, 400);
- }
-
- const caps = capabilities.split(",");
- const latNum = parseFloat(lat);
- const lngNum = parseFloat(lng);
-
- const conditions = ["active = TRUE", "capabilities @> $1"];
- const params: any[] = [caps, latNum, lngNum];
- let paramIdx = 4;
-
- if (substrates) {
- const subs = substrates.split(",");
- conditions.push(`substrates && $${paramIdx}`);
- params.push(subs);
- paramIdx++;
- }
-
- if (community) {
- conditions.push(`$${paramIdx} = ANY(communities)`);
- params.push(community);
- paramIdx++;
- }
-
- const result = await sql.unsafe(
- `SELECT *,
- round((earth_distance(ll_to_earth(lat, lng), ll_to_earth($2, $3)) / 1000)::numeric, 1) as distance_km
- FROM providers.providers
- WHERE ${conditions.join(" AND ")}
- AND (service_radius_km = 0 OR offers_shipping = TRUE
- OR earth_distance(ll_to_earth(lat, lng), ll_to_earth($2, $3)) <= service_radius_km * 1000)
- ORDER BY earth_distance(ll_to_earth(lat, lng), ll_to_earth($2, $3)) ASC
- LIMIT 20`,
- params
- );
-
- return c.json({
- matches: result.map(toProviderResponse),
- query: { capabilities: caps, location: { lat: latNum, lng: lngNum } },
- });
-});
-
-// ── GET /api/providers/:id — Single provider ──
-routes.get("/api/providers/:id", async (c) => {
- const id = c.req.param("id");
- const result = await sql.unsafe("SELECT * FROM providers.providers WHERE id = $1", [id]);
- if (result.length === 0) return c.json({ error: "Provider not found" }, 404);
- return c.json(toProviderResponse(result[0]));
-});
-
-// ── POST /api/providers — Register a new provider ──
-routes.post("/api/providers", async (c) => {
- const token = extractToken(c.req.raw.headers);
- if (!token) return c.json({ error: "Authentication required" }, 401);
- try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
-
- const body = await c.req.json();
- const { name, description, location, capabilities, substrates, turnaround, pricing, communities, contact, wallet } = body;
-
- if (!name || !location?.lat || !location?.lng || !capabilities?.length) {
- return c.json({ error: "Required: name, location.lat, location.lng, capabilities (non-empty array)" }, 400);
- }
-
- const result = await sql.unsafe(
- `INSERT INTO providers.providers (
- name, description, lat, lng, address, city, region, country,
- service_radius_km, offers_shipping, capabilities, substrates,
- standard_days, rush_days, rush_surcharge_pct, pricing, communities,
- contact_email, contact_phone, contact_website, wallet
- ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21)
- RETURNING *`,
- [
- name, description || null,
- location.lat, location.lng, location.address || null,
- location.city || null, location.region || null, location.country || null,
- location.service_radius_km || 0, location.offers_shipping || false,
- capabilities, substrates || [],
- turnaround?.standard_days || null, turnaround?.rush_days || null, turnaround?.rush_surcharge_pct || 0,
- JSON.stringify(pricing || {}), communities || [],
- contact?.email || null, contact?.phone || null, contact?.website || null,
- wallet || null,
- ]
- );
-
- return c.json(toProviderResponse(result[0]), 201);
-});
-
-// ── PUT /api/providers/:id — Update provider ──
-routes.put("/api/providers/:id", async (c) => {
- const token = extractToken(c.req.raw.headers);
- if (!token) return c.json({ error: "Authentication required" }, 401);
- try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
-
- const id = c.req.param("id");
- const existing = await sql.unsafe("SELECT id FROM providers.providers WHERE id = $1", [id]);
- if (existing.length === 0) return c.json({ error: "Provider not found" }, 404);
-
- const body = await c.req.json();
- const fields: string[] = [];
- const params: any[] = [];
- let paramIdx = 1;
-
- const settable = ["name", "description", "capabilities", "substrates", "communities", "wallet", "active"];
- for (const key of settable) {
- if (body[key] !== undefined) {
- fields.push(`${key} = $${paramIdx}`);
- params.push(body[key]);
- paramIdx++;
- }
- }
-
- if (body.location) {
- for (const [key, col] of Object.entries({ lat: "lat", lng: "lng", address: "address", city: "city", region: "region", country: "country", service_radius_km: "service_radius_km", offers_shipping: "offers_shipping" })) {
- if (body.location[key] !== undefined) {
- fields.push(`${col} = $${paramIdx}`);
- params.push(body.location[key]);
- paramIdx++;
- }
- }
- }
-
- if (body.turnaround) {
- for (const [key, col] of Object.entries({ standard_days: "standard_days", rush_days: "rush_days", rush_surcharge_pct: "rush_surcharge_pct" })) {
- if (body.turnaround[key] !== undefined) {
- fields.push(`${col} = $${paramIdx}`);
- params.push(body.turnaround[key]);
- paramIdx++;
- }
- }
- }
-
- if (body.pricing !== undefined) {
- fields.push(`pricing = $${paramIdx}`);
- params.push(JSON.stringify(body.pricing));
- paramIdx++;
- }
-
- if (body.contact) {
- for (const [key, col] of Object.entries({ email: "contact_email", phone: "contact_phone", website: "contact_website" })) {
- if (body.contact[key] !== undefined) {
- fields.push(`${col} = $${paramIdx}`);
- params.push(body.contact[key]);
- paramIdx++;
- }
- }
- }
-
- if (fields.length === 0) return c.json({ error: "No fields to update" }, 400);
-
- fields.push("updated_at = NOW()");
- params.push(id);
-
- const result = await sql.unsafe(
- `UPDATE providers.providers SET ${fields.join(", ")} WHERE id = $${paramIdx} RETURNING *`,
- params
- );
-
- return c.json(toProviderResponse(result[0]));
-});
-
-// ── DELETE /api/providers/:id — Deactivate provider ──
-routes.delete("/api/providers/:id", async (c) => {
- const result = await sql.unsafe(
- "UPDATE providers.providers SET active = FALSE, updated_at = NOW() WHERE id = $1 RETURNING *",
- [c.req.param("id")]
- );
- if (result.length === 0) return c.json({ error: "Provider not found" }, 404);
- return c.json({ message: "Provider deactivated", provider: toProviderResponse(result[0]) });
-});
-
-// ── Page route: browse providers ──
-routes.get("/", (c) => {
- const space = c.req.param("space") || "demo";
- return c.html(renderShell({
- title: `Providers | rSpace`,
- moduleId: "rproviders",
- spaceSlug: space,
- modules: getModuleInfoList(),
- theme: "dark",
- body: ``,
- scripts: ``,
- styles: ``,
- }));
-});
-
-export const providersModule: RSpaceModule = {
- id: "rproviders",
- name: "rProviders",
- icon: "\u{1F3ED}",
- description: "Local provider directory for cosmolocal production",
- routes,
- standaloneDomain: "providers.mycofi.earth",
-};
diff --git a/modules/splat/mod.ts b/modules/splat/mod.ts
index 343ba2f..a4a934c 100644
--- a/modules/splat/mod.ts
+++ b/modules/splat/mod.ts
@@ -540,6 +540,7 @@ export const splatModule: RSpaceModule = {
description: "3D Gaussian splat viewer",
routes,
standaloneDomain: "rsplat.online",
+ hidden: true,
async onSpaceCreate(_spaceSlug: string) {
// Splats are scoped by space_slug column. No per-space setup needed.
diff --git a/server/index.ts b/server/index.ts
index bef3e66..b8dff5b 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -425,6 +425,271 @@ app.get("/api/modules", (c) => {
return c.json({ modules: getModuleInfoList() });
});
+// ── Creative tools API endpoints ──
+
+const FAL_KEY = process.env.FAL_KEY || "";
+
+// Image generation via fal.ai Flux Pro
+app.post("/api/image-gen", async (c) => {
+ if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503);
+
+ const { prompt, style } = await c.req.json();
+ if (!prompt) return c.json({ error: "prompt required" }, 400);
+
+ const stylePrompts: Record = {
+ illustration: "digital illustration style, ",
+ photorealistic: "photorealistic, high detail, ",
+ painting: "oil painting style, artistic, ",
+ sketch: "pencil sketch style, hand-drawn, ",
+ "punk-zine": "punk zine aesthetic, cut-and-paste collage, bold contrast, ",
+ };
+ const styledPrompt = (stylePrompts[style] || "") + prompt;
+
+ const res = await fetch("https://queue.fal.run/fal-ai/flux-pro/v1.1", {
+ method: "POST",
+ headers: {
+ Authorization: `Key ${FAL_KEY}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ prompt: styledPrompt,
+ image_size: "landscape_4_3",
+ num_images: 1,
+ safety_tolerance: "2",
+ }),
+ });
+
+ if (!res.ok) {
+ const err = await res.text();
+ console.error("[image-gen] fal.ai error:", err);
+ return c.json({ error: "Image generation failed" }, 502);
+ }
+
+ const data = await res.json();
+ const imageUrl = data.images?.[0]?.url || data.output?.url;
+ if (!imageUrl) return c.json({ error: "No image returned" }, 502);
+
+ return c.json({ url: imageUrl, image_url: imageUrl });
+});
+
+// Text-to-video via fal.ai WAN 2.1
+app.post("/api/video-gen/t2v", async (c) => {
+ if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503);
+
+ const { prompt, duration } = await c.req.json();
+ if (!prompt) return c.json({ error: "prompt required" }, 400);
+
+ const res = await fetch("https://queue.fal.run/fal-ai/wan/v2.1", {
+ method: "POST",
+ headers: {
+ Authorization: `Key ${FAL_KEY}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ prompt,
+ num_frames: duration === "5s" ? 81 : 49,
+ resolution: "480p",
+ }),
+ });
+
+ if (!res.ok) {
+ const err = await res.text();
+ console.error("[video-gen/t2v] fal.ai error:", err);
+ return c.json({ error: "Video generation failed" }, 502);
+ }
+
+ const data = await res.json();
+ const videoUrl = data.video?.url || data.output?.url;
+ if (!videoUrl) return c.json({ error: "No video returned" }, 502);
+
+ return c.json({ url: videoUrl, video_url: videoUrl });
+});
+
+// Image-to-video via fal.ai Kling
+app.post("/api/video-gen/i2v", async (c) => {
+ if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503);
+
+ const { image, prompt, duration } = await c.req.json();
+ if (!image) return c.json({ error: "image required" }, 400);
+
+ const res = await fetch("https://queue.fal.run/fal-ai/kling-video/v1/standard/image-to-video", {
+ method: "POST",
+ headers: {
+ Authorization: `Key ${FAL_KEY}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ image_url: image,
+ prompt: prompt || "",
+ duration: duration === "5s" ? "5" : "5",
+ }),
+ });
+
+ if (!res.ok) {
+ const err = await res.text();
+ console.error("[video-gen/i2v] fal.ai error:", err);
+ return c.json({ error: "Video generation failed" }, 502);
+ }
+
+ const data = await res.json();
+ const videoUrl = data.video?.url || data.output?.url;
+ if (!videoUrl) return c.json({ error: "No video returned" }, 502);
+
+ return c.json({ url: videoUrl, video_url: videoUrl });
+});
+
+// Blender 3D generation via LLM + RunPod
+const RUNPOD_API_KEY = process.env.RUNPOD_API_KEY || "";
+
+app.post("/api/blender-gen", async (c) => {
+ if (!RUNPOD_API_KEY) return c.json({ error: "RUNPOD_API_KEY not configured" }, 503);
+
+ const { prompt } = await c.req.json();
+ if (!prompt) return c.json({ error: "prompt required" }, 400);
+
+ // Step 1: Generate Blender Python script via LLM
+ const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434";
+ let script = "";
+
+ try {
+ const llmRes = await fetch(`${OLLAMA_URL}/api/generate`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ model: process.env.OLLAMA_MODEL || "llama3.1",
+ prompt: `Generate a Blender Python script that creates: ${prompt}\n\nThe script should:\n- Import bpy\n- Clear the default scene\n- Create the described objects with materials\n- Set up basic lighting and camera\n- Render to /tmp/render.png at 1024x1024\n\nOnly output the Python code, no explanations.`,
+ stream: false,
+ }),
+ });
+
+ if (llmRes.ok) {
+ const llmData = await llmRes.json();
+ script = llmData.response || "";
+ // Extract code block if wrapped in markdown
+ const codeMatch = script.match(/```python\n([\s\S]*?)```/);
+ if (codeMatch) script = codeMatch[1];
+ }
+ } catch (e) {
+ console.error("[blender-gen] LLM error:", e);
+ }
+
+ if (!script) {
+ return c.json({ error: "Failed to generate Blender script" }, 502);
+ }
+
+ // Step 2: Execute on RunPod (headless Blender)
+ try {
+ const runpodRes = await fetch("https://api.runpod.ai/v2/blender/runsync", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${RUNPOD_API_KEY}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ input: {
+ script,
+ render: true,
+ },
+ }),
+ });
+
+ if (!runpodRes.ok) {
+ // Return just the script if RunPod fails
+ return c.json({ script, error_detail: "RunPod execution failed" });
+ }
+
+ const runpodData = await runpodRes.json();
+ return c.json({
+ render_url: runpodData.output?.render_url || null,
+ script,
+ blend_url: runpodData.output?.blend_url || null,
+ });
+ } catch (e) {
+ // Return the script even if RunPod is unavailable
+ return c.json({ script, error_detail: "RunPod unavailable" });
+ }
+});
+
+// KiCAD PCB design — REST-to-MCP bridge
+const KICAD_MCP_URL = process.env.KICAD_MCP_URL || "http://localhost:3001";
+
+app.post("/api/kicad/:action", async (c) => {
+ const action = c.req.param("action");
+ const body = await c.req.json();
+
+ const validActions = [
+ "create_project", "add_schematic_component", "add_schematic_connection",
+ "export_svg", "run_drc", "export_gerber", "export_bom", "export_pdf",
+ "search_symbols", "search_footprints", "place_component",
+ ];
+
+ if (!validActions.includes(action)) {
+ return c.json({ error: `Unknown action: ${action}` }, 400);
+ }
+
+ try {
+ const mcpRes = await fetch(`${KICAD_MCP_URL}/call-tool`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ name: action,
+ arguments: body,
+ }),
+ });
+
+ if (!mcpRes.ok) {
+ const err = await mcpRes.text();
+ console.error(`[kicad/${action}] MCP error:`, err);
+ return c.json({ error: `KiCAD action failed: ${action}` }, 502);
+ }
+
+ const data = await mcpRes.json();
+ return c.json(data);
+ } catch (e) {
+ console.error(`[kicad/${action}] Connection error:`, e);
+ return c.json({ error: "KiCAD MCP server not available" }, 503);
+ }
+});
+
+// FreeCAD parametric CAD — REST-to-MCP bridge
+const FREECAD_MCP_URL = process.env.FREECAD_MCP_URL || "http://localhost:3002";
+
+app.post("/api/freecad/:action", async (c) => {
+ const action = c.req.param("action");
+ const body = await c.req.json();
+
+ const validActions = [
+ "generate", "export_step", "export_stl", "update_parameters",
+ ];
+
+ if (!validActions.includes(action)) {
+ return c.json({ error: `Unknown action: ${action}` }, 400);
+ }
+
+ try {
+ const mcpRes = await fetch(`${FREECAD_MCP_URL}/call-tool`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ name: action,
+ arguments: body,
+ }),
+ });
+
+ if (!mcpRes.ok) {
+ const err = await mcpRes.text();
+ console.error(`[freecad/${action}] MCP error:`, err);
+ return c.json({ error: `FreeCAD action failed: ${action}` }, 502);
+ }
+
+ const data = await mcpRes.json();
+ return c.json(data);
+ } catch (e) {
+ console.error(`[freecad/${action}] Connection error:`, e);
+ return c.json({ error: "FreeCAD MCP server not available" }, 503);
+ }
+});
+
// ── Auto-provision personal space ──
app.post("/api/spaces/auto-provision", async (c) => {
const token = extractToken(c.req.raw.headers);
@@ -646,6 +911,12 @@ const server = Bun.serve({
// ── Standalone domain → internal rewrite to module routes ──
const standaloneModuleId = domainToModule.get(hostClean);
if (standaloneModuleId) {
+ // Self-fetch detection: landing proxy uses this User-Agent;
+ // return 404 to break circular fetch so the generic fallback is used
+ if (req.headers.get("user-agent") === "rSpace-Proxy/1.0") {
+ return new Response("Not found", { status: 404 });
+ }
+
// Static assets pass through
if (url.pathname !== "/" && !url.pathname.startsWith("/api/") && !url.pathname.startsWith("/ws/")) {
const assetPath = url.pathname.slice(1);
@@ -655,7 +926,20 @@ const server = Bun.serve({
}
}
- // Rewrite path internally: / → /demo/{moduleId}
+ // Root path → serve landing page (not the module app)
+ if (url.pathname === "/") {
+ const allModules = getAllModules();
+ const mod = allModules.find((m) => m.id === standaloneModuleId);
+ if (mod) {
+ const html = renderModuleLanding({
+ module: mod,
+ modules: getModuleInfoList(),
+ });
+ return new Response(html, { headers: { "Content-Type": "text/html" } });
+ }
+ }
+
+ // Sub-paths: rewrite internally → /{space}/{moduleId}/...
const pathParts = url.pathname.split("/").filter(Boolean);
let space = "demo";
let suffix = "";
diff --git a/server/shell.ts b/server/shell.ts
index 50f2cee..42b0ddd 100644
--- a/server/shell.ts
+++ b/server/shell.ts
@@ -45,7 +45,7 @@ export function renderShell(opts: ShellOptions): string {
} = opts;
const moduleListJSON = JSON.stringify(modules);
- const shellDemoUrl = `https://rspace.online/${escapeAttr(moduleId)}`;
+ const shellDemoUrl = `https://demo.rspace.online/${escapeAttr(moduleId)}`;
return `
diff --git a/shared/components/rstack-app-switcher.ts b/shared/components/rstack-app-switcher.ts
index 7248d9b..2d82152 100644
--- a/shared/components/rstack-app-switcher.ts
+++ b/shared/components/rstack-app-switcher.ts
@@ -101,7 +101,7 @@ const CATEGORY_ORDER = [
"Identity & Infrastructure",
];
-import { rspaceNavUrl, getCurrentSpace } from "../url-helpers";
+import { rspaceNavUrl, getCurrentSpace, isStandaloneDomain } from "../url-helpers";
export class RStackAppSwitcher extends HTMLElement {
#shadow: ShadowRoot;
@@ -187,11 +187,11 @@ export class RStackAppSwitcher extends HTMLElement {
: `${m.icon}`;
const space = this.#getSpaceSlug();
- // On demo (bare domain or demo subdomain): link to landing pages
+ // On demo (bare domain, demo subdomain, or standalone r*.online): link to landing pages
const host = window.location.host.split(":")[0];
const onRspace = host.includes("rspace.online");
const href =
- onRspace && space === "demo"
+ (onRspace && space === "demo") || isStandaloneDomain()
? `${window.location.protocol}//rspace.online/${m.id}`
: rspaceNavUrl(space, m.id);
diff --git a/shared/module.ts b/shared/module.ts
index 92df586..079afd7 100644
--- a/shared/module.ts
+++ b/shared/module.ts
@@ -50,6 +50,8 @@ export interface RSpaceModule {
onSpaceCreate?: (spaceSlug: string) => Promise;
/** Called when a space is deleted (e.g. to clean up module-specific data) */
onSpaceDelete?: (spaceSlug: string) => Promise;
+ /** If true, module is hidden from app switcher (still has routes) */
+ hidden?: boolean;
}
/** Registry of all loaded modules */
@@ -76,16 +78,19 @@ export interface ModuleInfo {
standaloneDomain?: string;
feeds?: FeedDefinition[];
acceptsFeeds?: FlowKind[];
+ hidden?: boolean;
}
export function getModuleInfoList(): ModuleInfo[] {
- return getAllModules().map((m) => ({
- id: m.id,
- name: m.name,
- icon: m.icon,
- description: m.description,
- ...(m.standaloneDomain ? { standaloneDomain: m.standaloneDomain } : {}),
- ...(m.feeds ? { feeds: m.feeds } : {}),
- ...(m.acceptsFeeds ? { acceptsFeeds: m.acceptsFeeds } : {}),
- }));
+ return getAllModules()
+ .filter((m) => !m.hidden)
+ .map((m) => ({
+ id: m.id,
+ name: m.name,
+ icon: m.icon,
+ description: m.description,
+ ...(m.standaloneDomain ? { standaloneDomain: m.standaloneDomain } : {}),
+ ...(m.feeds ? { feeds: m.feeds } : {}),
+ ...(m.acceptsFeeds ? { acceptsFeeds: m.acceptsFeeds } : {}),
+ }));
}
diff --git a/shared/url-helpers.ts b/shared/url-helpers.ts
index fa0eb1f..8247b9b 100644
--- a/shared/url-helpers.ts
+++ b/shared/url-helpers.ts
@@ -24,13 +24,19 @@ export function isBareDomain(): boolean {
return host === "rspace.online" || host === "www.rspace.online";
}
+/** Detect if the current page is on a standalone r*.online domain (e.g. rvote.online) */
+export function isStandaloneDomain(): boolean {
+ const host = window.location.host.split(":")[0];
+ return /^r[a-z]+\.online$/.test(host) && host !== "rspace.online";
+}
+
/** Get the current space from subdomain or path */
export function getCurrentSpace(): string {
if (isSubdomain()) {
return window.location.host.split(":")[0].split(".")[0];
}
- // Bare domain: space is implicit (demo by default, until auto-provision)
- if (isBareDomain()) {
+ // Bare domain or standalone domain: space is implicit (demo)
+ if (isBareDomain() || isStandaloneDomain()) {
return "demo";
}
// Path-based (localhost): /{space}/{moduleId}
@@ -68,6 +74,14 @@ export function rspaceNavUrl(space: string, moduleId: string): string {
hostParts.slice(-2).join(".") === "rspace.online" &&
!RESERVED_SUBDOMAINS.includes(hostParts[0]);
+ // Standalone r*.online domains → redirect to rspace.online for navigation
+ if (isStandaloneDomain()) {
+ if (space === "demo") {
+ return `${window.location.protocol}//demo.rspace.online/${moduleId}`;
+ }
+ return `${window.location.protocol}//${space}.rspace.online/${moduleId}`;
+ }
+
if (onSubdomain) {
// Same space → just change the path
if (hostParts[0] === space) {
diff --git a/vite.config.ts b/vite.config.ts
index 8017154..8ce5dc4 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -160,33 +160,6 @@ export default defineConfig({
resolve(__dirname, "dist/modules/cart/cart.css"),
);
- // Build providers module component
- await build({
- configFile: false,
- root: resolve(__dirname, "modules/providers/components"),
- build: {
- emptyOutDir: false,
- outDir: resolve(__dirname, "dist/modules/providers"),
- lib: {
- entry: resolve(__dirname, "modules/providers/components/folk-provider-directory.ts"),
- formats: ["es"],
- fileName: () => "folk-provider-directory.js",
- },
- rollupOptions: {
- output: {
- entryFileNames: "folk-provider-directory.js",
- },
- },
- },
- });
-
- // Copy providers CSS
- mkdirSync(resolve(__dirname, "dist/modules/providers"), { recursive: true });
- copyFileSync(
- resolve(__dirname, "modules/providers/components/providers.css"),
- resolve(__dirname, "dist/modules/providers/providers.css"),
- );
-
// Build swag module component
await build({
configFile: false,
diff --git a/website/canvas.html b/website/canvas.html
index d1ab1d9..4a80a00 100644
--- a/website/canvas.html
+++ b/website/canvas.html
@@ -403,6 +403,11 @@
folk-choice-rank,
folk-choice-spider,
folk-social-post,
+ folk-splat,
+ folk-blender,
+ folk-drawfast,
+ folk-freecad,
+ folk-kicad,
folk-rapp {
position: absolute;
}
@@ -414,7 +419,8 @@
folk-itinerary, folk-destination, folk-budget, folk-packing-list,
folk-booking, folk-token-mint, folk-token-ledger,
folk-choice-vote, folk-choice-rank, folk-choice-spider,
- folk-social-post, folk-rapp) {
+ folk-social-post, folk-splat, folk-blender, folk-drawfast,
+ folk-freecad, folk-kicad, folk-rapp) {
cursor: crosshair;
}
@@ -425,7 +431,8 @@
folk-itinerary, folk-destination, folk-budget, folk-packing-list,
folk-booking, folk-token-mint, folk-token-ledger,
folk-choice-vote, folk-choice-rank, folk-choice-spider,
- folk-social-post, folk-rapp):hover {
+ folk-social-post, folk-splat, folk-blender, folk-drawfast,
+ folk-freecad, folk-kicad, folk-rapp):hover {
outline: 2px dashed #3b82f6;
outline-offset: 4px;
}
@@ -608,10 +615,21 @@
+
+
@@ -740,6 +757,11 @@
FolkChoiceRank,
FolkChoiceSpider,
FolkSocialPost,
+ FolkSplat,
+ FolkBlender,
+ FolkDrawfast,
+ FolkFreeCAD,
+ FolkKiCAD,
FolkCanvas,
FolkRApp,
FolkFeed,
@@ -853,6 +875,11 @@
FolkChoiceRank.define();
FolkChoiceSpider.define();
FolkSocialPost.define();
+ FolkSplat.define();
+ FolkBlender.define();
+ FolkDrawfast.define();
+ FolkFreeCAD.define();
+ FolkKiCAD.define();
FolkCanvas.define();
FolkRApp.define();
FolkFeed.define();
@@ -908,6 +935,8 @@
"folk-token-mint", "folk-token-ledger",
"folk-choice-vote", "folk-choice-rank", "folk-choice-spider",
"folk-social-post",
+ "folk-splat", "folk-blender", "folk-drawfast",
+ "folk-freecad", "folk-kicad",
"folk-rapp",
"folk-feed"
].join(", ");
@@ -1301,6 +1330,22 @@
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-canvas":
shape = document.createElement("folk-canvas");
shape.parentSlug = communitySlug; // pass parent context for nest-from
@@ -1394,6 +1439,11 @@
"folk-choice-rank": { width: 380, height: 480 },
"folk-choice-spider": { width: 440, height: 540 },
"folk-social-post": { width: 300, height: 380 },
+ "folk-splat": { width: 480, height: 420 },
+ "folk-blender": { width: 420, height: 520 },
+ "folk-drawfast": { width: 500, height: 480 },
+ "folk-freecad": { width: 400, height: 480 },
+ "folk-kicad": { width: 420, height: 500 },
"folk-canvas": { width: 600, height: 400 },
"folk-rapp": { width: 500, height: 400 },
"folk-feed": { width: 280, height: 360 },
@@ -1549,6 +1599,11 @@
document.getElementById("new-video-chat").addEventListener("click", () => newShape("folk-video-chat"));
document.getElementById("new-obs-note").addEventListener("click", () => newShape("folk-obs-note"));
document.getElementById("new-workflow").addEventListener("click", () => newShape("folk-workflow-block"));
+ document.getElementById("new-splat").addEventListener("click", () => newShape("folk-splat"));
+ document.getElementById("new-blender").addEventListener("click", () => newShape("folk-blender"));
+ document.getElementById("new-drawfast").addEventListener("click", () => newShape("folk-drawfast"));
+ document.getElementById("new-freecad").addEventListener("click", () => newShape("folk-freecad"));
+ document.getElementById("new-kicad").addEventListener("click", () => newShape("folk-kicad"));
document.getElementById("new-google-item").addEventListener("click", () => {
newShape("folk-google-item", { service: "drive", title: "New Google Item" });
});
@@ -1664,7 +1719,6 @@
{ btnId: "embed-cart", moduleId: "rcart" },
{ btnId: "embed-data", moduleId: "rdata" },
{ btnId: "embed-network", moduleId: "rnetwork" },
- { btnId: "embed-splat", moduleId: "rsplat" },
{ btnId: "embed-swag", moduleId: "rswag" },
];