/** * — Layered tab system for rSpace. * * Each tab is a "layer" — an rApp page within the current space. * Supports two view modes: * - flat: traditional tab bar (looking down at one layer) * - stack: side view showing all layers as stacked strata with flows * * Attributes: * active — the active layer ID * space — current space slug * view-mode — "flat" | "stack" * * Events: * layer-switch — fired when user clicks a tab { detail: { layerId, moduleId } } * layer-add — fired when user clicks + to add a layer * layer-close — fired when user closes a tab { detail: { layerId } } * layer-reorder — fired on drag reorder { detail: { layerId, newIndex } } * view-toggle — fired when switching flat/stack view { detail: { mode } } * flow-select — fired when a flow is clicked in stack view { detail: { flowId } } */ import type { Layer, LayerFlow, FlowKind, FeedDefinition } from "../../lib/layer-types"; import { FLOW_COLORS, FLOW_LABELS } from "../../lib/layer-types"; // Badge info for tab display const MODULE_BADGES: Record = { rspace: { badge: "rS", color: "#5eead4" }, rnotes: { badge: "rN", color: "#fcd34d" }, rpubs: { badge: "rP", color: "#fda4af" }, rswag: { badge: "rSw", color: "#fda4af" }, rsplat: { badge: "r3", color: "#d8b4fe" }, rcal: { badge: "rC", color: "#7dd3fc" }, rtrips: { badge: "rT", color: "#6ee7b7" }, rmaps: { badge: "rM", color: "#86efac" }, rchats: { badge: "rCh", color: "#6ee7b7" }, rinbox: { badge: "rI", color: "#a5b4fc" }, rmail: { badge: "rMa", color: "#93c5fd" }, rforum: { badge: "rFo", color: "#fcd34d" }, rchoices: { badge: "rCo", color: "#f0abfc" }, rvote: { badge: "rV", color: "#c4b5fd" }, rfunds: { badge: "rF", color: "#bef264" }, rwallet: { badge: "rW", color: "#fde047" }, rcart: { badge: "rCt", color: "#fdba74" }, rauctions: { badge: "rA", color: "#fca5a5" }, rtube: { badge: "rTu", color: "#f9a8d4" }, rphotos: { badge: "rPh", color: "#f9a8d4" }, rnetwork: { badge: "rNe", color: "#93c5fd" }, rsocials: { badge: "rSo", color: "#7dd3fc" }, rfiles: { badge: "rFi", color: "#67e8f9" }, rbooks: { badge: "rB", color: "#fda4af" }, rdata: { badge: "rD", color: "#d8b4fe" }, rwork: { badge: "rWo", color: "#cbd5e1" }, }; // Category definitions for the + menu dropdown grouping const MODULE_CATEGORIES: Record = { rspace: "Creating", rnotes: "Creating", rpubs: "Creating", rtube: "Creating", rswag: "Creating", rsplat: "Creating", rcal: "Planning", rtrips: "Planning", rmaps: "Planning", rchats: "Communicating", rinbox: "Communicating", rmail: "Communicating", rforum: "Communicating", rchoices: "Deciding", rvote: "Deciding", rfunds: "Funding & Commerce", rwallet: "Funding & Commerce", rcart: "Funding & Commerce", rauctions: "Funding & Commerce", rphotos: "Sharing", rnetwork: "Sharing", rsocials: "Sharing", rfiles: "Sharing", rbooks: "Sharing", rdata: "Observing", rwork: "Work & Productivity", rids: "Identity & Infrastructure", rstack: "Identity & Infrastructure", }; const CATEGORY_ORDER = [ "Creating", "Planning", "Communicating", "Deciding", "Funding & Commerce", "Sharing", "Observing", "Work & Productivity", "Identity & Infrastructure", ]; export interface TabBarModule { id: string; name: string; icon: string; description: string; feeds?: FeedDefinition[]; acceptsFeeds?: FlowKind[]; } export class RStackTabBar extends HTMLElement { #shadow: ShadowRoot; #layers: Layer[] = []; #modules: TabBarModule[] = []; #flows: LayerFlow[] = []; #viewMode: "flat" | "stack" = "flat"; #draggedTabId: string | null = null; #addMenuOpen = false; #flowDragSource: string | null = null; #flowDragTarget: string | null = null; #flowDialogOpen = false; #flowDialogSourceId = ""; #flowDialogTargetId = ""; // 3D scene state #sceneRotX = 55; #sceneRotZ = -15; #scenePerspective = 1200; #orbitDragging = false; #orbitLastX = 0; #orbitLastY = 0; #simSpeed = 1; #simPlaying = true; constructor() { super(); this.#shadow = this.attachShadow({ mode: "open" }); } static get observedAttributes() { return ["active", "space", "view-mode"]; } get active(): string { return this.getAttribute("active") || ""; } set active(val: string) { this.setAttribute("active", val); } get space(): string { return this.getAttribute("space") || ""; } get viewMode(): "flat" | "stack" { return (this.getAttribute("view-mode") as "flat" | "stack") || "flat"; } set viewMode(val: "flat" | "stack") { this.setAttribute("view-mode", val); } connectedCallback() { this.#render(); } attributeChangedCallback() { this.#viewMode = this.viewMode; this.#render(); } /** Set the layer list (call from outside) */ setLayers(layers: Layer[]) { this.#layers = [...layers].sort((a, b) => a.order - b.order); this.#render(); } /** Provide the available module list (for the + add menu) */ setModules(modules: TabBarModule[]) { this.#modules = modules; } /** Set the inter-layer flows (for stack view) */ setFlows(flows: LayerFlow[]) { this.#flows = flows; if (this.#viewMode === "stack") this.#render(); } /** Add a single layer */ addLayer(layer: Layer) { this.#layers.push(layer); this.#layers.sort((a, b) => a.order - b.order); this.#render(); } /** Remove a layer by ID */ removeLayer(layerId: string) { this.#layers = this.#layers.filter(l => l.id !== layerId); this.#render(); } /** Update a layer */ updateLayer(layerId: string, updates: Partial) { const layer = this.#layers.find(l => l.id === layerId); if (layer) { Object.assign(layer, updates); this.#layers.sort((a, b) => a.order - b.order); this.#render(); } } // ── Feed compatibility helpers ── /** Get the set of FlowKinds a module can output (from its feeds) */ #getModuleOutputKinds(moduleId: string): Set { const mod = this.#modules.find(m => m.id === moduleId); if (!mod?.feeds) return new Set(); return new Set(mod.feeds.map(f => f.kind)); } /** Get the set of FlowKinds a module can accept as input */ #getModuleInputKinds(moduleId: string): Set { const mod = this.#modules.find(m => m.id === moduleId); if (!mod?.acceptsFeeds) return new Set(); return new Set(mod.acceptsFeeds); } /** Get compatible FlowKinds between two layers (intersection of source outputs and target accepts) */ #getCompatibleKinds(srcLayerId: string, tgtLayerId: string): Set { const srcLayer = this.#layers.find(l => l.id === srcLayerId); const tgtLayer = this.#layers.find(l => l.id === tgtLayerId); if (!srcLayer || !tgtLayer) return new Set(); const srcOutputs = this.#getModuleOutputKinds(srcLayer.moduleId); const tgtInputs = this.#getModuleInputKinds(tgtLayer.moduleId); // If either module has no feed data, return empty (fallback allows all in dialog) if (srcOutputs.size === 0 && tgtInputs.size === 0) return new Set(); const compatible = new Set(); for (const kind of srcOutputs) { if (tgtInputs.has(kind)) compatible.add(kind); } return compatible; } /** Get feeds that have no matching outgoing flow (contained within the layer) */ #getContainedFeeds(layerId: string): FeedDefinition[] { const layer = this.#layers.find(l => l.id === layerId); if (!layer) return []; const mod = this.#modules.find(m => m.id === layer.moduleId); if (!mod?.feeds) return []; const outgoingKinds = new Set( this.#flows .filter(f => f.sourceLayerId === layerId && f.active) .map(f => f.kind) ); return mod.feeds.filter(f => !outgoingKinds.has(f.kind)); } // ── Render ── #render() { const active = this.active; this.#shadow.innerHTML = `
${this.#layers.map(l => this.#renderTab(l, active)).join("")} ${this.#addMenuOpen ? this.#renderAddMenu() : ""}
${this.#viewMode === "stack" ? this.#renderStackView() : ""} `; this.#attachEvents(); } #renderTab(layer: Layer, activeId: string): string { const badge = MODULE_BADGES[layer.moduleId]; const isActive = layer.id === activeId; const badgeColor = layer.color || badge?.color || "#94a3b8"; return `
${badge?.badge || layer.moduleId.slice(0, 2)} ${layer.label} ${this.#layers.length > 1 ? `` : ""}
`; } #renderAddMenu(): string { const existingModuleIds = new Set(this.#layers.map(l => l.moduleId)); // Use server module list if available, fall back to MODULE_BADGES keys const availableModules: Array<{ id: string; name: string; icon: string; description: string }> = this.#modules.length > 0 ? this.#modules.filter(m => !existingModuleIds.has(m.id)) : Object.keys(MODULE_BADGES) .filter(id => !existingModuleIds.has(id)) .map(id => ({ id, name: id, icon: "", description: "" })); if (availableModules.length === 0) { return `
All rApps added
`; } // Group by category const groups = new Map(); const uncategorized: typeof availableModules = []; for (const m of availableModules) { const cat = MODULE_CATEGORIES[m.id]; if (cat) { if (!groups.has(cat)) groups.set(cat, []); groups.get(cat)!.push(m); } else { uncategorized.push(m); } } let html = ""; for (const cat of CATEGORY_ORDER) { const items = groups.get(cat); if (!items || items.length === 0) continue; html += `
${cat}
`; html += items.map(m => this.#renderAddMenuItem(m)).join(""); } if (uncategorized.length > 0) { html += `
Other
`; html += uncategorized.map(m => this.#renderAddMenuItem(m)).join(""); } return `
${html}
`; } #renderAddMenuItem(m: { id: string; name: string; icon: string; description: string }): string { const badge = MODULE_BADGES[m.id]; const badgeHtml = badge ? `${badge.badge}` : `${m.icon}`; return ` `; } #renderStackView(): string { const layerCount = this.#layers.length; if (layerCount === 0) return ""; const layerSpacing = 80; const animDuration = 2 / this.#simSpeed; // Build layer planes let layersHtml = ""; const layerZMap = new Map(); this.#layers.forEach((layer, i) => { const z = i * layerSpacing; layerZMap.set(layer.id, z); const badge = MODULE_BADGES[layer.moduleId]; const color = layer.color || badge?.color || "#94a3b8"; const isActive = layer.id === this.active; const containedFeeds = this.#getContainedFeeds(layer.id); // Feed port indicators — output kinds (right side) and input kinds (left side) const outputKinds = this.#getModuleOutputKinds(layer.moduleId); const inputKinds = this.#getModuleInputKinds(layer.moduleId); const outPorts = [...outputKinds].map(k => `` ).join(""); const inPorts = [...inputKinds].map(k => `` ).join(""); const containedHtml = containedFeeds.length > 0 ? `
${containedFeeds.map(f => ` \uD83D\uDD12 ${f.name} `).join("")}
` : ""; layersHtml += `
${inPorts}
${badge?.badge || layer.moduleId.slice(0, 2)} ${layer.label}
${outPorts}
${containedHtml}
`; }); // Build flow particles let particlesHtml = ""; for (const flow of this.#flows) { if (!flow.active) continue; const srcZ = layerZMap.get(flow.sourceLayerId); const tgtZ = layerZMap.get(flow.targetLayerId); if (srcZ === undefined || tgtZ === undefined) continue; const color = flow.color || FLOW_COLORS[flow.kind] || "#94a3b8"; const particleCount = Math.max(2, Math.round(flow.strength * 6)); for (let p = 0; p < particleCount; p++) { const delay = (p / particleCount) * animDuration; particlesHtml += `
`; } } // Flow legend const activeKinds = new Set(this.#flows.map(f => f.kind)); const legendHtml = [...activeKinds].map(k => ` ${FLOW_LABELS[k]} `).join(""); // Time scrubber const scrubberHtml = `
${this.#simSpeed.toFixed(1)}x
`; return `
${layersHtml} ${particlesHtml}
${legendHtml}
${scrubberHtml} ${this.#layers.length >= 2 ? `
Drag between layers to create a flow \u00b7 Drag empty space to orbit
` : ""} ${this.#flowDialogOpen ? this.#renderFlowDialog() : ""}
`; } // ── Flow creation dialog ── #renderFlowDialog(): string { const srcLayer = this.#layers.find(l => l.id === this.#flowDialogSourceId); const tgtLayer = this.#layers.find(l => l.id === this.#flowDialogTargetId); if (!srcLayer || !tgtLayer) return ""; const srcBadge = MODULE_BADGES[srcLayer.moduleId]; const tgtBadge = MODULE_BADGES[tgtLayer.moduleId]; const kinds: FlowKind[] = ["economic", "trust", "data", "attention", "governance", "resource"]; const compatible = this.#getCompatibleKinds(this.#flowDialogSourceId, this.#flowDialogTargetId); const hasModuleData = this.#modules.some(m => m.id === srcLayer.moduleId && m.feeds) || this.#modules.some(m => m.id === tgtLayer.moduleId && m.acceptsFeeds); // Count source feeds per kind for badge display const srcMod = this.#modules.find(m => m.id === srcLayer.moduleId); const feedCountByKind = new Map(); if (srcMod?.feeds) { for (const f of srcMod.feeds) { feedCountByKind.set(f.kind, (feedCountByKind.get(f.kind) || 0) + 1); } } return `
New Flow
${srcBadge?.badge || srcLayer.moduleId} \u2192 ${tgtBadge?.badge || tgtLayer.moduleId}
${kinds.map(k => { const isCompatible = !hasModuleData || compatible.has(k); const feedCount = feedCountByKind.get(k) || 0; return ` `; }).join("")}
`; } #openFlowDialog(sourceLayerId: string, targetLayerId: string) { this.#flowDialogOpen = true; this.#flowDialogSourceId = sourceLayerId; this.#flowDialogTargetId = targetLayerId; this.#render(); } #closeFlowDialog() { this.#flowDialogOpen = false; this.#flowDialogSourceId = ""; this.#flowDialogTargetId = ""; this.#render(); } // ── Events ── #attachEvents() { // Tab clicks this.#shadow.querySelectorAll(".tab").forEach(tab => { tab.addEventListener("click", (e) => { const target = e.target as HTMLElement; if (target.classList.contains("tab-close")) return; const layerId = tab.dataset.layerId!; const moduleId = tab.dataset.moduleId!; this.active = layerId; this.dispatchEvent(new CustomEvent("layer-switch", { detail: { layerId, moduleId }, bubbles: true, })); }); // Drag-and-drop reorder tab.addEventListener("dragstart", (e) => { this.#draggedTabId = tab.dataset.layerId!; tab.classList.add("dragging"); (e as DragEvent).dataTransfer!.effectAllowed = "move"; }); tab.addEventListener("dragend", () => { tab.classList.remove("dragging"); this.#draggedTabId = null; }); tab.addEventListener("dragover", (e) => { e.preventDefault(); (e as DragEvent).dataTransfer!.dropEffect = "move"; tab.classList.add("drag-over"); }); tab.addEventListener("dragleave", () => { tab.classList.remove("drag-over"); }); tab.addEventListener("drop", (e) => { e.preventDefault(); tab.classList.remove("drag-over"); if (!this.#draggedTabId || this.#draggedTabId === tab.dataset.layerId) return; const targetIdx = this.#layers.findIndex(l => l.id === tab.dataset.layerId); this.dispatchEvent(new CustomEvent("layer-reorder", { detail: { layerId: this.#draggedTabId, newIndex: targetIdx }, bubbles: true, })); }); }); // Close buttons this.#shadow.querySelectorAll(".tab-close").forEach(btn => { btn.addEventListener("click", (e) => { e.stopPropagation(); const layerId = (btn as HTMLElement).dataset.close!; this.dispatchEvent(new CustomEvent("layer-close", { detail: { layerId }, bubbles: true, })); }); }); // Add button const addBtn = this.#shadow.getElementById("add-btn"); addBtn?.addEventListener("click", (e) => { e.stopPropagation(); this.#addMenuOpen = !this.#addMenuOpen; this.#render(); }); // Add menu items this.#shadow.querySelectorAll(".add-menu-item").forEach(item => { item.addEventListener("click", (e) => { e.stopPropagation(); const moduleId = item.dataset.addModule!; this.#addMenuOpen = false; this.dispatchEvent(new CustomEvent("layer-add", { detail: { moduleId }, bubbles: true, })); }); }); // Close add menu on outside click if (this.#addMenuOpen) { const handler = () => { this.#addMenuOpen = false; this.#render(); document.removeEventListener("click", handler); }; setTimeout(() => document.addEventListener("click", handler), 0); } // View toggle const viewToggle = this.#shadow.getElementById("view-toggle"); viewToggle?.addEventListener("click", () => { const newMode = this.#viewMode === "flat" ? "stack" : "flat"; this.#viewMode = newMode; this.setAttribute("view-mode", newMode); this.dispatchEvent(new CustomEvent("view-toggle", { detail: { mode: newMode }, bubbles: true, })); this.#render(); }); // 3D Stack view: layer clicks + drag-to-connect this.#shadow.querySelectorAll(".layer-plane").forEach(plane => { const layerId = plane.dataset.layerId!; // Click to switch layer plane.addEventListener("click", (e) => { if (this.#flowDragSource || this.#orbitDragging) return; const layer = this.#layers.find(l => l.id === layerId); if (layer) { this.active = layerId; this.dispatchEvent(new CustomEvent("layer-switch", { detail: { layerId, moduleId: layer.moduleId }, bubbles: true, })); } }); // Drag-to-connect: mousedown starts a flow drag plane.addEventListener("mousedown", (e) => { if ((e as MouseEvent).button !== 0) return; e.stopPropagation(); // prevent orbit this.#flowDragSource = layerId; plane.classList.add("flow-drag-source"); }); plane.addEventListener("mouseenter", () => { if (this.#flowDragSource && this.#flowDragSource !== layerId) { this.#flowDragTarget = layerId; plane.classList.add("flow-drag-target"); } }); plane.addEventListener("mouseleave", () => { if (this.#flowDragTarget === layerId) { this.#flowDragTarget = null; plane.classList.remove("flow-drag-target"); } }); }); // 3D scene: orbit controls (drag on empty space to rotate) const sceneContainer = this.#shadow.getElementById("stack-3d"); if (sceneContainer) { sceneContainer.addEventListener("mousedown", (e) => { // Only orbit on left-click on empty space (not on layer planes) if ((e as MouseEvent).button !== 0) return; if ((e.target as HTMLElement).closest(".layer-plane")) return; this.#orbitDragging = true; this.#orbitLastX = (e as MouseEvent).clientX; this.#orbitLastY = (e as MouseEvent).clientY; sceneContainer.style.cursor = "grabbing"; }); const onMouseMove = (e: MouseEvent) => { if (this.#orbitDragging) { const dx = e.clientX - this.#orbitLastX; const dy = e.clientY - this.#orbitLastY; this.#sceneRotZ += dx * 0.3; this.#sceneRotX = Math.max(10, Math.min(80, this.#sceneRotX - dy * 0.3)); this.#orbitLastX = e.clientX; this.#orbitLastY = e.clientY; const scene = this.#shadow.getElementById("stack-scene"); if (scene) scene.style.transform = `rotateX(${this.#sceneRotX}deg) rotateZ(${this.#sceneRotZ}deg)`; } }; const onMouseUp = () => { if (this.#orbitDragging) { this.#orbitDragging = false; sceneContainer.style.cursor = ""; } // Complete flow drag if (this.#flowDragSource && this.#flowDragTarget) { this.#openFlowDialog(this.#flowDragSource, this.#flowDragTarget); } this.#flowDragSource = null; this.#flowDragTarget = null; this.#shadow.querySelectorAll(".flow-drag-source, .flow-drag-target").forEach(el => { el.classList.remove("flow-drag-source", "flow-drag-target"); }); }; // Attach to document for drag continuity document.addEventListener("mousemove", onMouseMove); document.addEventListener("mouseup", onMouseUp); // Scroll to zoom sceneContainer.addEventListener("wheel", (e) => { e.preventDefault(); this.#scenePerspective = Math.max(400, Math.min(3000, this.#scenePerspective + (e as WheelEvent).deltaY * 2)); sceneContainer.style.perspective = `${this.#scenePerspective}px`; }, { passive: false }); } // Flow particle clicks — select flow this.#shadow.querySelectorAll(".flow-particle").forEach(p => { p.addEventListener("click", (e) => { e.stopPropagation(); const flowId = p.dataset.flowId!; this.dispatchEvent(new CustomEvent("flow-select", { detail: { flowId }, bubbles: true, })); }); // Right-click to delete flow p.addEventListener("contextmenu", (e) => { e.preventDefault(); const flowId = p.dataset.flowId!; const flow = this.#flows.find(f => f.id === flowId); if (flow && confirm(`Remove ${FLOW_LABELS[flow.kind]} flow${flow.label ? `: ${flow.label}` : ""}?`)) { this.dispatchEvent(new CustomEvent("flow-remove", { detail: { flowId }, bubbles: true, })); } }); }); // Time scrubber controls const scrubberRange = this.#shadow.getElementById("scrubber-range") as HTMLInputElement | null; const scrubberLabel = this.#shadow.getElementById("scrubber-label"); const scrubberPlaypause = this.#shadow.getElementById("scrubber-playpause"); scrubberRange?.addEventListener("input", () => { this.#simSpeed = parseFloat(scrubberRange.value); if (scrubberLabel) scrubberLabel.textContent = `${this.#simSpeed.toFixed(1)}x`; // Update particle durations without full re-render const dur = 2 / this.#simSpeed; this.#shadow.querySelectorAll(".flow-particle").forEach(p => { p.style.setProperty("--duration", `${dur}s`); }); }); scrubberPlaypause?.addEventListener("click", () => { this.#simPlaying = !this.#simPlaying; scrubberPlaypause.textContent = this.#simPlaying ? "\u23F8" : "\u25B6"; scrubberPlaypause.title = this.#simPlaying ? "Pause" : "Play"; const state = this.#simPlaying ? "running" : "paused"; this.#shadow.querySelectorAll(".flow-particle").forEach(p => { p.style.animationPlayState = state; }); }); // Flow dialog events if (this.#flowDialogOpen) { // Default to first compatible kind, or "data" as fallback const compatible = this.#getCompatibleKinds(this.#flowDialogSourceId, this.#flowDialogTargetId); const defaultKind: FlowKind = compatible.size > 0 ? [...compatible][0] : "data"; let selectedKind: FlowKind = defaultKind; this.#shadow.querySelectorAll(".flow-kind-btn:not(.disabled)").forEach(btn => { btn.addEventListener("click", () => { this.#shadow.querySelectorAll(".flow-kind-btn").forEach(b => b.classList.remove("selected")); btn.classList.add("selected"); selectedKind = btn.dataset.kind as FlowKind; }); }); // Select default kind const defaultBtn = this.#shadow.querySelector(`.flow-kind-btn[data-kind="${defaultKind}"]:not(.disabled)`) || this.#shadow.querySelector('.flow-kind-btn:not(.disabled)'); defaultBtn?.classList.add("selected"); if (defaultBtn) selectedKind = (defaultBtn as HTMLElement).dataset.kind as FlowKind; this.#shadow.getElementById("flow-cancel")?.addEventListener("click", () => { this.#closeFlowDialog(); }); this.#shadow.getElementById("flow-create")?.addEventListener("click", () => { const label = (this.#shadow.getElementById("flow-label-input") as HTMLInputElement)?.value || ""; const strength = parseFloat((this.#shadow.getElementById("flow-strength-input") as HTMLInputElement)?.value || "0.5"); const flowId = `flow-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; this.dispatchEvent(new CustomEvent("flow-create", { detail: { flow: { id: flowId, kind: selectedKind, sourceLayerId: this.#flowDialogSourceId, targetLayerId: this.#flowDialogTargetId, label: label || undefined, strength, active: true, } }, bubbles: true, })); this.#closeFlowDialog(); }); } } static define(tag = "rstack-tab-bar") { if (!customElements.get(tag)) customElements.define(tag, RStackTabBar); } } // ── SVG Icons ── const ICON_STACK = ` `; const ICON_FLAT = ` `; // ── Styles ── const STYLES = ` :host { display: block; } /* ── Tab bar (flat mode) ── */ .tab-bar { display: flex; align-items: center; gap: 0; height: 36px; padding: 0 8px; overflow: hidden; } .tabs-scroll { display: flex; align-items: center; gap: 2px; flex: 1; min-width: 0; overflow-x: auto; scrollbar-width: none; position: relative; } .tabs-scroll::-webkit-scrollbar { display: none; } .tab-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; margin-left: 4px; } /* ── Individual tab ── */ .tab { display: flex; align-items: center; gap: 6px; padding: 4px 12px; border-radius: 6px 6px 0 0; cursor: pointer; white-space: nowrap; font-size: 0.8rem; font-weight: 500; transition: background 0.15s, opacity 0.15s; user-select: none; position: relative; flex-shrink: 0; } :host-context([data-theme="dark"]) .tab { color: #94a3b8; background: transparent; } :host-context([data-theme="dark"]) .tab:hover { background: rgba(255,255,255,0.05); color: #e2e8f0; } :host-context([data-theme="dark"]) .tab.active { background: rgba(255,255,255,0.08); color: #f1f5f9; } :host-context([data-theme="light"]) .tab { color: #64748b; background: transparent; } :host-context([data-theme="light"]) .tab:hover { background: rgba(0,0,0,0.04); color: #1e293b; } :host-context([data-theme="light"]) .tab.active { background: rgba(0,0,0,0.06); color: #0f172a; } /* Active indicator line at bottom */ .tab-indicator { position: absolute; bottom: 0; left: 8px; right: 8px; height: 2px; border-radius: 2px 2px 0 0; opacity: 0; transition: opacity 0.15s; } .tab.active .tab-indicator { opacity: 1; } .tab-badge { display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: 4px; font-size: 0.55rem; font-weight: 900; color: #0f172a; line-height: 1; flex-shrink: 0; } .tab-label { font-size: 0.8rem; } .tab-close { display: flex; align-items: center; justify-content: center; width: 16px; height: 16px; border: none; border-radius: 3px; background: transparent; color: inherit; font-size: 0.85rem; cursor: pointer; opacity: 0; transition: opacity 0.15s, background 0.15s; padding: 0; line-height: 1; } .tab:hover .tab-close { opacity: 0.5; } .tab-close:hover { opacity: 1 !important; background: rgba(239,68,68,0.2); color: #ef4444; } /* ── Drag states ── */ .tab.dragging { opacity: 0.4; } .tab.drag-over { box-shadow: inset 2px 0 0 #22d3ee; } /* ── Add button ── */ .tab-add { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border: 1px dashed rgba(148,163,184,0.3); border-radius: 5px; background: transparent; color: #64748b; font-size: 0.9rem; cursor: pointer; transition: border-color 0.15s, color 0.15s, background 0.15s; flex-shrink: 0; margin-left: 4px; } .tab-add:hover { border-color: #22d3ee; color: #22d3ee; background: rgba(34,211,238,0.08); } /* ── Add menu ── */ .add-menu { position: absolute; top: 100%; left: auto; right: 0; margin-top: 4px; min-width: 260px; max-height: 400px; overflow-y: auto; border-radius: 10px; padding: 4px; z-index: 100; box-shadow: 0 8px 30px rgba(0,0,0,0.25); scrollbar-width: thin; } :host-context([data-theme="dark"]) .add-menu { background: #1e293b; border: 1px solid rgba(255,255,255,0.1); } :host-context([data-theme="light"]) .add-menu { background: white; border: 1px solid rgba(0,0,0,0.1); } .add-menu-category { padding: 6px 10px 3px; font-size: 0.6rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; opacity: 0.45; user-select: none; } .add-menu-category:not(:first-child) { border-top: 1px solid rgba(128,128,128,0.12); margin-top: 2px; padding-top: 8px; } .add-menu-item { display: flex; align-items: center; gap: 8px; width: 100%; padding: 6px 10px; border: none; border-radius: 6px; background: transparent; color: inherit; font-size: 0.8rem; cursor: pointer; text-align: left; transition: background 0.12s; } :host-context([data-theme="dark"]) .add-menu-item:hover { background: rgba(255,255,255,0.06); } :host-context([data-theme="light"]) .add-menu-item:hover { background: rgba(0,0,0,0.04); } .add-menu-badge { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: 5px; font-size: 0.55rem; font-weight: 900; color: #0f172a; flex-shrink: 0; } .add-menu-icon { font-size: 1.1rem; width: 24px; text-align: center; flex-shrink: 0; } .add-menu-text { display: flex; flex-direction: column; min-width: 0; flex: 1; } .add-menu-name { font-size: 0.8rem; font-weight: 600; display: flex; align-items: center; gap: 4px; } .add-menu-emoji { font-size: 0.8rem; flex-shrink: 0; } .add-menu-desc { font-size: 0.65rem; opacity: 0.5; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .add-menu-empty { padding: 12px; text-align: center; font-size: 0.75rem; opacity: 0.5; } /* ── View toggle ── */ .view-toggle { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border: none; border-radius: 6px; background: transparent; color: #64748b; cursor: pointer; transition: background 0.15s, color 0.15s; } .view-toggle:hover { background: rgba(255,255,255,0.08); color: #e2e8f0; } :host-context([data-theme="light"]) .view-toggle:hover { background: rgba(0,0,0,0.05); color: #1e293b; } .view-toggle.active { color: #22d3ee; background: rgba(34,211,238,0.1); } /* ── Stack view (3D) ── */ .stack-view { padding: 12px; overflow: hidden; max-height: 60vh; transition: max-height 0.3s ease; position: relative; } :host-context([data-theme="dark"]) .stack-view { background: rgba(15,23,42,0.5); border-bottom: 1px solid rgba(255,255,255,0.06); } :host-context([data-theme="light"]) .stack-view { background: rgba(248,250,252,0.8); border-bottom: 1px solid rgba(0,0,0,0.06); } .stack-view-3d { perspective-origin: 50% 40%; width: 100%; height: 340px; display: flex; align-items: center; justify-content: center; cursor: grab; user-select: none; } .stack-scene { transform-style: preserve-3d; position: relative; width: 320px; height: 80px; } /* ── Layer planes ── */ .layer-plane { position: absolute; width: 320px; min-height: 70px; border-radius: 10px; border: 1px solid var(--layer-color); padding: 10px 14px; cursor: pointer; transition: border-color 0.2s, box-shadow 0.2s; transform-style: preserve-3d; backface-visibility: hidden; } :host-context([data-theme="dark"]) .layer-plane { background: rgba(15,23,42,0.65); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); color: #e2e8f0; } :host-context([data-theme="light"]) .layer-plane { background: rgba(255,255,255,0.7); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); color: #1e293b; } .layer-plane:hover { border-width: 2px; box-shadow: 0 0 16px color-mix(in srgb, var(--layer-color) 30%, transparent); } .layer-plane--active { border-width: 2px; box-shadow: 0 0 24px color-mix(in srgb, var(--layer-color) 40%, transparent); } :host-context([data-theme="dark"]) .layer-plane--active { background: rgba(15,23,42,0.85); } :host-context([data-theme="light"]) .layer-plane--active { background: rgba(255,255,255,0.9); } /* Drag-to-connect visual states */ .layer-plane.flow-drag-source { border-style: dashed; border-width: 2px; } .layer-plane.flow-drag-target { border-width: 3px; box-shadow: 0 0 30px color-mix(in srgb, var(--layer-color) 50%, transparent); } .layer-header { display: flex; align-items: center; gap: 8px; } .layer-badge { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: 5px; font-size: 0.55rem; font-weight: 900; color: #0f172a; flex-shrink: 0; } .layer-name { font-size: 0.8rem; font-weight: 600; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* Feed port indicators */ .layer-ports { display: flex; gap: 3px; flex-shrink: 0; } .feed-port { width: 6px; height: 6px; border-radius: 50%; opacity: 0.7; } .feed-port--in { box-shadow: inset 0 0 0 1.5px rgba(0,0,0,0.3); } .feed-port--out { box-shadow: 0 0 3px currentColor; } /* Containment indicators */ .layer-contained { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; } .contained-feed { display: inline-flex; align-items: center; gap: 3px; font-size: 0.6rem; padding: 2px 6px; border-radius: 4px; background: color-mix(in srgb, var(--feed-color) 10%, transparent); border: 1px solid color-mix(in srgb, var(--feed-color) 25%, transparent); opacity: 0.7; } .contained-lock { font-size: 0.55rem; } /* ── Flow particles ── */ .flow-particle { position: absolute; width: 6px; height: 6px; border-radius: 50%; background: var(--color); box-shadow: 0 0 6px var(--color); left: 50%; top: 50%; margin-left: -3px; margin-top: -3px; pointer-events: auto; cursor: pointer; animation: flow-particle var(--duration) linear var(--delay) infinite; } @keyframes flow-particle { 0% { transform: translateZ(var(--src-z)); opacity: 0; } 8% { opacity: 1; } 85% { opacity: 1; } 95% { opacity: 0.5; transform: translateZ(var(--tgt-z)); } 100% { transform: translateZ(var(--tgt-z)); opacity: 0; } } /* ── Legend ── */ .stack-legend { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; padding: 6px 0; } .legend-item { display: inline-flex; align-items: center; gap: 4px; font-size: 0.65rem; opacity: 0.6; } .legend-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; } /* ── Time scrubber ── */ .time-scrubber { display: flex; align-items: center; gap: 8px; padding: 4px 16px; justify-content: center; } .scrubber-playpause { width: 24px; height: 24px; border: none; border-radius: 50%; background: rgba(34,211,238,0.15); color: #22d3ee; font-size: 0.7rem; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: background 0.15s; flex-shrink: 0; } .scrubber-playpause:hover { background: rgba(34,211,238,0.25); } .scrubber-range { flex: 1; max-width: 200px; accent-color: #22d3ee; height: 4px; } .scrubber-label { font-size: 0.65rem; font-weight: 700; opacity: 0.5; width: 28px; text-align: center; flex-shrink: 0; } /* ── Stack hint ── */ .stack-hint { text-align: center; font-size: 0.6rem; opacity: 0.35; padding: 2px 0 4px; } /* ── Flow creation dialog ── */ .flow-dialog { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 280px; border-radius: 12px; padding: 16px; z-index: 50; box-shadow: 0 12px 40px rgba(0,0,0,0.4); } :host-context([data-theme="dark"]) .flow-dialog { background: #1e293b; border: 1px solid rgba(255,255,255,0.1); color: #e2e8f0; } :host-context([data-theme="light"]) .flow-dialog { background: white; border: 1px solid rgba(0,0,0,0.1); color: #1e293b; } .flow-dialog-header { font-size: 0.85rem; font-weight: 700; margin-bottom: 10px; } .flow-dialog-route { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; justify-content: center; } .flow-dialog-badge { display: inline-flex; align-items: center; justify-content: center; padding: 3px 8px; border-radius: 5px; font-size: 0.6rem; font-weight: 900; color: #0f172a; } .flow-dialog-arrow { font-size: 1rem; opacity: 0.4; } .flow-dialog-field { margin-bottom: 10px; } .flow-dialog-label { display: block; font-size: 0.6rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.5; margin-bottom: 4px; } .flow-kind-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; } .flow-kind-btn { display: flex; align-items: center; gap: 6px; padding: 5px 8px; border: 1px solid transparent; border-radius: 5px; background: transparent; color: inherit; font-size: 0.7rem; cursor: pointer; transition: background 0.12s, border-color 0.12s; } .flow-kind-btn:hover { background: rgba(255,255,255,0.05); } .flow-kind-btn.selected { border-color: var(--kind-color); background: color-mix(in srgb, var(--kind-color) 10%, transparent); } .flow-kind-btn.disabled { opacity: 0.25; pointer-events: none; cursor: default; } .flow-kind-count { margin-left: auto; font-size: 0.55rem; font-weight: 700; background: var(--kind-color); color: #0f172a; width: 16px; height: 16px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; } .flow-kind-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } .flow-dialog-input { width: 100%; padding: 6px 8px; border: 1px solid rgba(255,255,255,0.1); border-radius: 5px; background: rgba(255,255,255,0.04); color: inherit; font-size: 0.75rem; outline: none; } .flow-dialog-input:focus { border-color: rgba(34,211,238,0.4); } :host-context([data-theme="light"]) .flow-dialog-input { border-color: rgba(0,0,0,0.1); background: rgba(0,0,0,0.02); } .flow-dialog-range { width: 100%; accent-color: #22d3ee; } .flow-dialog-actions { display: flex; gap: 6px; justify-content: flex-end; margin-top: 4px; } .flow-dialog-cancel, .flow-dialog-create { padding: 5px 14px; border-radius: 6px; font-size: 0.7rem; font-weight: 600; cursor: pointer; } .flow-dialog-cancel { border: 1px solid rgba(255,255,255,0.1); background: transparent; color: inherit; opacity: 0.6; } .flow-dialog-cancel:hover { opacity: 1; } .flow-dialog-create { border: none; background: #22d3ee; color: #0f172a; } .flow-dialog-create:hover { opacity: 0.85; } /* ── Responsive ── */ @media (max-width: 640px) { .tab-label { display: none; } .tab { padding: 4px 8px; } .stack-view { max-height: 40vh; } .stack-view-3d { height: 260px; } .stack-scene { width: 240px; } .layer-plane { width: 240px; min-height: 56px; padding: 8px 10px; } .flow-dialog { width: 240px; } } `;