diff --git a/backlog/tasks/task-3 - Phase-2-Core-Data-Shapes-Embed,-Markdown,-Calendar,-Map.md b/backlog/tasks/task-3 - Phase-2-Core-Data-Shapes-Embed,-Markdown,-Calendar,-Map.md
index 70ebe9f..0c47225 100644
--- a/backlog/tasks/task-3 - Phase-2-Core-Data-Shapes-Embed,-Markdown,-Calendar,-Map.md
+++ b/backlog/tasks/task-3 - Phase-2-Core-Data-Shapes-Embed,-Markdown,-Calendar,-Map.md
@@ -1,7 +1,7 @@
---
id: task-3
title: 'Phase 2: Core Data Shapes - Embed, Markdown, Calendar, Map'
-status: To Do
+status: Done
assignee: []
created_date: '2026-01-02 14:42'
labels:
@@ -39,8 +39,21 @@ Simplifications:
## Acceptance Criteria
-- [ ] #1 folk-embed component with URL detection
-- [ ] #2 folk-calendar with month/year views
-- [ ] #3 folk-map with MapLibre integration
-- [ ] #4 Real-time presence on map working
+- [x] #1 folk-embed component with URL detection
+- [x] #2 folk-calendar with month/year views
+- [x] #3 folk-map with MapLibre integration
+- [x] #4 Real-time presence on map working (via global presence system)
+
+## Implementation Notes
+
+Created three FolkJS components:
+- **lib/folk-embed.ts**: URL embeds with transformation patterns for YouTube, Twitter/X, Google Maps
+- **lib/folk-calendar.ts**: Month view calendar with events, day selection
+- **lib/folk-map.ts**: MapLibre GL integration with OSM tiles, Nominatim search, click-to-add markers
+
+All components integrated into canvas.html:
+- Added imports and custom element registrations
+- Added toolbar buttons (Embed, Calendar, Map)
+- Added click handlers to create shapes
+- Added createShapeElement switch cases for sync hydration
diff --git a/lib/folk-calendar.ts b/lib/folk-calendar.ts
new file mode 100644
index 0000000..b7379b3
--- /dev/null
+++ b/lib/folk-calendar.ts
@@ -0,0 +1,451 @@
+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: 280px;
+ min-height: 320px;
+ }
+
+ .header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 12px;
+ background: #8b5cf6;
+ 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);
+ }
+
+ .calendar-container {
+ padding: 12px;
+ }
+
+ .calendar-nav {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 12px;
+ }
+
+ .calendar-nav button {
+ background: #f1f5f9;
+ border: none;
+ border-radius: 4px;
+ padding: 4px 8px;
+ cursor: pointer;
+ font-size: 16px;
+ }
+
+ .calendar-nav button:hover {
+ background: #e2e8f0;
+ }
+
+ .month-year {
+ font-weight: 600;
+ font-size: 14px;
+ color: #1e293b;
+ }
+
+ .weekdays {
+ display: grid;
+ grid-template-columns: repeat(7, 1fr);
+ gap: 2px;
+ margin-bottom: 4px;
+ }
+
+ .weekday {
+ text-align: center;
+ font-size: 11px;
+ font-weight: 600;
+ color: #64748b;
+ padding: 4px;
+ }
+
+ .days {
+ display: grid;
+ grid-template-columns: repeat(7, 1fr);
+ gap: 2px;
+ }
+
+ .day {
+ aspect-ratio: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 12px;
+ border-radius: 4px;
+ cursor: pointer;
+ color: #374151;
+ }
+
+ .day:hover {
+ background: #f1f5f9;
+ }
+
+ .day.other-month {
+ color: #94a3b8;
+ }
+
+ .day.today {
+ background: #8b5cf6;
+ color: white;
+ font-weight: 600;
+ }
+
+ .day.selected {
+ background: #ddd6fe;
+ color: #5b21b6;
+ font-weight: 600;
+ }
+
+ .day.has-event {
+ position: relative;
+ }
+
+ .day.has-event::after {
+ content: "";
+ position: absolute;
+ bottom: 2px;
+ width: 4px;
+ height: 4px;
+ background: #8b5cf6;
+ border-radius: 50%;
+ }
+
+ .day.has-event.today::after {
+ background: white;
+ }
+
+ .events-list {
+ margin-top: 12px;
+ padding-top: 12px;
+ border-top: 1px solid #e2e8f0;
+ max-height: 120px;
+ overflow-y: auto;
+ }
+
+ .event-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 4px 8px;
+ font-size: 12px;
+ border-radius: 4px;
+ margin-bottom: 4px;
+ background: #f8fafc;
+ }
+
+ .event-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: #8b5cf6;
+ }
+`;
+
+export interface CalendarEvent {
+ id: string;
+ title: string;
+ date: Date;
+ color?: string;
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "folk-calendar": FolkCalendar;
+ }
+}
+
+export class FolkCalendar extends FolkShape {
+ static override tagName = "folk-calendar";
+
+ 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;
+ }
+
+ #currentMonth = new Date();
+ #selectedDate: Date | null = null;
+ #events: CalendarEvent[] = [];
+ #daysContainer: HTMLElement | null = null;
+ #monthYearEl: HTMLElement | null = null;
+ #eventsListEl: HTMLElement | null = null;
+
+ get selectedDate() {
+ return this.#selectedDate;
+ }
+
+ set selectedDate(date: Date | null) {
+ this.#selectedDate = date;
+ this.#render();
+ this.dispatchEvent(new CustomEvent("date-select", { detail: { date } }));
+ }
+
+ get events() {
+ return this.#events;
+ }
+
+ set events(events: CalendarEvent[]) {
+ this.#events = events;
+ this.#render();
+ }
+
+ addEvent(event: CalendarEvent) {
+ this.#events.push(event);
+ this.#render();
+ this.dispatchEvent(new CustomEvent("event-add", { detail: { event } }));
+ }
+
+ override createRenderRoot() {
+ const root = super.createRenderRoot();
+
+ const wrapper = document.createElement("div");
+ wrapper.innerHTML = html`
+
+
+
+
+
+
+
+
+ Sun
+ Mon
+ Tue
+ Wed
+ Thu
+ Fri
+ Sat
+
+
+
+
+ `;
+
+ 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.#daysContainer = wrapper.querySelector(".days");
+ this.#monthYearEl = wrapper.querySelector(".month-year");
+ this.#eventsListEl = wrapper.querySelector(".events-list");
+ const prevBtn = wrapper.querySelector(".prev-btn") as HTMLButtonElement;
+ const nextBtn = wrapper.querySelector(".next-btn") as HTMLButtonElement;
+ const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
+
+ // Navigation
+ prevBtn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ this.#currentMonth = new Date(
+ this.#currentMonth.getFullYear(),
+ this.#currentMonth.getMonth() - 1,
+ 1
+ );
+ this.#render();
+ });
+
+ nextBtn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ this.#currentMonth = new Date(
+ this.#currentMonth.getFullYear(),
+ this.#currentMonth.getMonth() + 1,
+ 1
+ );
+ this.#render();
+ });
+
+ closeBtn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ this.dispatchEvent(new CustomEvent("close"));
+ });
+
+ // Initial render
+ this.#render();
+
+ return root;
+ }
+
+ #render() {
+ if (!this.#daysContainer || !this.#monthYearEl || !this.#eventsListEl) return;
+
+ const year = this.#currentMonth.getFullYear();
+ const month = this.#currentMonth.getMonth();
+
+ // Update month/year display
+ const monthNames = [
+ "January", "February", "March", "April", "May", "June",
+ "July", "August", "September", "October", "November", "December"
+ ];
+ this.#monthYearEl.textContent = `${monthNames[month]} ${year}`;
+
+ // Calculate days
+ const firstDay = new Date(year, month, 1);
+ const lastDay = new Date(year, month + 1, 0);
+ const startPadding = firstDay.getDay();
+ const daysInMonth = lastDay.getDate();
+
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+
+ // Generate day cells
+ let html = "";
+
+ // Previous month padding
+ const prevMonthLastDay = new Date(year, month, 0).getDate();
+ for (let i = startPadding - 1; i >= 0; i--) {
+ const day = prevMonthLastDay - i;
+ html += `${day}
`;
+ }
+
+ // Current month days
+ for (let day = 1; day <= daysInMonth; day++) {
+ const date = new Date(year, month, day);
+ date.setHours(0, 0, 0, 0);
+
+ const classes = ["day"];
+ if (date.getTime() === today.getTime()) classes.push("today");
+ if (this.#selectedDate && date.getTime() === this.#selectedDate.getTime()) {
+ classes.push("selected");
+ }
+ if (this.#hasEventOnDate(date)) classes.push("has-event");
+
+ html += `${day}
`;
+ }
+
+ // Next month padding
+ const totalCells = startPadding + daysInMonth;
+ const nextPadding = totalCells <= 35 ? 35 - totalCells : 42 - totalCells;
+ for (let day = 1; day <= nextPadding; day++) {
+ html += `${day}
`;
+ }
+
+ this.#daysContainer.innerHTML = html;
+
+ // Add click handlers
+ this.#daysContainer.querySelectorAll(".day").forEach((el) => {
+ el.addEventListener("click", (e) => {
+ e.stopPropagation();
+ const dateStr = (el as HTMLElement).dataset.date;
+ if (dateStr) {
+ const [y, m, d] = dateStr.split("-").map(Number);
+ this.selectedDate = new Date(y, m, d);
+ }
+ });
+ });
+
+ // Render events for selected date
+ this.#renderEvents();
+ }
+
+ #hasEventOnDate(date: Date): boolean {
+ return this.#events.some((event) => {
+ const eventDate = new Date(event.date);
+ eventDate.setHours(0, 0, 0, 0);
+ return eventDate.getTime() === date.getTime();
+ });
+ }
+
+ #renderEvents() {
+ if (!this.#eventsListEl) return;
+
+ if (!this.#selectedDate) {
+ this.#eventsListEl.style.display = "none";
+ return;
+ }
+
+ const dayEvents = this.#events.filter((event) => {
+ const eventDate = new Date(event.date);
+ eventDate.setHours(0, 0, 0, 0);
+ const selected = new Date(this.#selectedDate!);
+ selected.setHours(0, 0, 0, 0);
+ return eventDate.getTime() === selected.getTime();
+ });
+
+ if (dayEvents.length === 0) {
+ this.#eventsListEl.innerHTML = 'No events
';
+ } else {
+ this.#eventsListEl.innerHTML = dayEvents
+ .map(
+ (event) => `
+
+
+ ${this.#escapeHtml(event.title)}
+
+ `
+ )
+ .join("");
+ }
+ this.#eventsListEl.style.display = "block";
+ }
+
+ #escapeHtml(text: string): string {
+ const div = document.createElement("div");
+ div.textContent = text;
+ return div.innerHTML;
+ }
+
+ override toJSON() {
+ return {
+ ...super.toJSON(),
+ type: "folk-calendar",
+ selectedDate: this.selectedDate?.toISOString() || null,
+ events: this.events.map((e) => ({
+ ...e,
+ date: e.date.toISOString(),
+ })),
+ };
+ }
+}
diff --git a/lib/folk-embed.ts b/lib/folk-embed.ts
new file mode 100644
index 0000000..756ffcd
--- /dev/null
+++ b/lib/folk-embed.ts
@@ -0,0 +1,383 @@
+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: 200px;
+ }
+
+ .header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 12px;
+ background: #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;
+ }
+
+ .favicon {
+ width: 16px;
+ height: 16px;
+ border-radius: 2px;
+ }
+
+ .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 {
+ width: 100%;
+ height: calc(100% - 36px);
+ position: relative;
+ }
+
+ .url-input-container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ padding: 24px;
+ gap: 12px;
+ }
+
+ .url-input {
+ width: 100%;
+ max-width: 400px;
+ padding: 12px 16px;
+ border: 2px solid #e2e8f0;
+ border-radius: 8px;
+ font-size: 14px;
+ outline: none;
+ }
+
+ .url-input:focus {
+ border-color: #eab308;
+ }
+
+ .url-error {
+ color: #ef4444;
+ font-size: 12px;
+ }
+
+ .embed-iframe {
+ width: 100%;
+ height: 100%;
+ border: none;
+ border-radius: 0 0 8px 8px;
+ }
+
+ .unsupported {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ padding: 24px;
+ gap: 12px;
+ text-align: center;
+ color: #64748b;
+ }
+
+ .open-link {
+ background: #eab308;
+ color: white;
+ border: none;
+ border-radius: 6px;
+ padding: 8px 16px;
+ cursor: pointer;
+ font-size: 14px;
+ }
+
+ .open-link:hover {
+ background: #ca8a04;
+ }
+`;
+
+// URL transformation patterns
+function transformUrl(url: string): string | null {
+ try {
+ const parsed = new URL(url);
+ const hostname = parsed.hostname.replace("www.", "");
+
+ // YouTube
+ const youtubeMatch = url.match(
+ /(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/
+ );
+ if (youtubeMatch) {
+ return `https://www.youtube.com/embed/${youtubeMatch[1]}`;
+ }
+
+ // Twitter/X
+ const twitterMatch = url.match(
+ /(?:twitter\.com|x\.com)\/([^\/\s?]+)(?:\/(?:status|tweets)\/(\d+)|$)/
+ );
+ if (twitterMatch) {
+ if (twitterMatch[2]) {
+ // Tweet embed
+ return `https://platform.x.com/embed/Tweet.html?id=${twitterMatch[2]}`;
+ }
+ // Profile - not embeddable
+ return null;
+ }
+
+ // Google Maps
+ if (hostname.includes("google") && parsed.pathname.includes("/maps")) {
+ // Already an embed URL
+ if (parsed.pathname.includes("/embed")) return url;
+ // Convert place/directions URLs would need API key
+ return url;
+ }
+
+ // Gather.town
+ if (hostname === "app.gather.town") {
+ return url.replace("app.gather.town", "gather.town/embed");
+ }
+
+ // Medium - not embeddable
+ if (hostname.includes("medium.com")) {
+ return null;
+ }
+
+ // Pass through other URLs
+ return url;
+ } catch {
+ return null;
+ }
+}
+
+function getFaviconUrl(url: string): string {
+ try {
+ const hostname = new URL(url).hostname;
+ return `https://www.google.com/s2/favicons?domain=${hostname}&sz=32`;
+ } catch {
+ return "";
+ }
+}
+
+function getDisplayTitle(url: string): string {
+ try {
+ const hostname = new URL(url).hostname.replace("www.", "");
+ if (hostname.includes("youtube")) return "YouTube";
+ if (hostname.includes("twitter") || hostname.includes("x.com")) return "Twitter/X";
+ if (hostname.includes("google") && url.includes("/maps")) return "Google Maps";
+ return hostname;
+ } catch {
+ return "Embed";
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "folk-embed": FolkEmbed;
+ }
+}
+
+export class FolkEmbed extends FolkShape {
+ static override tagName = "folk-embed";
+
+ 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;
+ }
+
+ #url: string | null = null;
+ #error: string | null = null;
+
+ get url() {
+ return this.#url;
+ }
+
+ set url(value: string | null) {
+ this.#url = value;
+ this.requestUpdate("url");
+ this.dispatchEvent(new CustomEvent("url-change", { detail: { url: value } }));
+ }
+
+ override createRenderRoot() {
+ const root = super.createRenderRoot();
+
+ this.#url = this.getAttribute("url") || null;
+
+ const wrapper = document.createElement("div");
+ wrapper.innerHTML = html`
+
+
+ `;
+
+ const slot = root.querySelector("slot");
+ if (slot?.parentElement) {
+ const parent = slot.parentElement;
+ const existingDiv = parent.querySelector("div");
+ if (existingDiv) {
+ parent.replaceChild(wrapper, existingDiv);
+ }
+ }
+
+ const content = wrapper.querySelector(".content") as HTMLElement;
+ const urlInputContainer = wrapper.querySelector(".url-input-container") as HTMLElement;
+ const urlInput = wrapper.querySelector(".url-input") as HTMLInputElement;
+ const urlError = wrapper.querySelector(".url-error") as HTMLElement;
+ const titleText = wrapper.querySelector(".title-text") as HTMLElement;
+ const headerTitle = wrapper.querySelector(".header-title") as HTMLElement;
+ const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
+
+ // Handle URL input
+ const handleUrlSubmit = () => {
+ let inputUrl = urlInput.value.trim();
+ if (!inputUrl) return;
+
+ // Auto-complete https://
+ if (!inputUrl.startsWith("http://") && !inputUrl.startsWith("https://")) {
+ inputUrl = `https://${inputUrl}`;
+ }
+
+ // Validate URL
+ const isValid = inputUrl.match(/(^\w+:|^)\/\//);
+ if (!isValid) {
+ this.#error = "Please enter a valid URL";
+ urlError.textContent = this.#error;
+ urlError.style.display = "block";
+ return;
+ }
+
+ // Transform and set URL
+ const embedUrl = transformUrl(inputUrl);
+ this.url = inputUrl;
+ this.#renderEmbed(content, urlInputContainer, titleText, headerTitle, inputUrl, embedUrl);
+ };
+
+ urlInput.addEventListener("keydown", (e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ handleUrlSubmit();
+ }
+ });
+
+ urlInput.addEventListener("blur", () => {
+ if (urlInput.value.trim()) {
+ handleUrlSubmit();
+ }
+ });
+
+ // Close button
+ closeBtn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ this.dispatchEvent(new CustomEvent("close"));
+ });
+
+ // If URL is already set, render embed
+ if (this.#url) {
+ const embedUrl = transformUrl(this.#url);
+ this.#renderEmbed(content, urlInputContainer, titleText, headerTitle, this.#url, embedUrl);
+ }
+
+ return root;
+ }
+
+ #renderEmbed(
+ content: HTMLElement,
+ urlInputContainer: HTMLElement,
+ titleText: HTMLElement,
+ headerTitle: HTMLElement,
+ originalUrl: string,
+ embedUrl: string | null
+ ) {
+ // Update header
+ titleText.textContent = getDisplayTitle(originalUrl);
+ const favicon = document.createElement("img");
+ favicon.className = "favicon";
+ favicon.src = getFaviconUrl(originalUrl);
+ favicon.onerror = () => (favicon.style.display = "none");
+ headerTitle.insertBefore(favicon, titleText);
+
+ if (!embedUrl) {
+ // Unsupported content
+ urlInputContainer.innerHTML = `
+
+
This content cannot be embedded in an iframe.
+
+
+ `;
+ const openBtn = urlInputContainer.querySelector(".open-link");
+ openBtn?.addEventListener("click", () => {
+ window.open(originalUrl, "_blank", "noopener,noreferrer");
+ });
+ } else {
+ // Create iframe
+ urlInputContainer.style.display = "none";
+
+ const iframe = document.createElement("iframe");
+ iframe.className = "embed-iframe";
+ iframe.src = embedUrl;
+ iframe.loading = "lazy";
+ iframe.allow =
+ "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture";
+ iframe.allowFullscreen = true;
+ iframe.referrerPolicy = "no-referrer";
+
+ content.appendChild(iframe);
+ }
+ }
+
+ override toJSON() {
+ return {
+ ...super.toJSON(),
+ type: "folk-embed",
+ url: this.url,
+ };
+ }
+}
diff --git a/lib/folk-map.ts b/lib/folk-map.ts
new file mode 100644
index 0000000..0a33439
--- /dev/null
+++ b/lib/folk-map.ts
@@ -0,0 +1,497 @@
+import { FolkShape } from "./folk-shape";
+import { css, html } from "./tags";
+
+const MAPLIBRE_CSS = "https://unpkg.com/maplibre-gl@4.1.2/dist/maplibre-gl.css";
+const MAPLIBRE_JS = "https://unpkg.com/maplibre-gl@4.1.2/dist/maplibre-gl.js";
+
+// Default tile provider (OpenStreetMap)
+const DEFAULT_STYLE = {
+ version: 8,
+ sources: {
+ osm: {
+ type: "raster",
+ tiles: ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"],
+ tileSize: 256,
+ attribution: '© OpenStreetMap',
+ },
+ },
+ layers: [
+ {
+ id: "osm",
+ type: "raster",
+ source: "osm",
+ },
+ ],
+};
+
+const styles = css`
+ :host {
+ background: white;
+ border-radius: 8px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ min-width: 400px;
+ min-height: 300px;
+ }
+
+ .header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 12px;
+ background: #22c55e;
+ 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);
+ }
+
+ .map-container {
+ width: 100%;
+ height: calc(100% - 36px);
+ border-radius: 0 0 8px 8px;
+ overflow: hidden;
+ position: relative;
+ }
+
+ .map {
+ width: 100%;
+ height: 100%;
+ }
+
+ .loading {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: #f1f5f9;
+ color: #64748b;
+ font-size: 14px;
+ }
+
+ .loading.hidden {
+ display: none;
+ }
+
+ .search-box {
+ position: absolute;
+ top: 10px;
+ left: 10px;
+ z-index: 10;
+ display: flex;
+ gap: 4px;
+ }
+
+ .search-input {
+ padding: 8px 12px;
+ border: none;
+ border-radius: 4px;
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
+ font-size: 13px;
+ width: 200px;
+ outline: none;
+ }
+
+ .search-btn {
+ padding: 8px 12px;
+ background: #22c55e;
+ color: white;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
+ }
+
+ .search-btn:hover {
+ background: #16a34a;
+ }
+
+ .locate-btn {
+ position: absolute;
+ bottom: 80px;
+ right: 10px;
+ z-index: 10;
+ width: 32px;
+ height: 32px;
+ background: white;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
+ font-size: 16px;
+ }
+
+ .locate-btn:hover {
+ background: #f1f5f9;
+ }
+
+ /* Override MapLibre default styles */
+ .maplibregl-ctrl-attrib {
+ font-size: 10px !important;
+ }
+`;
+
+export interface MapMarker {
+ id: string;
+ lng: number;
+ lat: number;
+ color?: string;
+ label?: string;
+}
+
+// MapLibre types (loaded dynamically from CDN)
+interface MapLibreMap {
+ flyTo(options: { center: [number, number] }): void;
+ setZoom(zoom: number): void;
+ getCenter(): { lng: number; lat: number };
+ getZoom(): number;
+ addControl(control: unknown, position?: string): void;
+ on(event: string, handler: (e: MapLibreEvent) => void): void;
+}
+
+interface MapLibreEvent {
+ lngLat: { lng: number; lat: number };
+}
+
+interface MapLibreMarker {
+ setLngLat(coords: [number, number]): this;
+ addTo(map: MapLibreMap): this;
+ setPopup(popup: unknown): this;
+ remove(): void;
+}
+
+interface MapLibreGL {
+ Map: new (options: {
+ container: HTMLElement;
+ style: object;
+ center: [number, number];
+ zoom: number;
+ }) => MapLibreMap;
+ NavigationControl: new () => unknown;
+ Marker: new (options?: { element?: HTMLElement }) => MapLibreMarker;
+ Popup: new (options?: { offset?: number }) => { setText(text: string): unknown };
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "folk-map": FolkMap;
+ }
+ interface Window {
+ maplibregl: MapLibreGL;
+ }
+}
+
+export class FolkMap extends FolkShape {
+ static override tagName = "folk-map";
+
+ 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;
+ }
+
+ #map: MapLibreMap | null = null;
+ #markers: MapMarker[] = [];
+ #mapMarkerInstances = new Map();
+ #center: [number, number] = [-74.006, 40.7128]; // NYC default
+ #zoom = 12;
+ #mapEl: HTMLElement | null = null;
+ #loadingEl: HTMLElement | null = null;
+
+ get center(): [number, number] {
+ return this.#center;
+ }
+
+ set center(value: [number, number]) {
+ this.#center = value;
+ this.#map?.flyTo({ center: value });
+ }
+
+ get zoom(): number {
+ return this.#zoom;
+ }
+
+ set zoom(value: number) {
+ this.#zoom = value;
+ this.#map?.setZoom(value);
+ }
+
+ get markers(): MapMarker[] {
+ return this.#markers;
+ }
+
+ addMarker(marker: MapMarker) {
+ this.#markers.push(marker);
+ this.#renderMarker(marker);
+ this.dispatchEvent(new CustomEvent("marker-add", { detail: { marker } }));
+ }
+
+ removeMarker(id: string) {
+ const instance = this.#mapMarkerInstances.get(id);
+ if (instance) {
+ instance.remove();
+ this.#mapMarkerInstances.delete(id);
+ }
+ this.#markers = this.#markers.filter((m) => m.id !== id);
+ }
+
+ override createRenderRoot() {
+ const root = super.createRenderRoot();
+
+ // Parse initial attributes
+ const centerAttr = this.getAttribute("center");
+ if (centerAttr) {
+ const [lng, lat] = centerAttr.split(",").map(Number);
+ if (!isNaN(lng) && !isNaN(lat)) {
+ this.#center = [lng, lat];
+ }
+ }
+ const zoomAttr = this.getAttribute("zoom");
+ if (zoomAttr && !isNaN(Number(zoomAttr))) {
+ this.#zoom = Number(zoomAttr);
+ }
+
+ const wrapper = document.createElement("div");
+ wrapper.innerHTML = html`
+
+
+
Loading map...
+
+
+
+
+
+
+
+ `;
+
+ const slot = root.querySelector("slot");
+ if (slot?.parentElement) {
+ const parent = slot.parentElement;
+ const existingDiv = parent.querySelector("div");
+ if (existingDiv) {
+ parent.replaceChild(wrapper, existingDiv);
+ }
+ }
+
+ this.#mapEl = wrapper.querySelector(".map");
+ this.#loadingEl = wrapper.querySelector(".loading");
+ const searchInput = wrapper.querySelector(".search-input") as HTMLInputElement;
+ const searchBtn = wrapper.querySelector(".search-btn") as HTMLButtonElement;
+ const locateBtn = wrapper.querySelector(".locate-btn") as HTMLButtonElement;
+ const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
+
+ // Load MapLibre and initialize map
+ this.#loadMapLibre().then(() => {
+ this.#initMap();
+ });
+
+ // Search handler
+ const handleSearch = async () => {
+ const query = searchInput.value.trim();
+ if (!query) return;
+
+ try {
+ // Use Nominatim for geocoding
+ const response = await fetch(
+ `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}`
+ );
+ const results = await response.json();
+
+ if (results.length > 0) {
+ const { lon, lat } = results[0];
+ this.center = [parseFloat(lon), parseFloat(lat)];
+ this.zoom = 15;
+ }
+ } catch (error) {
+ console.error("Search error:", error);
+ }
+ };
+
+ searchBtn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ handleSearch();
+ });
+
+ searchInput.addEventListener("keydown", (e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ handleSearch();
+ }
+ });
+
+ // Prevent map interactions from triggering shape drag
+ searchInput.addEventListener("pointerdown", (e) => e.stopPropagation());
+
+ // Location button
+ locateBtn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ if ("geolocation" in navigator) {
+ navigator.geolocation.getCurrentPosition(
+ (pos) => {
+ this.center = [pos.coords.longitude, pos.coords.latitude];
+ this.zoom = 15;
+ },
+ (err) => {
+ console.error("Geolocation error:", err);
+ }
+ );
+ }
+ });
+
+ // Close button
+ closeBtn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ this.dispatchEvent(new CustomEvent("close"));
+ });
+
+ return root;
+ }
+
+ async #loadMapLibre() {
+ // Check if already loaded
+ if (window.maplibregl) return;
+
+ // Load CSS
+ const link = document.createElement("link");
+ link.rel = "stylesheet";
+ link.href = MAPLIBRE_CSS;
+ document.head.appendChild(link);
+
+ // Load JS
+ return new Promise((resolve, reject) => {
+ const script = document.createElement("script");
+ script.src = MAPLIBRE_JS;
+ script.onload = () => resolve();
+ script.onerror = reject;
+ document.head.appendChild(script);
+ });
+ }
+
+ #initMap() {
+ if (!this.#mapEl || !window.maplibregl) return;
+
+ this.#map = new window.maplibregl.Map({
+ container: this.#mapEl,
+ style: DEFAULT_STYLE,
+ center: this.#center,
+ zoom: this.#zoom,
+ });
+
+ // Add navigation controls
+ this.#map.addControl(new window.maplibregl.NavigationControl(), "top-right");
+
+ // Hide loading indicator
+ this.#map.on("load", () => {
+ if (this.#loadingEl) {
+ this.#loadingEl.classList.add("hidden");
+ }
+
+ // Render any existing markers
+ this.#markers.forEach((marker) => this.#renderMarker(marker));
+ });
+
+ // Track map movement
+ this.#map.on("moveend", () => {
+ const center = this.#map!.getCenter();
+ this.#center = [center.lng, center.lat];
+ this.#zoom = this.#map!.getZoom();
+ this.dispatchEvent(
+ new CustomEvent("map-move", {
+ detail: { center: this.#center, zoom: this.#zoom },
+ })
+ );
+ });
+
+ // Click to add marker
+ this.#map.on("click", (e) => {
+ const marker: MapMarker = {
+ id: crypto.randomUUID(),
+ lng: e.lngLat.lng,
+ lat: e.lngLat.lat,
+ color: "#22c55e",
+ };
+ this.addMarker(marker);
+ });
+ }
+
+ #renderMarker(marker: MapMarker) {
+ if (!this.#map || !window.maplibregl) return;
+
+ const el = document.createElement("div");
+ el.style.cssText = `
+ width: 24px;
+ height: 24px;
+ background: ${marker.color || "#22c55e"};
+ border: 2px solid white;
+ border-radius: 50%;
+ cursor: pointer;
+ box-shadow: 0 2px 6px rgba(0,0,0,0.3);
+ `;
+
+ const mapMarker = new window.maplibregl.Marker({ element: el })
+ .setLngLat([marker.lng, marker.lat])
+ .addTo(this.#map);
+
+ if (marker.label) {
+ mapMarker.setPopup(
+ new window.maplibregl.Popup({ offset: 25 }).setText(marker.label)
+ );
+ }
+
+ this.#mapMarkerInstances.set(marker.id, mapMarker);
+ }
+
+ override toJSON() {
+ return {
+ ...super.toJSON(),
+ type: "folk-map",
+ center: this.center,
+ zoom: this.zoom,
+ markers: this.markers,
+ };
+ }
+}
diff --git a/lib/index.ts b/lib/index.ts
index 218286f..1a7af12 100644
--- a/lib/index.ts
+++ b/lib/index.ts
@@ -29,6 +29,9 @@ export * from "./folk-slide";
export * from "./folk-chat";
export * from "./folk-google-item";
export * from "./folk-piano";
+export * from "./folk-embed";
+export * from "./folk-calendar";
+export * from "./folk-map";
// Sync
export * from "./community-sync";
diff --git a/website/canvas.html b/website/canvas.html
index a7d5439..efa419d 100644
--- a/website/canvas.html
+++ b/website/canvas.html
@@ -152,7 +152,10 @@
folk-slide,
folk-chat,
folk-google-item,
- folk-piano {
+ folk-piano,
+ folk-embed,
+ folk-calendar,
+ folk-map {
position: absolute;
}
@@ -185,7 +188,10 @@
-
+
+
+
+
@@ -208,6 +214,9 @@
FolkChat,
FolkGoogleItem,
FolkPiano,
+ FolkEmbed,
+ FolkCalendar,
+ FolkMap,
CommunitySync,
PresenceManager,
generatePeerId
@@ -222,6 +231,9 @@
FolkChat.define();
FolkGoogleItem.define();
FolkPiano.define();
+ FolkEmbed.define();
+ FolkCalendar.define();
+ FolkMap.define();
// Get community info from URL
const hostname = window.location.hostname;
@@ -374,6 +386,26 @@
shape = document.createElement("folk-piano");
if (data.isMinimized) shape.isMinimized = data.isMinimized;
break;
+ case "folk-embed":
+ shape = document.createElement("folk-embed");
+ if (data.url) shape.url = data.url;
+ break;
+ case "folk-calendar":
+ shape = document.createElement("folk-calendar");
+ if (data.selectedDate) shape.selectedDate = new Date(data.selectedDate);
+ if (data.events) {
+ shape.events = data.events.map(e => ({
+ ...e,
+ date: new Date(e.date)
+ }));
+ }
+ break;
+ case "folk-map":
+ shape = document.createElement("folk-map");
+ if (data.center) shape.center = data.center;
+ if (data.zoom) shape.zoom = data.zoom;
+ // Note: markers would need to be handled separately
+ break;
case "folk-markdown":
default:
shape = document.createElement("folk-markdown");
@@ -505,6 +537,51 @@
sync.registerShape(shape);
});
+ // Add embed button
+ document.getElementById("add-embed").addEventListener("click", () => {
+ const id = `shape-${Date.now()}-${++shapeCounter}`;
+ const shape = document.createElement("folk-embed");
+ shape.id = id;
+ shape.x = 100 + Math.random() * 200;
+ shape.y = 100 + Math.random() * 200;
+ shape.width = 480;
+ shape.height = 360;
+
+ setupShapeEventListeners(shape);
+ canvas.appendChild(shape);
+ sync.registerShape(shape);
+ });
+
+ // Add calendar button
+ document.getElementById("add-calendar").addEventListener("click", () => {
+ const id = `shape-${Date.now()}-${++shapeCounter}`;
+ const shape = document.createElement("folk-calendar");
+ shape.id = id;
+ shape.x = 100 + Math.random() * 200;
+ shape.y = 100 + Math.random() * 200;
+ shape.width = 320;
+ shape.height = 380;
+
+ setupShapeEventListeners(shape);
+ canvas.appendChild(shape);
+ sync.registerShape(shape);
+ });
+
+ // Add map button
+ document.getElementById("add-map").addEventListener("click", () => {
+ const id = `shape-${Date.now()}-${++shapeCounter}`;
+ const shape = document.createElement("folk-map");
+ shape.id = id;
+ shape.x = 100 + Math.random() * 200;
+ shape.y = 100 + Math.random() * 200;
+ shape.width = 500;
+ shape.height = 400;
+
+ setupShapeEventListeners(shape);
+ canvas.appendChild(shape);
+ sync.registerShape(shape);
+ });
+
// Arrow connection mode
let connectMode = false;
let connectSource = null;