rspace-online/shared/components/rstack-tab-bar.ts

1651 lines
45 KiB
TypeScript

/**
* <rstack-tab-bar> — 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<string, { badge: string; color: string }> = {
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<string, string> = {
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<Layer>) {
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<FlowKind> {
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<FlowKind> {
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<FlowKind> {
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<FlowKind>();
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 = `
<style>${STYLES}</style>
<div class="tab-bar" data-view="${this.#viewMode}">
<div class="tabs-scroll">
${this.#layers.map(l => this.#renderTab(l, active)).join("")}
<button class="tab-add" id="add-btn" title="Add layer">+</button>
${this.#addMenuOpen ? this.#renderAddMenu() : ""}
</div>
<div class="tab-actions">
<button class="view-toggle ${this.#viewMode === "stack" ? "active" : ""}"
id="view-toggle" title="Toggle stack view">
${this.#viewMode === "stack" ? ICON_FLAT : ICON_STACK}
</button>
</div>
</div>
${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 `
<div class="tab ${isActive ? "active" : ""}"
data-layer-id="${layer.id}"
data-module-id="${layer.moduleId}"
draggable="true">
<span class="tab-indicator" style="background:${badgeColor}"></span>
<span class="tab-badge" style="background:${badgeColor}">${badge?.badge || layer.moduleId.slice(0, 2)}</span>
<span class="tab-label">${layer.label}</span>
${this.#layers.length > 1 ? `<button class="tab-close" data-close="${layer.id}">&times;</button>` : ""}
</div>
`;
}
#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 `<div class="add-menu" id="add-menu"><div class="add-menu-empty">All rApps added</div></div>`;
}
// Group by category
const groups = new Map<string, typeof availableModules>();
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 += `<div class="add-menu-category">${cat}</div>`;
html += items.map(m => this.#renderAddMenuItem(m)).join("");
}
if (uncategorized.length > 0) {
html += `<div class="add-menu-category">Other</div>`;
html += uncategorized.map(m => this.#renderAddMenuItem(m)).join("");
}
return `<div class="add-menu" id="add-menu">${html}</div>`;
}
#renderAddMenuItem(m: { id: string; name: string; icon: string; description: string }): string {
const badge = MODULE_BADGES[m.id];
const badgeHtml = badge
? `<span class="add-menu-badge" style="background:${badge.color}">${badge.badge}</span>`
: `<span class="add-menu-icon">${m.icon}</span>`;
return `
<button class="add-menu-item" data-add-module="${m.id}">
${badgeHtml}
<div class="add-menu-text">
<span class="add-menu-name">${m.name} <span class="add-menu-emoji">${m.icon}</span></span>
${m.description ? `<span class="add-menu-desc">${m.description}</span>` : ""}
</div>
</button>
`;
}
#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<string, number>();
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);
// Build I/O chip markup — output feeds (right) and input accepts (left)
const mod = this.#modules.find(m => m.id === layer.moduleId);
const outFeeds = mod?.feeds || [];
const inKinds = mod?.acceptsFeeds || [];
const containedSet = new Set(containedFeeds.map(f => f.id));
const outChips = outFeeds.map(f => {
const contained = containedSet.has(f.id);
return `<span class="io-chip io-chip--out ${contained ? "io-chip--contained" : ""}"
style="--chip-color:${FLOW_COLORS[f.kind]}" title="${f.description || f.name}">
<span class="io-dot"></span>${f.name}${contained ? '<span class="io-lock">\uD83D\uDD12</span>' : ""}
</span>`;
}).join("");
const inChips = inKinds.map(k =>
`<span class="io-chip io-chip--in" style="--chip-color:${FLOW_COLORS[k]}" title="Accepts ${FLOW_LABELS[k]}">
<span class="io-dot"></span>${FLOW_LABELS[k]}
</span>`
).join("");
const hasIO = outFeeds.length > 0 || inKinds.length > 0;
layersHtml += `
<div class="layer-plane ${isActive ? "layer-plane--active" : ""}"
data-layer-id="${layer.id}"
style="--layer-color:${color}; transform: translateZ(${z}px);">
<div class="layer-header">
<span class="layer-badge" style="background:${color}">${badge?.badge || layer.moduleId.slice(0, 2)}</span>
<span class="layer-name">${layer.label}</span>
</div>
${hasIO ? `<div class="layer-io">
<div class="io-col io-col--in">${inChips}</div>
<div class="io-col io-col--out">${outChips}</div>
</div>` : ""}
</div>
`;
});
// 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 += `
<div class="flow-particle"
data-flow-id="${flow.id}"
style="--src-z:${srcZ}px; --tgt-z:${tgtZ}px; --color:${color};
--duration:${animDuration}s; --delay:${delay}s;
animation-play-state:${this.#simPlaying ? "running" : "paused"};"></div>
`;
}
}
// Flow legend
const activeKinds = new Set(this.#flows.map(f => f.kind));
const legendHtml = [...activeKinds].map(k => `
<span class="legend-item">
<span class="legend-dot" style="background:${FLOW_COLORS[k]}"></span>
${FLOW_LABELS[k]}
</span>
`).join("");
// Time scrubber
const scrubberHtml = `
<div class="time-scrubber">
<button class="scrubber-playpause" id="scrubber-playpause" title="${this.#simPlaying ? "Pause" : "Play"}">
${this.#simPlaying ? "\u23F8" : "\u25B6"}
</button>
<input type="range" class="scrubber-range" id="scrubber-range"
min="0.1" max="5" step="0.1" value="${this.#simSpeed}" />
<span class="scrubber-label" id="scrubber-label">${this.#simSpeed.toFixed(1)}x</span>
</div>
`;
return `
<div class="stack-view">
<div class="stack-view-3d" id="stack-3d"
style="perspective:${this.#scenePerspective}px;">
<div class="stack-scene" id="stack-scene"
style="transform: rotateX(${this.#sceneRotX}deg) rotateZ(${this.#sceneRotZ}deg);">
${layersHtml}
${particlesHtml}
</div>
</div>
<div class="stack-legend">${legendHtml}</div>
${scrubberHtml}
${this.#layers.length >= 2 ? `<div class="stack-hint">Drag between layers to create a flow \u00b7 Drag empty space to orbit</div>` : ""}
${this.#flowDialogOpen ? this.#renderFlowDialog() : ""}
</div>
`;
}
// ── 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<FlowKind, number>();
if (srcMod?.feeds) {
for (const f of srcMod.feeds) {
feedCountByKind.set(f.kind, (feedCountByKind.get(f.kind) || 0) + 1);
}
}
return `
<div class="flow-dialog" id="flow-dialog">
<div class="flow-dialog-header">New Flow</div>
<div class="flow-dialog-route">
<span class="flow-dialog-badge" style="background:${srcBadge?.color || "#94a3b8"}">${srcBadge?.badge || srcLayer.moduleId}</span>
<span class="flow-dialog-arrow">\u2192</span>
<span class="flow-dialog-badge" style="background:${tgtBadge?.color || "#94a3b8"}">${tgtBadge?.badge || tgtLayer.moduleId}</span>
</div>
<div class="flow-dialog-field">
<label class="flow-dialog-label">Flow Type</label>
<div class="flow-kind-grid" id="flow-kind-grid">
${kinds.map(k => {
const isCompatible = !hasModuleData || compatible.has(k);
const feedCount = feedCountByKind.get(k) || 0;
return `
<button class="flow-kind-btn ${!isCompatible ? "disabled" : ""}"
data-kind="${k}"
style="--kind-color:${FLOW_COLORS[k]}"
${!isCompatible ? 'disabled aria-disabled="true"' : ""}>
<span class="flow-kind-dot" style="background:${FLOW_COLORS[k]}"></span>
${FLOW_LABELS[k]}
${isCompatible && feedCount > 0 ? `<span class="flow-kind-count">${feedCount}</span>` : ""}
</button>
`;
}).join("")}
</div>
</div>
<div class="flow-dialog-field">
<label class="flow-dialog-label">Label (optional)</label>
<input class="flow-dialog-input" id="flow-label-input" type="text" placeholder="e.g. Budget allocation" />
</div>
<div class="flow-dialog-field">
<label class="flow-dialog-label">Strength</label>
<input class="flow-dialog-range" id="flow-strength-input" type="range" min="0.1" max="1" step="0.1" value="0.5" />
</div>
<div class="flow-dialog-actions">
<button class="flow-dialog-cancel" id="flow-cancel">Cancel</button>
<button class="flow-dialog-create" id="flow-create">Create Flow</button>
</div>
</div>
`;
}
#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<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".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 = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="2" y="2" width="12" height="3" rx="1" />
<rect x="2" y="6.5" width="12" height="3" rx="1" />
<rect x="2" y="11" width="12" height="3" rx="1" />
</svg>`;
const ICON_FLAT = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="1" y="4" width="14" height="9" rx="1.5" />
<path d="M1 6h14" />
<circle cx="3.5" cy="5" r="0.5" fill="currentColor" />
<circle cx="5.5" cy="5" r="0.5" fill="currentColor" />
<circle cx="7.5" cy="5" r="0.5" fill="currentColor" />
</svg>`;
// ── 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: 44px;
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;
}
/* ── I/O chip system ── */
.layer-io {
display: flex;
justify-content: space-between;
gap: 6px;
margin-top: 5px;
}
.io-col {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.io-col--in { align-items: flex-start; }
.io-col--out { align-items: flex-end; }
.io-chip {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.55rem;
font-weight: 600;
padding: 2px 7px;
border-radius: 9px;
white-space: nowrap;
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
transition: opacity 0.15s, box-shadow 0.15s;
cursor: default;
}
.io-chip--out {
background: color-mix(in srgb, var(--chip-color) 18%, transparent);
border: 1px solid color-mix(in srgb, var(--chip-color) 40%, transparent);
color: var(--chip-color);
}
.io-chip--in {
background: transparent;
border: 1px dashed color-mix(in srgb, var(--chip-color) 35%, transparent);
color: color-mix(in srgb, var(--chip-color) 70%, #e2e8f0);
}
.io-chip--contained {
opacity: 0.5;
}
.io-chip:hover {
opacity: 1;
box-shadow: 0 0 8px color-mix(in srgb, var(--chip-color) 30%, transparent);
}
.io-dot {
width: 5px;
height: 5px;
border-radius: 50%;
flex-shrink: 0;
}
.io-chip--out .io-dot {
background: var(--chip-color);
box-shadow: 0 0 4px var(--chip-color);
}
.io-chip--in .io-dot {
background: transparent;
box-shadow: inset 0 0 0 1.5px var(--chip-color);
}
.io-lock {
font-size: 0.5rem;
margin-left: 2px;
opacity: 0.6;
}
/* ── 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: 40px; padding: 8px 10px; }
.io-chip { font-size: 0.5rem; padding: 1px 5px; max-width: 100px; }
.flow-dialog { width: 240px; }
}
`;