diff --git a/backlog/tasks/task-2 - Phase-1-FolkJS-Foundation-Port-Simple-Shapes.md b/backlog/tasks/task-2 - Phase-1-FolkJS-Foundation-Port-Simple-Shapes.md
index 066b3f6..314847a 100644
--- a/backlog/tasks/task-2 - Phase-1-FolkJS-Foundation-Port-Simple-Shapes.md
+++ b/backlog/tasks/task-2 - Phase-1-FolkJS-Foundation-Port-Simple-Shapes.md
@@ -1,9 +1,10 @@
---
id: task-2
title: 'Phase 1: FolkJS Foundation - Port Simple Shapes'
-status: To Do
+status: Done
assignee: []
created_date: '2026-01-02 14:42'
+updated_date: '2026-01-02 19:00'
labels:
- foundation
- migration
@@ -30,8 +31,46 @@ Key simplifications vs tldraw:
## Acceptance Criteria
-- [ ] #1 folk-slide component created
-- [ ] #2 folk-chat component created
-- [ ] #3 folk-google-item component created
-- [ ] #4 folk-piano component created
+- [x] #1 folk-slide component created
+- [x] #2 folk-chat component created
+- [x] #3 folk-google-item component created
+- [x] #4 folk-piano component created
+
+## Notes
+
+### Implementation Complete
+
+Created 4 FolkJS web components:
+
+1. **folk-slide.ts** (`lib/folk-slide.ts`)
+ - Simple slide container with dashed border
+ - Label display (e.g., "Slide 1")
+ - Minimal implementation for presentation mode
+
+2. **folk-chat.ts** (`lib/folk-chat.ts`)
+ - Real-time chat with message list
+ - Username prompt with localStorage persistence
+ - Message input form with send button
+ - Orange header theme matching original
+ - Emits `message` events for sync integration
+
+3. **folk-google-item.ts** (`lib/folk-google-item.ts`)
+ - Data card for Gmail/Drive/Photos/Calendar items
+ - Visibility toggle (local/shared)
+ - Service icons and relative date formatting
+ - Dark mode support
+ - Helper function `createGoogleItemProps()`
+
+4. **folk-piano.ts** (`lib/folk-piano.ts`)
+ - Chrome Music Lab Shared Piano iframe embed
+ - Loading/error states with retry
+ - Minimize/expand toggle
+ - Sandboxed with audio/MIDI permissions
+
+All components:
+- Extend FolkShape base class
+- Use CSS-in-JS via template literals
+- Support drag from header/container via `data-drag`
+- Implement `toJSON()` for Automerge sync
+- Registered in `canvas.html` with toolbar buttons
diff --git a/lib/folk-chat.ts b/lib/folk-chat.ts
new file mode 100644
index 0000000..40a622a
--- /dev/null
+++ b/lib/folk-chat.ts
@@ -0,0 +1,360 @@
+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: 300px;
+ min-height: 400px;
+ }
+
+ .header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 12px;
+ background: #f97316;
+ 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);
+ }
+
+ .chat-container {
+ display: flex;
+ flex-direction: column;
+ height: calc(100% - 36px);
+ }
+
+ .messages {
+ flex: 1;
+ overflow-y: auto;
+ padding: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ .message {
+ background: #f1f5f9;
+ border-radius: 8px;
+ padding: 8px 12px;
+ max-width: 85%;
+ }
+
+ .message.own {
+ background: #f97316;
+ color: white;
+ align-self: flex-end;
+ }
+
+ .message-header {
+ display: flex;
+ justify-content: space-between;
+ font-size: 11px;
+ opacity: 0.7;
+ margin-bottom: 4px;
+ }
+
+ .message-content {
+ font-size: 14px;
+ line-height: 1.4;
+ }
+
+ .input-container {
+ display: flex;
+ gap: 8px;
+ padding: 12px;
+ border-top: 1px solid #e2e8f0;
+ }
+
+ .message-input {
+ flex: 1;
+ border: 1px solid #e2e8f0;
+ border-radius: 6px;
+ padding: 8px 12px;
+ font-size: 14px;
+ outline: none;
+ }
+
+ .message-input:focus {
+ border-color: #f97316;
+ }
+
+ .send-btn {
+ background: #f97316;
+ color: white;
+ border: none;
+ border-radius: 6px;
+ padding: 8px 16px;
+ cursor: pointer;
+ font-weight: 500;
+ }
+
+ .send-btn:hover {
+ background: #ea580c;
+ }
+
+ .username-prompt {
+ padding: 12px;
+ text-align: center;
+ }
+
+ .username-input {
+ border: 1px solid #e2e8f0;
+ border-radius: 6px;
+ padding: 8px 12px;
+ font-size: 14px;
+ outline: none;
+ margin-bottom: 8px;
+ width: 100%;
+ box-sizing: border-box;
+ }
+
+ .username-btn {
+ background: #f97316;
+ color: white;
+ border: none;
+ border-radius: 6px;
+ padding: 8px 16px;
+ cursor: pointer;
+ font-weight: 500;
+ }
+`;
+
+interface ChatMessage {
+ id: string;
+ userName: string;
+ content: string;
+ timestamp: number;
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "folk-chat": FolkChat;
+ }
+}
+
+export class FolkChat extends FolkShape {
+ static override tagName = "folk-chat";
+
+ 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;
+ }
+
+ #roomId = "default-room";
+ #userName = "";
+ #messages: ChatMessage[] = [];
+ #messagesEl: HTMLElement | null = null;
+
+ get roomId() {
+ return this.#roomId;
+ }
+
+ set roomId(value: string) {
+ this.#roomId = value;
+ this.requestUpdate("roomId");
+ }
+
+ get userName() {
+ return this.#userName;
+ }
+
+ set userName(value: string) {
+ this.#userName = value;
+ localStorage.setItem("folk-chat-username", value);
+ this.requestUpdate("userName");
+ }
+
+ get messages() {
+ return this.#messages;
+ }
+
+ override createRenderRoot() {
+ const root = super.createRenderRoot();
+
+ // Try to restore username from localStorage
+ this.#userName = localStorage.getItem("folk-chat-username") || "";
+ this.#roomId = this.getAttribute("room-id") || "default-room";
+
+ const wrapper = document.createElement("div");
+ wrapper.innerHTML = html`
+
+
+
+
Enter your name to join the chat:
+
+
+
+ `;
+
+ const slot = root.querySelector("slot");
+ if (slot?.parentElement) {
+ const parent = slot.parentElement;
+ const existingDiv = parent.querySelector("div");
+ if (existingDiv) {
+ parent.replaceChild(wrapper, existingDiv);
+ }
+ }
+
+ // Get element references
+ this.#messagesEl = wrapper.querySelector(".messages");
+ const chatContainer = wrapper.querySelector(".chat-container") as HTMLElement;
+ const usernamePrompt = wrapper.querySelector(".username-prompt") as HTMLElement;
+ const messageInput = wrapper.querySelector(".message-input") as HTMLInputElement;
+ const sendBtn = wrapper.querySelector(".send-btn") as HTMLButtonElement;
+ const usernameInput = wrapper.querySelector(".username-input") as HTMLInputElement;
+ const usernameBtn = wrapper.querySelector(".username-btn") as HTMLButtonElement;
+ const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
+
+ // Show username prompt if not set
+ if (!this.#userName) {
+ chatContainer.style.display = "none";
+ usernamePrompt.style.display = "block";
+ }
+
+ // Username submit
+ const submitUsername = () => {
+ const name = usernameInput.value.trim();
+ if (name) {
+ this.userName = name;
+ chatContainer.style.display = "flex";
+ usernamePrompt.style.display = "none";
+ }
+ };
+
+ usernameBtn.addEventListener("click", submitUsername);
+ usernameInput.addEventListener("keydown", (e) => {
+ if (e.key === "Enter") submitUsername();
+ });
+
+ // Send message
+ const sendMessage = () => {
+ const content = messageInput.value.trim();
+ if (content && this.#userName) {
+ this.addMessage({
+ id: crypto.randomUUID(),
+ userName: this.#userName,
+ content,
+ timestamp: Date.now(),
+ });
+ messageInput.value = "";
+ }
+ };
+
+ sendBtn.addEventListener("click", sendMessage);
+ messageInput.addEventListener("keydown", (e) => {
+ if (e.key === "Enter") sendMessage();
+ });
+
+ // Close button
+ closeBtn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ this.dispatchEvent(new CustomEvent("close"));
+ });
+
+ return root;
+ }
+
+ addMessage(message: ChatMessage) {
+ this.#messages.push(message);
+ this.#renderMessages();
+ this.dispatchEvent(
+ new CustomEvent("message", {
+ detail: { message },
+ bubbles: true,
+ })
+ );
+ }
+
+ #renderMessages() {
+ if (!this.#messagesEl) return;
+
+ this.#messagesEl.innerHTML = this.#messages
+ .map((msg) => {
+ const isOwn = msg.userName === this.#userName;
+ const time = new Date(msg.timestamp).toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+ return `
+
+
+
${this.#escapeHtml(msg.content)}
+
+ `;
+ })
+ .join("");
+
+ // Scroll to bottom
+ this.#messagesEl.scrollTop = this.#messagesEl.scrollHeight;
+ }
+
+ #escapeHtml(text: string): string {
+ const div = document.createElement("div");
+ div.textContent = text;
+ return div.innerHTML;
+ }
+
+ override toJSON() {
+ return {
+ ...super.toJSON(),
+ type: "folk-chat",
+ roomId: this.roomId,
+ messages: this.messages,
+ };
+ }
+}
diff --git a/lib/folk-google-item.ts b/lib/folk-google-item.ts
new file mode 100644
index 0000000..3cea812
--- /dev/null
+++ b/lib/folk-google-item.ts
@@ -0,0 +1,321 @@
+import { FolkShape } from "./folk-shape";
+import { css, html } from "./tags";
+
+export type GoogleService = "gmail" | "drive" | "photos" | "calendar";
+export type ItemVisibility = "local" | "shared";
+
+const SERVICE_ICONS: Record = {
+ gmail: "\u{1F4E7}",
+ drive: "\u{1F4C1}",
+ photos: "\u{1F4F7}",
+ calendar: "\u{1F4C5}",
+};
+
+const styles = css`
+ :host {
+ background: white;
+ border-radius: 8px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ min-width: 180px;
+ min-height: 60px;
+ }
+
+ .card {
+ width: 100%;
+ height: 100%;
+ padding: 12px;
+ box-sizing: border-box;
+ position: relative;
+ cursor: move;
+ }
+
+ .card.local {
+ border: 2px dashed #6366f1;
+ }
+
+ .card.shared {
+ border: 2px solid #22c55e;
+ }
+
+ .visibility-badge {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ font-size: 12px;
+ cursor: pointer;
+ }
+
+ .visibility-badge:hover {
+ transform: scale(1.2);
+ }
+
+ .header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 8px;
+ }
+
+ .service-icon {
+ font-size: 20px;
+ }
+
+ .title {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ font-size: 14px;
+ font-weight: 600;
+ color: #1e293b;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ flex: 1;
+ }
+
+ .preview {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ font-size: 12px;
+ color: #64748b;
+ line-height: 1.4;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ }
+
+ .date {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ font-size: 11px;
+ color: #94a3b8;
+ margin-top: 8px;
+ }
+
+ .thumbnail {
+ width: 100%;
+ height: 80px;
+ object-fit: cover;
+ border-radius: 4px;
+ margin-top: 8px;
+ }
+
+ /* Dark mode support */
+ @media (prefers-color-scheme: dark) {
+ :host {
+ background: #1e293b;
+ }
+ .title {
+ color: #f1f5f9;
+ }
+ .preview {
+ color: #94a3b8;
+ }
+ }
+`;
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "folk-google-item": FolkGoogleItem;
+ }
+}
+
+export class FolkGoogleItem extends FolkShape {
+ static override tagName = "folk-google-item";
+
+ 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;
+ }
+
+ #itemId = "";
+ #service: GoogleService = "drive";
+ #title = "Untitled";
+ #preview = "";
+ #date: number = Date.now();
+ #thumbnailUrl = "";
+ #visibility: ItemVisibility = "local";
+
+ get itemId() {
+ return this.#itemId;
+ }
+ set itemId(value: string) {
+ this.#itemId = value;
+ this.requestUpdate("itemId");
+ }
+
+ get service() {
+ return this.#service;
+ }
+ set service(value: GoogleService) {
+ this.#service = value;
+ this.requestUpdate("service");
+ }
+
+ get title() {
+ return this.#title;
+ }
+ set title(value: string) {
+ this.#title = value;
+ this.requestUpdate("title");
+ }
+
+ get preview() {
+ return this.#preview;
+ }
+ set preview(value: string) {
+ this.#preview = value;
+ this.requestUpdate("preview");
+ }
+
+ get date() {
+ return this.#date;
+ }
+ set date(value: number) {
+ this.#date = value;
+ this.requestUpdate("date");
+ }
+
+ get thumbnailUrl() {
+ return this.#thumbnailUrl;
+ }
+ set thumbnailUrl(value: string) {
+ this.#thumbnailUrl = value;
+ this.requestUpdate("thumbnailUrl");
+ }
+
+ get visibility() {
+ return this.#visibility;
+ }
+ set visibility(value: ItemVisibility) {
+ this.#visibility = value;
+ this.requestUpdate("visibility");
+ }
+
+ override createRenderRoot() {
+ const root = super.createRenderRoot();
+
+ // Read attributes
+ this.#itemId = this.getAttribute("item-id") || "";
+ this.#service = (this.getAttribute("service") as GoogleService) || "drive";
+ this.#title = this.getAttribute("title") || "Untitled";
+ this.#preview = this.getAttribute("preview") || "";
+ this.#date = parseInt(this.getAttribute("date") || String(Date.now()), 10);
+ this.#thumbnailUrl = this.getAttribute("thumbnail-url") || "";
+ this.#visibility = (this.getAttribute("visibility") as ItemVisibility) || "local";
+
+ const wrapper = document.createElement("div");
+ wrapper.innerHTML = html`
+
+
+ ${this.#visibility === "local" ? "\u{1F512}" : "\u{1F310}"}
+
+
+ ${this.#preview ? `
${this.#escapeHtml(this.#preview)}
` : ""}
+
${this.#formatDate(this.#date)}
+ ${
+ this.#thumbnailUrl && this.height > 100
+ ? `

`
+ : ""
+ }
+
+ `;
+
+ const slot = root.querySelector("slot");
+ if (slot?.parentElement) {
+ const parent = slot.parentElement;
+ const existingDiv = parent.querySelector("div");
+ if (existingDiv) {
+ parent.replaceChild(wrapper.querySelector(".card")!, existingDiv);
+ }
+ }
+
+ // Toggle visibility on badge click
+ const badge = root.querySelector(".visibility-badge") as HTMLElement;
+ const card = root.querySelector(".card") as HTMLElement;
+
+ badge?.addEventListener("click", (e) => {
+ e.stopPropagation();
+ this.#visibility = this.#visibility === "local" ? "shared" : "local";
+
+ badge.textContent = this.#visibility === "local" ? "\u{1F512}" : "\u{1F310}";
+ card.classList.remove("local", "shared");
+ card.classList.add(this.#visibility);
+
+ this.dispatchEvent(
+ new CustomEvent("visibility-change", {
+ detail: { visibility: this.#visibility, itemId: this.#itemId },
+ bubbles: true,
+ })
+ );
+ });
+
+ return root;
+ }
+
+ #escapeHtml(text: string): string {
+ const div = document.createElement("div");
+ div.textContent = text;
+ return div.innerHTML;
+ }
+
+ #formatDate(timestamp: number): string {
+ const now = new Date();
+ const date = new Date(timestamp);
+ const diffDays = Math.floor(
+ (now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24)
+ );
+
+ if (diffDays === 0) return "Today";
+ if (diffDays === 1) return "Yesterday";
+ if (diffDays < 7) return `${diffDays}d ago`;
+ return date.toLocaleDateString();
+ }
+
+ override toJSON() {
+ return {
+ ...super.toJSON(),
+ type: "folk-google-item",
+ itemId: this.itemId,
+ service: this.service,
+ title: this.title,
+ preview: this.preview,
+ date: this.date,
+ thumbnailUrl: this.thumbnailUrl,
+ visibility: this.visibility,
+ };
+ }
+}
+
+/**
+ * Helper to create Google item props
+ */
+export function createGoogleItemProps(
+ service: GoogleService,
+ title: string,
+ options: Partial<{
+ itemId: string;
+ preview: string;
+ date: number;
+ thumbnailUrl: string;
+ visibility: ItemVisibility;
+ }> = {}
+) {
+ return {
+ service,
+ title,
+ itemId: options.itemId || crypto.randomUUID(),
+ preview: options.preview || "",
+ date: options.date || Date.now(),
+ thumbnailUrl: options.thumbnailUrl || "",
+ visibility: options.visibility || "local",
+ };
+}
diff --git a/lib/folk-piano.ts b/lib/folk-piano.ts
new file mode 100644
index 0000000..09223b5
--- /dev/null
+++ b/lib/folk-piano.ts
@@ -0,0 +1,291 @@
+import { FolkShape } from "./folk-shape";
+import { css, html } from "./tags";
+
+const PIANO_URL = "https://musiclab.chromeexperiments.com/Shared-Piano/";
+
+const styles = css`
+ :host {
+ background: #1e1e2e;
+ border-radius: 8px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
+ min-width: 400px;
+ min-height: 300px;
+ overflow: hidden;
+ }
+
+ .piano-container {
+ width: 100%;
+ height: 100%;
+ position: relative;
+ }
+
+ .piano-iframe {
+ width: 100%;
+ height: 100%;
+ border: none;
+ }
+
+ .loading {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: linear-gradient(135deg, #1e1e2e 0%, #2d2d44 100%);
+ color: white;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ font-size: 18px;
+ gap: 12px;
+ }
+
+ .loading.hidden {
+ display: none;
+ }
+
+ .error {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ background: linear-gradient(135deg, #1e1e2e 0%, #2d2d44 100%);
+ color: white;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ gap: 12px;
+ }
+
+ .error.hidden {
+ display: none;
+ }
+
+ .error-message {
+ font-size: 14px;
+ color: #f87171;
+ }
+
+ .retry-btn {
+ background: #6366f1;
+ color: white;
+ border: none;
+ border-radius: 6px;
+ padding: 8px 16px;
+ cursor: pointer;
+ font-size: 14px;
+ }
+
+ .retry-btn:hover {
+ background: #4f46e5;
+ }
+
+ .controls {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ display: flex;
+ gap: 4px;
+ }
+
+ .control-btn {
+ background: rgba(0, 0, 0, 0.5);
+ color: white;
+ border: none;
+ border-radius: 4px;
+ padding: 4px 8px;
+ cursor: pointer;
+ font-size: 14px;
+ }
+
+ .control-btn:hover {
+ background: rgba(0, 0, 0, 0.7);
+ }
+
+ .minimized {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: linear-gradient(135deg, #1e1e2e 0%, #2d2d44 100%);
+ color: white;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ font-size: 24px;
+ cursor: pointer;
+ }
+
+ .minimized.hidden {
+ display: none;
+ }
+`;
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "folk-piano": FolkPiano;
+ }
+}
+
+export class FolkPiano extends FolkShape {
+ static override tagName = "folk-piano";
+
+ 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;
+ }
+
+ #isMinimized = false;
+ #isLoading = true;
+ #hasError = false;
+ #iframe: HTMLIFrameElement | null = null;
+ #loadingEl: HTMLElement | null = null;
+ #errorEl: HTMLElement | null = null;
+ #minimizedEl: HTMLElement | null = null;
+ #containerEl: HTMLElement | null = null;
+
+ get isMinimized() {
+ return this.#isMinimized;
+ }
+
+ set isMinimized(value: boolean) {
+ this.#isMinimized = value;
+ this.#updateVisibility();
+ this.requestUpdate("isMinimized");
+ }
+
+ override createRenderRoot() {
+ const root = super.createRenderRoot();
+
+ const wrapper = document.createElement("div");
+ wrapper.innerHTML = html`
+
+
+ \u{1F3B9}
+ Loading Shared Piano...
+
+
+ \u{1F3B9}
+ Failed to load piano
+
+
+
+ \u{1F3B9} Shared Piano
+
+
+
+
+
+
+ `;
+
+ const slot = root.querySelector("slot");
+ if (slot?.parentElement) {
+ const parent = slot.parentElement;
+ const existingDiv = parent.querySelector("div");
+ if (existingDiv) {
+ parent.replaceChild(wrapper.querySelector(".piano-container")!, existingDiv);
+ }
+ }
+
+ // Get references
+ this.#containerEl = root.querySelector(".piano-container");
+ this.#loadingEl = root.querySelector(".loading");
+ this.#errorEl = root.querySelector(".error");
+ this.#minimizedEl = root.querySelector(".minimized");
+ this.#iframe = root.querySelector(".piano-iframe");
+ const minimizeBtn = root.querySelector(".minimize-btn") as HTMLButtonElement;
+ const retryBtn = root.querySelector(".retry-btn") as HTMLButtonElement;
+
+ // Iframe load handling
+ this.#iframe?.addEventListener("load", () => {
+ this.#isLoading = false;
+ this.#hasError = false;
+ if (this.#loadingEl) this.#loadingEl.classList.add("hidden");
+ if (this.#iframe) this.#iframe.style.opacity = "1";
+ });
+
+ this.#iframe?.addEventListener("error", () => {
+ this.#isLoading = false;
+ this.#hasError = true;
+ if (this.#loadingEl) this.#loadingEl.classList.add("hidden");
+ if (this.#errorEl) this.#errorEl.classList.remove("hidden");
+ });
+
+ // Minimize toggle
+ minimizeBtn?.addEventListener("click", (e) => {
+ e.stopPropagation();
+ this.isMinimized = !this.#isMinimized;
+ minimizeBtn.textContent = this.#isMinimized ? "\u{1F53D}" : "\u{1F53C}";
+ });
+
+ // Click minimized view to expand
+ this.#minimizedEl?.addEventListener("click", (e) => {
+ e.stopPropagation();
+ this.isMinimized = false;
+ if (minimizeBtn) minimizeBtn.textContent = "\u{1F53C}";
+ });
+
+ // Retry button
+ retryBtn?.addEventListener("click", (e) => {
+ e.stopPropagation();
+ this.#retry();
+ });
+
+ // Suppress Chrome Music Lab console errors
+ window.addEventListener("error", (e) => {
+ if (e.message?.includes("musiclab") || e.filename?.includes("musiclab")) {
+ e.preventDefault();
+ }
+ });
+
+ return root;
+ }
+
+ #updateVisibility() {
+ if (!this.#iframe || !this.#minimizedEl) return;
+
+ if (this.#isMinimized) {
+ this.#iframe.style.display = "none";
+ this.#minimizedEl.classList.remove("hidden");
+ } else {
+ this.#iframe.style.display = "block";
+ this.#minimizedEl.classList.add("hidden");
+ }
+ }
+
+ #retry() {
+ if (!this.#iframe || !this.#errorEl || !this.#loadingEl) return;
+
+ this.#hasError = false;
+ this.#isLoading = true;
+ this.#errorEl.classList.add("hidden");
+ this.#loadingEl.classList.remove("hidden");
+ this.#iframe.style.opacity = "0";
+ this.#iframe.src = PIANO_URL;
+ }
+
+ override toJSON() {
+ return {
+ ...super.toJSON(),
+ type: "folk-piano",
+ isMinimized: this.isMinimized,
+ };
+ }
+}
diff --git a/lib/folk-slide.ts b/lib/folk-slide.ts
new file mode 100644
index 0000000..aff03b2
--- /dev/null
+++ b/lib/folk-slide.ts
@@ -0,0 +1,116 @@
+import { FolkShape } from "./folk-shape";
+import { css, html } from "./tags";
+
+const styles = css`
+ :host {
+ background: transparent;
+ min-width: 200px;
+ min-height: 150px;
+ }
+
+ .slide-container {
+ width: 100%;
+ height: 100%;
+ position: relative;
+ border: 2px dashed #94a3b8;
+ border-radius: 8px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ .slide-label {
+ position: absolute;
+ top: 8px;
+ left: 12px;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ font-size: 14px;
+ font-weight: 600;
+ color: #64748b;
+ background: white;
+ padding: 2px 8px;
+ border-radius: 4px;
+ }
+
+ .slide-content {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #94a3b8;
+ font-size: 48px;
+ }
+`;
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "folk-slide": FolkSlide;
+ }
+}
+
+export class FolkSlide extends FolkShape {
+ static override tagName = "folk-slide";
+
+ 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;
+ }
+
+ #label = "Slide 1";
+
+ get label() {
+ return this.#label;
+ }
+
+ set label(value: string) {
+ this.#label = value;
+ this.requestUpdate("label");
+ }
+
+ override createRenderRoot() {
+ const root = super.createRenderRoot();
+
+ const wrapper = document.createElement("div");
+ wrapper.innerHTML = html`
+
+ `;
+
+ const slot = root.querySelector("slot");
+ if (slot?.parentElement) {
+ slot.parentElement.replaceChild(
+ wrapper.querySelector(".slide-container")!,
+ slot.parentElement.querySelector("div")!
+ );
+ }
+
+ // Update label from attribute
+ this.#label = this.getAttribute("label") || "Slide 1";
+ const labelEl = root.querySelector(".slide-label") as HTMLElement;
+ if (labelEl) {
+ labelEl.textContent = this.#label;
+ }
+
+ return root;
+ }
+
+ override toJSON() {
+ return {
+ ...super.toJSON(),
+ type: "folk-slide",
+ label: this.label,
+ };
+ }
+}
diff --git a/lib/index.ts b/lib/index.ts
index 9e17ac4..13a5615 100644
--- a/lib/index.ts
+++ b/lib/index.ts
@@ -25,6 +25,10 @@ export * from "./folk-shape";
export * from "./folk-markdown";
export * from "./folk-wrapper";
export * from "./folk-arrow";
+export * from "./folk-slide";
+export * from "./folk-chat";
+export * from "./folk-google-item";
+export * from "./folk-piano";
// Sync
export * from "./community-sync";
diff --git a/website/canvas.html b/website/canvas.html
index 7415711..3ca6c5f 100644
--- a/website/canvas.html
+++ b/website/canvas.html
@@ -128,7 +128,11 @@
folk-markdown,
folk-wrapper,
- folk-arrow {
+ folk-arrow,
+ folk-slide,
+ folk-chat,
+ folk-google-item,
+ folk-piano {
position: absolute;
}
@@ -158,6 +162,9 @@