diff --git a/lib/folk-holon-browser.ts b/lib/folk-holon-browser.ts
new file mode 100644
index 0000000..2f049f1
--- /dev/null
+++ b/lib/folk-holon-browser.ts
@@ -0,0 +1,436 @@
+/**
+ * folk-holon-browser — Canvas shape for searching and browsing holons.
+ *
+ * Ported from canvas-website HolonBrowser.tsx (React) to FolkShape
+ * web component. Searches H3 cell IDs or coordinates, probes standard
+ * lenses, and dispatches "open-holon" CustomEvent for canvas to create
+ * a folk-holon shape.
+ */
+
+import * as h3 from 'h3-js';
+import { FolkShape } from './folk-shape';
+import { css, html } from './tags';
+import {
+ isValidHolonId,
+ isH3CellId,
+ getResolutionName,
+ getResolutionDescription,
+ STANDARD_LENSES,
+ LENS_ICONS,
+ getAvailableLenses,
+ getLensData,
+ type HolonData,
+} from './holon-service';
+
+const styles = css`
+ :host {
+ background: var(--rs-bg-surface, #fff);
+ color: var(--rs-text-primary, #1e293b);
+ border-radius: 8px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ min-width: 360px;
+ 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 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 {
+ padding: 16px;
+ overflow: auto;
+ height: calc(100% - 36px);
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ }
+
+ .search-row {
+ display: flex;
+ gap: 8px;
+ }
+
+ .search-input {
+ flex: 1;
+ padding: 8px 12px;
+ border: 1px solid #d1d5db;
+ border-radius: 6px;
+ font-size: 13px;
+ font-family: monospace;
+ outline: none;
+ }
+
+ .search-input:focus {
+ border-color: #22c55e;
+ box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
+ }
+
+ .search-btn {
+ padding: 8px 16px;
+ background: #3b82f6;
+ color: white;
+ border: none;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 13px;
+ font-weight: 600;
+ }
+
+ .search-btn:hover { background: #2563eb; }
+ .search-btn:disabled { background: #9ca3af; cursor: not-allowed; }
+
+ .error {
+ color: #dc2626;
+ font-size: 12px;
+ }
+
+ .result-card {
+ border: 1px solid #e5e7eb;
+ border-radius: 8px;
+ padding: 14px;
+ }
+
+ .result-name {
+ font-size: 15px;
+ font-weight: 600;
+ color: #111827;
+ margin-bottom: 8px;
+ }
+
+ .result-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 6px;
+ margin-bottom: 12px;
+ font-size: 12px;
+ }
+
+ .result-label {
+ color: #6b7280;
+ font-size: 11px;
+ }
+
+ .result-value {
+ color: #1e293b;
+ font-family: monospace;
+ font-size: 12px;
+ word-break: break-all;
+ }
+
+ .lens-badges {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ margin-bottom: 12px;
+ }
+
+ .lens-badge {
+ padding: 3px 8px;
+ border-radius: 10px;
+ font-size: 11px;
+ background: #dbeafe;
+ color: #1e40af;
+ }
+
+ .lens-badge.empty {
+ background: #f3f4f6;
+ color: #9ca3af;
+ }
+
+ .action-row {
+ display: flex;
+ gap: 8px;
+ padding-top: 12px;
+ border-top: 1px solid #e5e7eb;
+ }
+
+ .open-btn {
+ padding: 8px 16px;
+ background: #22c55e;
+ color: white;
+ border: none;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 13px;
+ font-weight: 600;
+ }
+
+ .open-btn:hover { background: #16a34a; }
+
+ .reset-btn {
+ padding: 8px 16px;
+ background: #f3f4f6;
+ color: #374151;
+ border: none;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 13px;
+ }
+
+ .reset-btn:hover { background: #e5e7eb; }
+
+ .loading {
+ color: #6b7280;
+ font-size: 13px;
+ padding: 8px 0;
+ }
+
+ .hint {
+ font-size: 12px;
+ color: #6b7280;
+ }
+`;
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'folk-holon-browser': FolkHolonBrowser;
+ }
+}
+
+export class FolkHolonBrowser extends FolkShape {
+ static override tagName = 'folk-holon-browser';
+
+ 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;
+ }
+
+ // ── State ──
+ #searchId = '';
+ #result: HolonData | null = null;
+ #availableLenses: string[] = [];
+ #loading = false;
+ #error = '';
+ #contentEl: HTMLElement | null = null;
+
+ override createRenderRoot() {
+ const root = super.createRenderRoot();
+
+ const wrapper = document.createElement('div');
+ wrapper.style.cssText = 'width:100%;height:100%;display:flex;flex-direction:column;';
+ wrapper.innerHTML = html`
+
+
+ `;
+
+ const slot = root.querySelector('slot');
+ const containerDiv = slot?.parentElement as HTMLElement;
+ if (containerDiv) containerDiv.replaceWith(wrapper);
+
+ this.#contentEl = wrapper.querySelector('.content');
+
+ wrapper.querySelector('.close-btn')!.addEventListener('click', (e) => {
+ e.stopPropagation();
+ this.dispatchEvent(new CustomEvent('close'));
+ });
+
+ this.#render();
+ return root;
+ }
+
+ // ── Search ──
+
+ async #handleSearch() {
+ const id = this.#searchId.trim();
+ if (!id) { this.#error = 'Please enter a Holon ID'; this.#render(); return; }
+ if (!isValidHolonId(id)) {
+ this.#error = 'Invalid ID. Enter an H3 cell (e.g. 872a1070bffffff) or numeric ID.';
+ this.#render();
+ return;
+ }
+
+ this.#error = '';
+ this.#loading = true;
+ this.#render();
+
+ try {
+ let lat = 0, lng = 0, resolution = -1, name = `Holon ${id.slice(-8)}`;
+
+ if (isH3CellId(id)) {
+ [lat, lng] = h3.cellToLatLng(id);
+ resolution = h3.getResolution(id);
+ name = `${getResolutionName(resolution)} Holon`;
+ }
+
+ this.#result = {
+ id,
+ name,
+ description: resolution >= 0 ? getResolutionDescription(resolution) : '',
+ latitude: lat,
+ longitude: lng,
+ resolution,
+ timestamp: Date.now(),
+ };
+
+ // Probe lenses
+ this.#availableLenses = await getAvailableLenses(id);
+ if (this.#availableLenses.length === 0) {
+ this.#availableLenses = ['general'];
+ }
+ } catch (e) {
+ this.#error = `Search failed: ${e instanceof Error ? e.message : 'Unknown error'}`;
+ this.#result = null;
+ } finally {
+ this.#loading = false;
+ this.#render();
+ }
+ }
+
+ #handleOpenHolon() {
+ if (!this.#result) return;
+ this.dispatchEvent(new CustomEvent('open-holon', {
+ bubbles: true,
+ detail: { holon: this.#result, lenses: this.#availableLenses },
+ }));
+ }
+
+ #handleReset() {
+ this.#searchId = '';
+ this.#result = null;
+ this.#availableLenses = [];
+ this.#error = '';
+ this.#render();
+ }
+
+ // ── Rendering ──
+
+ #render() {
+ if (!this.#contentEl) return;
+
+ const result = this.#result;
+ const isGeo = result && result.resolution >= 0;
+
+ let resultHtml = '';
+ if (result) {
+ const infoGrid = isGeo
+ ? html`
+
+
+
Coordinates
+
${result.latitude.toFixed(6)}, ${result.longitude.toFixed(6)}
+
+
+
Resolution
+
${getResolutionName(result.resolution)} (Level ${result.resolution})
+
+
+
Holon ID
+
${result.id}
+
+
+
Description
+
${getResolutionDescription(result.resolution)}
+
+
`
+ : html`
+
+
+
Type
+
Workspace / Group
+
+
+
Holon ID
+
${result.id}
+
+
`;
+
+ const lensBadges = this.#availableLenses.map((lens) => {
+ const icon = LENS_ICONS[lens] ?? '📁';
+ return `${icon} ${lens}`;
+ }).join('');
+
+ resultHtml = html`
+
+
📍 ${escapeHtml(result.name)}
+ ${infoGrid}
+
Available Data
+
${lensBadges || 'No data yet'}
+
+
+
+
+
`;
+ }
+
+ this.#contentEl.innerHTML = html`
+ Search by H3 cell ID (e.g. 872a1070bffffff) or numeric Holon ID
+
+
+
+
+ ${this.#error ? `${escapeHtml(this.#error)}
` : ''}
+ ${resultHtml}
+ `;
+
+ // Wire events
+ const input = this.#contentEl.querySelector('.search-input') as HTMLInputElement;
+ const searchBtn = this.#contentEl.querySelector('.search-btn') as HTMLButtonElement;
+
+ input?.addEventListener('input', (e) => { this.#searchId = (e.target as HTMLInputElement).value; });
+ input?.addEventListener('pointerdown', (e) => e.stopPropagation());
+ input?.addEventListener('keydown', (e) => {
+ e.stopPropagation();
+ if (e.key === 'Enter') this.#handleSearch();
+ });
+ searchBtn?.addEventListener('click', (e) => { e.stopPropagation(); this.#handleSearch(); });
+
+ const openBtn = this.#contentEl.querySelector('.open-btn');
+ const resetBtn = this.#contentEl.querySelector('.reset-btn');
+ openBtn?.addEventListener('click', (e) => { e.stopPropagation(); this.#handleOpenHolon(); });
+ resetBtn?.addEventListener('click', (e) => { e.stopPropagation(); this.#handleReset(); });
+ }
+
+ // ── Serialization ──
+
+ override toJSON() {
+ return { ...super.toJSON(), type: 'folk-holon-browser' };
+ }
+
+ static override fromData(data: Record): FolkHolonBrowser {
+ return FolkShape.fromData(data) as FolkHolonBrowser;
+ }
+}
+
+function escapeHtml(str: string): string {
+ return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+}
diff --git a/lib/folk-holon.ts b/lib/folk-holon.ts
new file mode 100644
index 0000000..8484ceb
--- /dev/null
+++ b/lib/folk-holon.ts
@@ -0,0 +1,718 @@
+/**
+ * folk-holon — Canvas shape for H3 geospatial holons.
+ *
+ * Ported from canvas-website HolonShapeUtil.tsx (React/tldraw) to
+ * FolkShape web component with Automerge-backed local-first data.
+ *
+ * Two states:
+ * - Disconnected: ID entry form (H3 cell ID or numeric)
+ * - Connected: header with name/coords, lens selector, lens data grid
+ */
+
+import * as h3 from 'h3-js';
+import { FolkShape } from './folk-shape';
+import { css, html } from './tags';
+import {
+ isValidHolonId,
+ isH3CellId,
+ getResolutionName,
+ getResolutionDescription,
+ STANDARD_LENSES,
+ LENS_ICONS,
+ saveHolon,
+ loadHolon,
+ putLensData,
+ getLensData,
+ getAvailableLenses,
+ subscribeLenses,
+ type HolonData,
+} from './holon-service';
+
+const styles = css`
+ :host {
+ background: var(--rs-bg-surface, #fff);
+ color: var(--rs-text-primary, #1e293b);
+ border-radius: 8px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ min-width: 360px;
+ min-height: 280px;
+ }
+
+ .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;
+ min-width: 0;
+ overflow: hidden;
+ }
+
+ .header-title span:last-child {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .header-actions {
+ display: flex;
+ gap: 4px;
+ flex-shrink: 0;
+ }
+
+ .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 {
+ padding: 16px;
+ overflow: auto;
+ height: calc(100% - 36px);
+ }
+
+ /* -- Disconnected state -- */
+
+ .connect-form {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ gap: 16px;
+ text-align: center;
+ }
+
+ .connect-icon {
+ font-size: 48px;
+ }
+
+ .connect-label {
+ font-size: 14px;
+ font-weight: 600;
+ color: #374151;
+ }
+
+ .connect-hint {
+ font-size: 12px;
+ color: #6b7280;
+ max-width: 320px;
+ }
+
+ .connect-row {
+ display: flex;
+ gap: 8px;
+ width: 100%;
+ max-width: 400px;
+ }
+
+ .connect-input {
+ flex: 1;
+ padding: 8px 12px;
+ border: 1px solid #d1d5db;
+ border-radius: 6px;
+ font-size: 13px;
+ font-family: monospace;
+ outline: none;
+ }
+
+ .connect-input:focus {
+ border-color: #22c55e;
+ box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
+ }
+
+ .connect-btn {
+ padding: 8px 16px;
+ background: #22c55e;
+ color: white;
+ border: none;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 13px;
+ font-weight: 600;
+ white-space: nowrap;
+ }
+
+ .connect-btn:hover {
+ background: #16a34a;
+ }
+
+ .connect-btn:disabled {
+ background: #9ca3af;
+ cursor: not-allowed;
+ }
+
+ .error {
+ color: #dc2626;
+ font-size: 12px;
+ margin-top: 4px;
+ }
+
+ /* -- Connected state -- */
+
+ .info-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 8px;
+ margin-bottom: 12px;
+ font-size: 12px;
+ }
+
+ .info-label {
+ color: #6b7280;
+ font-size: 11px;
+ }
+
+ .info-value {
+ color: #1e293b;
+ font-family: monospace;
+ font-size: 12px;
+ word-break: break-all;
+ }
+
+ .lens-bar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ margin-bottom: 12px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid #e5e7eb;
+ }
+
+ .lens-btn {
+ padding: 4px 10px;
+ border: none;
+ border-radius: 12px;
+ font-size: 11px;
+ cursor: pointer;
+ background: #f3f4f6;
+ color: #374151;
+ white-space: nowrap;
+ }
+
+ .lens-btn:hover {
+ background: #e5e7eb;
+ }
+
+ .lens-btn.active {
+ background: #22c55e;
+ color: white;
+ }
+
+ .lens-data {
+ background: #f9fafb;
+ border-radius: 6px;
+ padding: 10px;
+ font-size: 12px;
+ max-height: 200px;
+ overflow: auto;
+ }
+
+ .lens-data pre {
+ margin: 0;
+ white-space: pre-wrap;
+ word-break: break-word;
+ font-family: monospace;
+ font-size: 11px;
+ color: #374151;
+ }
+
+ .lens-empty {
+ color: #9ca3af;
+ font-style: italic;
+ font-size: 12px;
+ padding: 8px;
+ }
+
+ .lens-add-row {
+ display: flex;
+ gap: 6px;
+ margin-top: 8px;
+ }
+
+ .lens-add-row input {
+ flex: 1;
+ padding: 6px 10px;
+ border: 1px solid #d1d5db;
+ border-radius: 4px;
+ font-size: 12px;
+ outline: none;
+ }
+
+ .lens-add-row input:focus {
+ border-color: #22c55e;
+ }
+
+ .lens-add-row button {
+ padding: 6px 12px;
+ background: #22c55e;
+ color: white;
+ border: none;
+ border-radius: 4px;
+ font-size: 12px;
+ cursor: pointer;
+ }
+
+ .lens-add-row button:hover {
+ background: #16a34a;
+ }
+
+ .loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 20px;
+ color: #6b7280;
+ font-size: 13px;
+ }
+
+ .status-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ display: inline-block;
+ margin-right: 6px;
+ }
+
+ .status-dot.connected { background: #22c55e; }
+ .status-dot.disconnected { background: #ef4444; }
+`;
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'folk-holon': FolkHolon;
+ }
+}
+
+export class FolkHolon extends FolkShape {
+ static override tagName = 'folk-holon';
+
+ 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;
+ }
+
+ // ── State ──
+ #holonId = '';
+ #name = 'New Holon';
+ #description = '';
+ #latitude = 0;
+ #longitude = 0;
+ #resolution = -1;
+ #connected = false;
+ #selectedLens = 'users';
+ #lensData: Record> = {};
+ #loading = false;
+ #error = '';
+ #space = '';
+ #unsubLenses: (() => void) | null = null;
+
+ // ── DOM refs ──
+ #contentEl: HTMLElement | null = null;
+
+ // ── Accessors for Automerge sync ──
+
+ get holonId() { return this.#holonId; }
+ set holonId(v: string) { this.#holonId = v; this.#render(); }
+
+ get holonName() { return this.#name; }
+ set holonName(v: string) { this.#name = v; this.#render(); }
+
+ get selectedLens() { return this.#selectedLens; }
+ set selectedLens(v: string) { this.#selectedLens = v; this.#render(); }
+
+ override createRenderRoot() {
+ const root = super.createRenderRoot();
+
+ // Parse attributes
+ this.#holonId = this.getAttribute('holon-id') ?? '';
+ this.#name = this.getAttribute('name') ?? 'New Holon';
+ this.#description = this.getAttribute('description') ?? '';
+ this.#selectedLens = this.getAttribute('selected-lens') ?? 'users';
+ this.#space = this.getAttribute('space') ?? '';
+
+ const latAttr = this.getAttribute('latitude');
+ const lngAttr = this.getAttribute('longitude');
+ const resAttr = this.getAttribute('resolution');
+ if (latAttr) this.#latitude = parseFloat(latAttr) || 0;
+ if (lngAttr) this.#longitude = parseFloat(lngAttr) || 0;
+ if (resAttr) this.#resolution = parseInt(resAttr, 10);
+
+ // Build content wrapper
+ const wrapper = document.createElement('div');
+ wrapper.className = 'content-wrapper';
+ wrapper.style.cssText = 'width:100%;height:100%;display:flex;flex-direction:column;';
+ wrapper.innerHTML = html`
+
+
+ `;
+
+ // Replace the slot container
+ const slot = root.querySelector('slot');
+ const containerDiv = slot?.parentElement as HTMLElement;
+ if (containerDiv) containerDiv.replaceWith(wrapper);
+
+ this.#contentEl = wrapper.querySelector('.content');
+
+ // Wire header buttons
+ const refreshBtn = wrapper.querySelector('.refresh-btn') as HTMLButtonElement;
+ const closeBtn = wrapper.querySelector('.close-btn') as HTMLButtonElement;
+
+ refreshBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ this.#handleRefresh();
+ });
+ closeBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ this.dispatchEvent(new CustomEvent('close'));
+ });
+
+ // Auto-connect if holonId was set via attribute
+ if (this.#holonId) {
+ this.#connected = true;
+ this.#resolveHolonInfo(this.#holonId);
+ this.#loadData();
+ }
+
+ this.#render();
+ return root;
+ }
+
+ disconnectedCallback() {
+ this.#unsubLenses?.();
+ this.#unsubLenses = null;
+ }
+
+ // ── Connect flow ──
+
+ async #handleConnect() {
+ const id = this.#holonId.trim();
+ if (!id) {
+ this.#error = 'Please enter a Holon ID';
+ this.#render();
+ return;
+ }
+ if (!isValidHolonId(id)) {
+ this.#error = 'Invalid ID. Enter an H3 cell (e.g. 872a1070bffffff) or numeric ID.';
+ this.#render();
+ return;
+ }
+
+ this.#holonId = id;
+ this.#error = '';
+ this.#connected = true;
+ this.#resolveHolonInfo(id);
+ await this.#loadData();
+
+ // Persist metadata
+ await saveHolon({
+ id,
+ name: this.#name,
+ description: this.#description,
+ latitude: this.#latitude,
+ longitude: this.#longitude,
+ resolution: this.#resolution,
+ timestamp: Date.now(),
+ });
+
+ this.#render();
+ }
+
+ #resolveHolonInfo(id: string) {
+ if (isH3CellId(id)) {
+ try {
+ const [lat, lng] = h3.cellToLatLng(id);
+ this.#latitude = lat;
+ this.#longitude = lng;
+ this.#resolution = h3.getResolution(id);
+ this.#name = `${getResolutionName(this.#resolution)} Holon`;
+ } catch { /* keep defaults */ }
+ } else {
+ this.#resolution = -1;
+ this.#name = `Holon ${id.slice(-8)}`;
+ }
+
+ // Update header title
+ const titleEl = this.renderRoot.querySelector('.holon-title');
+ if (titleEl) titleEl.textContent = this.#name;
+ }
+
+ async #loadData() {
+ this.#loading = true;
+ this.#render();
+
+ try {
+ // Load existing holon metadata
+ const existing = await loadHolon(this.#holonId);
+ if (existing?.name) {
+ this.#name = existing.name;
+ this.#description = existing.description;
+ const titleEl = this.renderRoot.querySelector('.holon-title');
+ if (titleEl) titleEl.textContent = this.#name;
+ }
+
+ // Load lens data
+ for (const lens of STANDARD_LENSES) {
+ const data = await getLensData(this.#holonId, lens);
+ if (data && Object.keys(data).length > 0) {
+ this.#lensData[lens] = data;
+ }
+ }
+
+ // Subscribe for live updates
+ this.#unsubLenses?.();
+ this.#unsubLenses = subscribeLenses(this.#holonId, (lenses) => {
+ this.#lensData = { ...lenses };
+ this.#render();
+ });
+ } catch (e) {
+ console.error('[folk-holon] Load error:', e);
+ this.#error = 'Failed to load data';
+ } finally {
+ this.#loading = false;
+ this.#render();
+ }
+ }
+
+ async #handleRefresh() {
+ if (!this.#connected || !this.#holonId) return;
+ await this.#loadData();
+ }
+
+ async #handleAddData() {
+ if (!this.#holonId || !this.#connected) return;
+ const input = this.#contentEl?.querySelector('.add-data-input') as HTMLInputElement;
+ if (!input?.value.trim()) return;
+
+ const entryId = `entry-${Date.now()}`;
+ await putLensData(this.#holonId, this.#selectedLens, {
+ [entryId]: { content: input.value.trim(), timestamp: Date.now() },
+ });
+
+ input.value = '';
+ // Data will update via subscription
+ }
+
+ // ── Rendering ──
+
+ #render() {
+ if (!this.#contentEl) return;
+
+ if (!this.#connected) {
+ this.#renderDisconnected();
+ } else if (this.#loading) {
+ this.#contentEl.innerHTML = 'Loading holon data...
';
+ } else {
+ this.#renderConnected();
+ }
+ }
+
+ #renderDisconnected() {
+ this.#contentEl!.innerHTML = html`
+
+ `;
+
+ const input = this.#contentEl!.querySelector('.connect-input') as HTMLInputElement;
+ const btn = this.#contentEl!.querySelector('.connect-btn') as HTMLButtonElement;
+
+ // Set value from state
+ if (this.#holonId) input.value = this.#holonId;
+
+ input.addEventListener('input', (e) => {
+ this.#holonId = (e.target as HTMLInputElement).value;
+ });
+ input.addEventListener('pointerdown', (e) => e.stopPropagation());
+ input.addEventListener('keydown', (e) => {
+ e.stopPropagation();
+ if (e.key === 'Enter') this.#handleConnect();
+ });
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ this.#handleConnect();
+ });
+ }
+
+ #renderConnected() {
+ const resName = getResolutionName(this.#resolution);
+ const resDesc = getResolutionDescription(this.#resolution);
+ const isGeo = this.#resolution >= 0;
+
+ // Available lenses (ones with data + all standard for selection)
+ const withData = new Set(Object.keys(this.#lensData));
+
+ // Build info section
+ const infoHtml = isGeo
+ ? html`
+
+
+
Coordinates
+
${this.#latitude.toFixed(6)}, ${this.#longitude.toFixed(6)}
+
+
+
Resolution
+
${resName} (Level ${this.#resolution})
+
+
+
Holon ID
+
${this.#holonId}
+
+
+
Area Info
+
${resDesc}
+
+
`
+ : html`
+
+
+
+
Holon ID
+
${this.#holonId}
+
+
`;
+
+ // Build lens buttons
+ const lensButtons = STANDARD_LENSES.map((lens) => {
+ const icon = LENS_ICONS[lens] ?? '📁';
+ const active = lens === this.#selectedLens ? ' active' : '';
+ const badge = withData.has(lens) ? ' *' : '';
+ return ``;
+ }).join('');
+
+ // Current lens data
+ const currentData = this.#lensData[this.#selectedLens];
+ let dataHtml: string;
+ if (currentData && Object.keys(currentData).length > 0) {
+ dataHtml = `${escapeHtml(JSON.stringify(currentData, null, 2))} `;
+ } else {
+ dataHtml = `No data in "${this.#selectedLens}" lens
`;
+ }
+
+ this.#contentEl!.innerHTML = html`
+ ${infoHtml}
+ ${lensButtons}
+ ${dataHtml}
+
+
+
+
+ `;
+
+ // Wire lens buttons
+ this.#contentEl!.querySelectorAll('.lens-btn').forEach((btn) => {
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ this.#selectedLens = (btn as HTMLElement).dataset.lens!;
+ this.#render();
+ });
+ });
+
+ // Wire add data
+ const addInput = this.#contentEl!.querySelector('.add-data-input') as HTMLInputElement;
+ const addBtn = this.#contentEl!.querySelector('.add-data-btn') as HTMLButtonElement;
+ addInput?.addEventListener('pointerdown', (e) => e.stopPropagation());
+ addInput?.addEventListener('keydown', (e) => {
+ e.stopPropagation();
+ if (e.key === 'Enter') this.#handleAddData();
+ });
+ addBtn?.addEventListener('click', (e) => {
+ e.stopPropagation();
+ this.#handleAddData();
+ });
+ }
+
+ // ── Serialization ──
+
+ override toJSON() {
+ return {
+ ...super.toJSON(),
+ type: 'folk-holon',
+ holonId: this.#holonId,
+ name: this.#name,
+ description: this.#description,
+ latitude: this.#latitude,
+ longitude: this.#longitude,
+ resolution: this.#resolution,
+ connected: this.#connected,
+ selectedLens: this.#selectedLens,
+ };
+ }
+
+ static override fromData(data: Record): FolkHolon {
+ const shape = FolkShape.fromData(data) as FolkHolon;
+ if (data.holonId) shape.setAttribute('holon-id', data.holonId);
+ if (data.name) shape.setAttribute('name', data.name);
+ if (data.description) shape.setAttribute('description', data.description);
+ if (data.latitude != null) shape.setAttribute('latitude', String(data.latitude));
+ if (data.longitude != null) shape.setAttribute('longitude', String(data.longitude));
+ if (data.resolution != null) shape.setAttribute('resolution', String(data.resolution));
+ if (data.selectedLens) shape.setAttribute('selected-lens', data.selectedLens);
+ return shape;
+ }
+
+ override applyData(data: Record): void {
+ super.applyData(data);
+ if (data.holonId && data.holonId !== this.#holonId) {
+ this.#holonId = data.holonId;
+ this.#connected = !!data.connected;
+ if (this.#connected) {
+ this.#resolveHolonInfo(this.#holonId);
+ this.#loadData();
+ }
+ }
+ if (data.name) this.#name = data.name;
+ if (data.selectedLens && data.selectedLens !== this.#selectedLens) {
+ this.#selectedLens = data.selectedLens;
+ }
+ this.#render();
+ }
+}
+
+function escapeHtml(str: string): string {
+ return str.replace(/&/g, '&').replace(//g, '>');
+}
diff --git a/lib/holon-service.ts b/lib/holon-service.ts
new file mode 100644
index 0000000..2a16b3c
--- /dev/null
+++ b/lib/holon-service.ts
@@ -0,0 +1,263 @@
+/**
+ * Holon Service — Local-first data layer for H3 geospatial holons.
+ *
+ * Replaces the dead HoloSphereService (GunDB stub) with Automerge-backed
+ * CRDT storage via window.__rspaceOfflineRuntime.
+ *
+ * Data model:
+ * Registry doc: `{space}:holons:registry:{holonId}` — HolonData
+ * Lens doc: `{space}:holons:lenses:{holonId}` — all lens data
+ *
+ * H3 hierarchy methods use pure h3-js (no external service).
+ * Designed so data could bridge to AD4M Perspectives later.
+ */
+
+import * as h3 from 'h3-js';
+import type { DocSchema, DocumentId } from '../shared/local-first/document';
+import type { RSpaceOfflineRuntime } from '../shared/local-first/runtime';
+
+// ── Types ──
+
+export interface HolonData {
+ id: string;
+ name: string;
+ description: string;
+ latitude: number;
+ longitude: number;
+ resolution: number;
+ timestamp: number;
+}
+
+export interface HolonLens {
+ name: string;
+ data: Record;
+}
+
+export interface HolonConnection {
+ id: string;
+ name: string;
+ type: 'federation' | 'reference';
+ targetSpace: string;
+ status: 'connected' | 'disconnected' | 'error';
+}
+
+// ── Standard lenses ──
+
+export const STANDARD_LENSES = [
+ 'active_users', 'users', 'rankings', 'stats', 'tasks', 'progress',
+ 'events', 'activities', 'items', 'shopping', 'active_items',
+ 'proposals', 'offers', 'requests', 'checklists', 'roles',
+] as const;
+
+export type LensName = (typeof STANDARD_LENSES)[number] | string;
+
+// ── Lens icons ──
+
+export const LENS_ICONS: Record = {
+ active_users: '👥', users: '👤', rankings: '🏆', stats: '📊',
+ tasks: '✅', progress: '📈', events: '📅', activities: '🔔',
+ items: '📦', shopping: '🛒', active_items: '⚡', proposals: '💡',
+ offers: '🤝', requests: '📋', checklists: '☑️', roles: '🎭',
+};
+
+// ── Resolution names ──
+
+const RESOLUTION_NAMES = [
+ 'Country', 'State/Province', 'Metropolitan Area', 'City', 'District',
+ 'Neighborhood', 'Block', 'Building', 'Room', 'Desk', 'Chair', 'Point',
+];
+
+const RESOLUTION_DESCRIPTIONS = [
+ 'Country level — covers entire countries',
+ 'State/Province level — covers states and provinces',
+ 'Metropolitan area level — covers large urban areas',
+ 'City level — covers individual cities',
+ 'District level — covers city districts',
+ 'Neighborhood level — covers neighborhoods',
+ 'Block level — covers city blocks',
+ 'Building level — covers individual buildings',
+ 'Room level — covers individual rooms',
+ 'Desk level — covers individual desks',
+ 'Chair level — covers individual chairs',
+ 'Point level — covers individual points',
+];
+
+export function getResolutionName(resolution: number): string {
+ if (resolution < 0) return 'Workspace / Group';
+ return RESOLUTION_NAMES[resolution] ?? `Level ${resolution}`;
+}
+
+export function getResolutionDescription(resolution: number): string {
+ if (resolution < 0) return 'Non-geospatial workspace or group';
+ return RESOLUTION_DESCRIPTIONS[resolution] ?? `Geographic level ${resolution}`;
+}
+
+// ── Automerge schemas ──
+
+interface HolonRegistryDoc {
+ holon: HolonData;
+}
+
+interface HolonLensesDoc {
+ lenses: Record>;
+}
+
+const registrySchema: DocSchema = {
+ module: 'holons',
+ collection: 'registry',
+ version: 1,
+ init: () => ({
+ holon: { id: '', name: '', description: '', latitude: 0, longitude: 0, resolution: 0, timestamp: 0 },
+ }),
+};
+
+const lensesSchema: DocSchema = {
+ module: 'holons',
+ collection: 'lenses',
+ version: 1,
+ init: () => ({ lenses: {} }),
+};
+
+// ── Helper: get runtime ──
+
+function getRuntime(): RSpaceOfflineRuntime | null {
+ return (window as any).__rspaceOfflineRuntime ?? null;
+}
+
+function getSpace(): string {
+ return getRuntime()?.space ?? 'demo';
+}
+
+// ── H3 validation ──
+
+export function isValidHolonId(id: string): boolean {
+ if (!id || !id.trim()) return false;
+ const trimmed = id.trim();
+ try { if (h3.isValidCell(trimmed)) return true; } catch { /* not h3 */ }
+ if (/^\d{6,20}$/.test(trimmed)) return true;
+ if (/^[a-zA-Z0-9_-]{3,50}$/.test(trimmed)) return true;
+ return false;
+}
+
+export function isH3CellId(id: string): boolean {
+ if (!id || !id.trim()) return false;
+ try { return h3.isValidCell(id.trim()); } catch { return false; }
+}
+
+// ── H3 hierarchy (pure h3-js) ──
+
+export function getHolonHierarchy(holonId: string): { parent?: string; children: string[] } {
+ try {
+ const resolution = h3.getResolution(holonId);
+ const parent = resolution > 0 ? h3.cellToParent(holonId, resolution - 1) : undefined;
+ const children = resolution < 15 ? h3.cellToChildren(holonId, resolution + 1) : [];
+ return { parent, children };
+ } catch {
+ return { children: [] };
+ }
+}
+
+export function getHolonScalespace(holonId: string): string[] {
+ try {
+ const resolution = h3.getResolution(holonId);
+ const scales: string[] = [holonId];
+ let current = holonId;
+ for (let r = resolution - 1; r >= 0; r--) {
+ current = h3.cellToParent(current, r);
+ scales.unshift(current);
+ }
+ return scales;
+ } catch {
+ return [];
+ }
+}
+
+// ── CRDT-backed data operations ──
+
+/** Get or create the registry doc for a holon. */
+async function ensureRegistryDoc(holonId: string): Promise {
+ const runtime = getRuntime();
+ if (!runtime) throw new Error('Offline runtime not available');
+ const docId = `${getSpace()}:holons:registry:${holonId}` as DocumentId;
+ await runtime.subscribe(docId, registrySchema);
+ return docId;
+}
+
+/** Get or create the lenses doc for a holon. */
+async function ensureLensesDoc(holonId: string): Promise {
+ const runtime = getRuntime();
+ if (!runtime) throw new Error('Offline runtime not available');
+ const docId = `${getSpace()}:holons:lenses:${holonId}` as DocumentId;
+ await runtime.subscribe(docId, lensesSchema);
+ return docId;
+}
+
+/** Save holon metadata to Automerge. */
+export async function saveHolon(data: HolonData): Promise {
+ const runtime = getRuntime();
+ if (!runtime) return;
+ const docId = await ensureRegistryDoc(data.id);
+ runtime.change(docId, 'Update holon metadata', (doc) => {
+ doc.holon.id = data.id;
+ doc.holon.name = data.name;
+ doc.holon.description = data.description;
+ doc.holon.latitude = data.latitude;
+ doc.holon.longitude = data.longitude;
+ doc.holon.resolution = data.resolution;
+ doc.holon.timestamp = data.timestamp;
+ });
+}
+
+/** Load holon metadata from Automerge. */
+export async function loadHolon(holonId: string): Promise {
+ const runtime = getRuntime();
+ if (!runtime) return null;
+ const docId = await ensureRegistryDoc(holonId);
+ const doc = runtime.get(docId);
+ if (!doc || !doc.holon.id) return null;
+ return { ...doc.holon };
+}
+
+/** Put data into a lens. */
+export async function putLensData(holonId: string, lens: string, data: Record): Promise {
+ const runtime = getRuntime();
+ if (!runtime) return;
+ const docId = await ensureLensesDoc(holonId);
+ runtime.change(docId, `Update lens: ${lens}`, (doc) => {
+ if (!doc.lenses[lens]) doc.lenses[lens] = {};
+ Object.assign(doc.lenses[lens], data);
+ });
+}
+
+/** Get data from a lens. */
+export async function getLensData(holonId: string, lens: string): Promise | null> {
+ const runtime = getRuntime();
+ if (!runtime) return null;
+ const docId = await ensureLensesDoc(holonId);
+ const doc = runtime.get(docId);
+ if (!doc?.lenses?.[lens]) return null;
+ return { ...doc.lenses[lens] };
+}
+
+/** Get all lenses with data for a holon. */
+export async function getAvailableLenses(holonId: string): Promise {
+ const runtime = getRuntime();
+ if (!runtime) return [];
+ const docId = await ensureLensesDoc(holonId);
+ const doc = runtime.get(docId);
+ if (!doc?.lenses) return [];
+ return Object.keys(doc.lenses).filter((k) => {
+ const v = doc.lenses[k];
+ return v && Object.keys(v).length > 0;
+ });
+}
+
+/** Subscribe to changes on a holon's lens data. Returns unsubscribe function. */
+export function subscribeLenses(holonId: string, cb: (lenses: Record>) => void): () => void {
+ const runtime = getRuntime();
+ if (!runtime) return () => {};
+ const docId = `${getSpace()}:holons:lenses:${holonId}` as DocumentId;
+ return runtime.onChange(docId, (doc) => {
+ cb(doc.lenses ?? {});
+ });
+}
diff --git a/lib/index.ts b/lib/index.ts
index a9cecfa..f5f6644 100644
--- a/lib/index.ts
+++ b/lib/index.ts
@@ -81,6 +81,11 @@ export * from "./folk-choice-conviction";
// 3D Spider Plot (governance visualization)
export * from "./folk-spider-3d";
+// Holon Shapes (H3 geospatial)
+export * from "./folk-holon";
+export * from "./folk-holon-browser";
+export * from "./holon-service";
+
// Nested Space Shape
export * from "./folk-canvas";
diff --git a/website/canvas.html b/website/canvas.html
index d9c301b..deeacda 100644
--- a/website/canvas.html
+++ b/website/canvas.html
@@ -1464,6 +1464,8 @@
#canvas.feed-mode folk-budget,
#canvas.feed-mode folk-packing-list,
#canvas.feed-mode folk-booking,
+ #canvas.feed-mode folk-holon,
+ #canvas.feed-mode folk-holon-browser,
#canvas.feed-mode folk-feed {
position: relative !important;
transform: none !important;
@@ -1739,6 +1741,8 @@
folk-kicad,
folk-zine-gen,
folk-rapp,
+ folk-holon,
+ folk-holon-browser,
folk-multisig-email {
position: absolute;
}
@@ -1760,7 +1764,7 @@
folk-booking, folk-token-mint, folk-token-ledger,
folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-spider-3d, folk-choice-conviction,
folk-social-post, folk-splat, folk-blender, folk-drawfast,
- folk-freecad, folk-kicad, folk-zine-gen, folk-rapp, folk-multisig-email) {
+ folk-freecad, folk-kicad, folk-zine-gen, folk-rapp, folk-holon, folk-holon-browser, folk-multisig-email) {
cursor: crosshair;
}
@@ -1772,7 +1776,7 @@
folk-booking, folk-token-mint, folk-token-ledger,
folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-spider-3d, folk-choice-conviction,
folk-social-post, folk-splat, folk-blender, folk-drawfast,
- folk-freecad, folk-kicad, folk-zine-gen, folk-rapp, folk-multisig-email):hover {
+ folk-freecad, folk-kicad, folk-zine-gen, folk-rapp, folk-holon, folk-holon-browser, folk-multisig-email):hover {
outline: 2px dashed #3b82f6;
outline-offset: 4px;
}
@@ -2227,6 +2231,8 @@
+
+
@@ -2524,6 +2530,8 @@
FolkCanvas,
FolkRApp,
FolkFeed,
+ FolkHolon,
+ FolkHolonBrowser,
CommunitySync,
PresenceManager,
generatePeerId,
@@ -2655,6 +2663,8 @@
FolkCanvas.define();
FolkRApp.define();
FolkFeed.define();
+ FolkHolon.define();
+ FolkHolonBrowser.define();
// Register all shapes with the shape registry
shapeRegistry.register("folk-shape", FolkShape);
@@ -2699,6 +2709,8 @@
shapeRegistry.register("folk-canvas", FolkCanvas);
shapeRegistry.register("folk-rapp", FolkRApp);
shapeRegistry.register("folk-feed", FolkFeed);
+ shapeRegistry.register("folk-holon", FolkHolon);
+ shapeRegistry.register("folk-holon-browser", FolkHolonBrowser);
// Zoom and pan state — declared early to avoid TDZ errors
// (event handlers reference these before awaits yield execution)
@@ -2972,6 +2984,7 @@
"folk-splat", "folk-blender", "folk-drawfast",
"folk-freecad", "folk-kicad",
"folk-rapp",
+ "folk-holon", "folk-holon-browser",
"folk-multisig-email",
"folk-feed"
].join(", ");
@@ -3661,6 +3674,8 @@
"folk-canvas": { width: 600, height: 400 },
"folk-rapp": { width: 500, height: 400 },
"folk-feed": { width: 280, height: 360 },
+ "folk-holon": { width: 500, height: 400 },
+ "folk-holon-browser": { width: 400, height: 450 },
"folk-transaction-builder": { width: 420, height: 520 },
};
@@ -4099,6 +4114,8 @@
document.getElementById("new-bookmark").addEventListener("click", () => setPendingTool("folk-bookmark"));
document.getElementById("new-calendar").addEventListener("click", () => setPendingTool("folk-calendar"));
document.getElementById("new-map").addEventListener("click", () => setPendingTool("folk-map"));
+ document.getElementById("new-holon").addEventListener("click", () => setPendingTool("folk-holon"));
+ document.getElementById("new-holon-browser").addEventListener("click", () => setPendingTool("folk-holon-browser"));
document.getElementById("new-image-gen").addEventListener("click", () => setPendingTool("folk-image-gen"));
document.getElementById("new-video-gen").addEventListener("click", () => setPendingTool("folk-video-gen"));
document.getElementById("new-zine-gen").addEventListener("click", () => setPendingTool("folk-zine-gen"));
@@ -5242,7 +5259,8 @@
"folk-choice-spider": "🕸", "folk-spider-3d": "📊", "folk-choice-conviction": "⏳", "folk-social-post": "📱",
"folk-splat": "🔮", "folk-blender": "🧊", "folk-drawfast": "✏️",
"folk-freecad": "📐", "folk-kicad": "🔌",
- "folk-rapp": "📱", "folk-multisig-email": "✉️", "folk-feed": "🔄", "folk-arrow": "↗️",
+ "folk-rapp": "📱", "folk-holon": "🌐", "folk-holon-browser": "🔍",
+ "folk-multisig-email": "✉️", "folk-feed": "🔄", "folk-arrow": "↗️",
};
function getShapeLabel(data) {