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

1992 lines
56 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" },
rflows: { badge: "rFl", 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",
rflows: "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;
// Wiring mode state
#wiringActive = false;
#wiringSourceLayerId = "";
#wiringSourceFeedId = "";
#wiringSourceKind: FlowKind | null = null;
#escHandler: ((e: KeyboardEvent) => void) | null = null;
// Recent apps: moduleId → last-used timestamp
#recentApps: Map<string, number> = new Map();
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.#loadRecentApps();
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);
// Seed recent apps from existing layers
for (const l of layers) {
if (!this.#recentApps.has(l.moduleId)) {
this.#recentApps.set(l.moduleId, l.createdAt || 0);
}
}
this.#render();
}
/** Provide the available module list (for the + add menu) */
setModules(modules: TabBarModule[]) {
this.#modules = modules;
}
// ── Recent apps persistence ──
#recentAppsKey(): string {
return `rspace_recent_apps_${this.space || "default"}`;
}
#loadRecentApps() {
try {
const raw = localStorage.getItem(this.#recentAppsKey());
if (raw) {
const obj = JSON.parse(raw) as Record<string, number>;
this.#recentApps = new Map(Object.entries(obj));
}
} catch { /* ignore */ }
}
#saveRecentApps() {
try {
const obj: Record<string, number> = {};
for (const [k, v] of this.#recentApps) obj[k] = v;
localStorage.setItem(this.#recentAppsKey(), JSON.stringify(obj));
} catch { /* ignore */ }
}
/** Record a module as recently used (call from outside or internally) */
trackRecent(moduleId: string) {
this.#recentApps.set(moduleId, Date.now());
this.#saveRecentApps();
}
/** Set the inter-layer flows (for stack view) */
setFlows(flows: LayerFlow[]) {
this.#flows = flows;
if (this.#viewMode === "stack") {
const scene = this.#shadow.getElementById("stack-scene");
if (scene) this.#updateFlowsInPlace(scene);
else 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 allModules: Array<{ id: string; name: string; icon: string; description: string }> =
this.#modules.length > 0
? this.#modules
: Object.keys(MODULE_BADGES)
.map(id => ({ id, name: id, icon: "", description: "" }));
if (allModules.length === 0) {
return `<div class="add-menu" id="add-menu"><div class="add-menu-empty">No rApps available</div></div>`;
}
let html = "";
// ── Recent apps section (top of menu) ──
if (this.#recentApps.size > 0) {
const recentEntries = [...this.#recentApps.entries()]
.sort((a, b) => b[1] - a[1]) // newest first
.slice(0, 6); // show up to 6 recent
const recentModules = recentEntries
.map(([id]) => allModules.find(m => m.id === id))
.filter((m): m is typeof allModules[0] => !!m);
if (recentModules.length > 0) {
html += `<div class="add-menu-category">Recent</div>`;
html += recentModules.map(m => this.#renderAddMenuItem(m, existingModuleIds.has(m.id))).join("");
}
}
// ── Group by category ──
const groups = new Map<string, typeof allModules>();
const uncategorized: typeof allModules = [];
for (const m of allModules) {
const cat = MODULE_CATEGORIES[m.id];
if (cat) {
if (!groups.has(cat)) groups.set(cat, []);
groups.get(cat)!.push(m);
} else {
uncategorized.push(m);
}
}
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, existingModuleIds.has(m.id))).join("");
}
if (uncategorized.length > 0) {
html += `<div class="add-menu-category">Other</div>`;
html += uncategorized.map(m => this.#renderAddMenuItem(m, existingModuleIds.has(m.id))).join("");
}
return `<div class="add-menu" id="add-menu">${html}</div>`;
}
#renderAddMenuItem(m: { id: string; name: string; icon: string; description: string }, isOpen: boolean): 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>`;
const openClass = isOpen ? " add-menu-item--open" : "";
const openIndicator = isOpen ? `<span class="add-menu-open-dot" title="Already open">●</span>` : "";
return `
<button class="add-menu-item${openClass}" data-add-module="${m.id}" data-module-open="${isOpen}">
${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>
${openIndicator}
</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" : ""}"
data-feed-id="${f.id}" data-feed-kind="${f.kind}" data-layer-id="${layer.id}"
style="--chip-color:${FLOW_COLORS[f.kind]}" title="${f.description || f.name}">
<span class="io-dot"></span>${f.name}${contained ? '<span class="io-lock">🔒</span>' : ""}
</span>`;
}).join("");
const inChips = inKinds.map(k =>
`<span class="io-chip io-chip--in"
data-accept-kind="${k}" data-layer-id="${layer.id}"
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 tubes and particles
const activeFlows = this.#flows.filter(f =>
f.active && layerZMap.has(f.sourceLayerId) && layerZMap.has(f.targetLayerId));
const flowSpacing = 35;
const flowStartX = -(activeFlows.length - 1) * flowSpacing / 2;
let tubesHtml = "";
let particlesHtml = "";
activeFlows.forEach((flow, fi) => {
const srcZ = layerZMap.get(flow.sourceLayerId)!;
const tgtZ = layerZMap.get(flow.targetLayerId)!;
const color = flow.color || FLOW_COLORS[flow.kind] || "#94a3b8";
const minZ = Math.min(srcZ, tgtZ);
const maxZ = Math.max(srcZ, tgtZ);
const distance = maxZ - minZ;
const midZ = (minZ + maxZ) / 2;
const xOffset = activeFlows.length === 1 ? 0 : flowStartX + fi * flowSpacing;
const tubeWidth = 2 + Math.round(flow.strength * 4);
tubesHtml += `
<div class="flow-tube" data-flow-id="${flow.id}"
style="--color:${color}; width:${tubeWidth}px; height:${distance}px;
transform: translateZ(${midZ}px) rotateX(90deg);
margin-left:${xOffset - tubeWidth / 2}px;"></div>
`;
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;
--x-offset:${xOffset}px;
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 ? "⏸" : "▶"}
</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}
${tubesHtml}
${particlesHtml}
</div>
</div>
<div class="stack-legend">${legendHtml}</div>
${scrubberHtml}
${this.#layers.length >= 2 ? `<div class="stack-hint">Click an output port to wire, or drag between layers · 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">→</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();
}
// ── In-place flow update (prevents animation restart) ──
#updateFlowsInPlace(scene: HTMLElement) {
scene.querySelectorAll(".flow-tube, .flow-particle").forEach(el => el.remove());
const layerZMap = new Map<string, number>();
const layerSpacing = 80;
this.#layers.forEach((layer, i) => layerZMap.set(layer.id, i * layerSpacing));
const animDuration = 2 / this.#simSpeed;
const activeFlows = this.#flows.filter(f =>
f.active && layerZMap.has(f.sourceLayerId) && layerZMap.has(f.targetLayerId));
const flowSpacing = 35;
const flowStartX = -(activeFlows.length - 1) * flowSpacing / 2;
activeFlows.forEach((flow, fi) => {
const srcZ = layerZMap.get(flow.sourceLayerId)!;
const tgtZ = layerZMap.get(flow.targetLayerId)!;
const color = flow.color || FLOW_COLORS[flow.kind] || "#94a3b8";
const minZ = Math.min(srcZ, tgtZ);
const maxZ = Math.max(srcZ, tgtZ);
const distance = maxZ - minZ;
const midZ = (minZ + maxZ) / 2;
const xOffset = activeFlows.length === 1 ? 0 : flowStartX + fi * flowSpacing;
const tubeWidth = 2 + Math.round(flow.strength * 4);
const tube = document.createElement("div");
tube.className = "flow-tube";
tube.dataset.flowId = flow.id;
tube.style.cssText = `--color:${color}; width:${tubeWidth}px; height:${distance}px; transform: translateZ(${midZ}px) rotateX(90deg); margin-left:${xOffset - tubeWidth / 2}px;`;
scene.appendChild(tube);
const particleCount = Math.max(2, Math.round(flow.strength * 6));
for (let p = 0; p < particleCount; p++) {
const delay = (p / particleCount) * animDuration;
const particle = document.createElement("div");
particle.className = "flow-particle";
particle.dataset.flowId = flow.id;
particle.style.cssText = `--src-z:${srcZ}px; --tgt-z:${tgtZ}px; --color:${color}; --duration:${animDuration}s; --delay:${delay}s; --x-offset:${xOffset}px; animation-play-state:${this.#simPlaying ? "running" : "paused"};`;
scene.appendChild(particle);
}
});
this.#attachFlowEvents();
}
// ── Flow element events (shared between full render and in-place update) ──
#attachFlowEvents() {
this.#shadow.querySelectorAll<HTMLElement>(".flow-tube").forEach(tube => {
tube.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("flow-select", {
detail: { flowId: tube.dataset.flowId },
bubbles: true,
}));
});
tube.addEventListener("contextmenu", (e) => {
e.preventDefault();
const flowId = tube.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,
}));
}
});
});
this.#shadow.querySelectorAll<HTMLElement>(".flow-particle").forEach(p => {
p.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("flow-select", {
detail: { flowId: p.dataset.flowId },
bubbles: true,
}));
});
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,
}));
}
});
});
}
// ── Wiring mode ──
#enterWiring(layerId: string, feedId: string, kind: FlowKind) {
this.#wiringActive = true;
this.#wiringSourceLayerId = layerId;
this.#wiringSourceFeedId = feedId;
this.#wiringSourceKind = kind;
this.#applyWiringClasses();
this.#escHandler = (e: KeyboardEvent) => {
if (e.key === "Escape") this.#cancelWiring();
};
document.addEventListener("keydown", this.#escHandler);
}
#cancelWiring() {
this.#wiringActive = false;
this.#wiringSourceLayerId = "";
this.#wiringSourceFeedId = "";
this.#wiringSourceKind = null;
this.#shadow.querySelectorAll(".io-chip--wiring-source, .io-chip--wiring-target, .io-chip--wiring-dimmed")
.forEach(el => el.classList.remove("io-chip--wiring-source", "io-chip--wiring-target", "io-chip--wiring-dimmed"));
this.#shadow.querySelectorAll(".layer-plane--wiring-dimmed")
.forEach(el => el.classList.remove("layer-plane--wiring-dimmed"));
if (this.#escHandler) {
document.removeEventListener("keydown", this.#escHandler);
this.#escHandler = null;
}
}
#applyWiringClasses() {
const sourceKind = this.#wiringSourceKind;
const sourceLayerId = this.#wiringSourceLayerId;
const sourceFeedId = this.#wiringSourceFeedId;
// Output chips: highlight source, dim the rest
this.#shadow.querySelectorAll<HTMLElement>(".io-chip--out").forEach(chip => {
if (chip.dataset.feedId === sourceFeedId && chip.dataset.layerId === sourceLayerId) {
chip.classList.add("io-chip--wiring-source");
} else {
chip.classList.add("io-chip--wiring-dimmed");
}
});
// Input chips: highlight compatible targets on other layers
this.#shadow.querySelectorAll<HTMLElement>(".io-chip--in").forEach(chip => {
const layerId = chip.dataset.layerId!;
const acceptKind = chip.dataset.acceptKind as FlowKind;
if (layerId !== sourceLayerId && acceptKind === sourceKind) {
chip.classList.add("io-chip--wiring-target");
} else {
chip.classList.add("io-chip--wiring-dimmed");
}
});
// Dim layers with no compatible input chips
this.#shadow.querySelectorAll<HTMLElement>(".layer-plane").forEach(plane => {
const layerId = plane.dataset.layerId!;
if (layerId === sourceLayerId) return;
if (!plane.querySelector(".io-chip--wiring-target")) {
plane.classList.add("layer-plane--wiring-dimmed");
}
});
}
// ── 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.trackRecent(moduleId);
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 — click + touch support
const addBtn = this.#shadow.getElementById("add-btn");
const toggleAddMenu = (e: Event) => {
e.stopPropagation();
e.preventDefault();
this.#addMenuOpen = !this.#addMenuOpen;
this.#render();
};
addBtn?.addEventListener("click", toggleAddMenu);
addBtn?.addEventListener("touchend", toggleAddMenu);
// Add menu items — click + touch support
this.#shadow.querySelectorAll<HTMLElement>(".add-menu-item").forEach(item => {
const handleSelect = (e: Event) => {
e.stopPropagation();
e.preventDefault();
const moduleId = item.dataset.addModule!;
const isOpen = item.dataset.moduleOpen === "true";
this.#addMenuOpen = false;
this.trackRecent(moduleId);
if (isOpen) {
// Surface existing tab instead of adding a duplicate
const existing = this.#layers.find(l => l.moduleId === moduleId);
if (existing) {
this.setAttribute("active", existing.id);
this.dispatchEvent(new CustomEvent("layer-switch", {
detail: { layerId: existing.id, moduleId },
bubbles: true,
}));
}
} else {
this.dispatchEvent(new CustomEvent("layer-add", {
detail: { moduleId },
bubbles: true,
}));
}
};
item.addEventListener("click", handleSelect);
item.addEventListener("touchend", handleSelect);
});
// Close add menu on outside click/touch
if (this.#addMenuOpen) {
const handler = () => {
this.#addMenuOpen = false;
this.#render();
document.removeEventListener("click", handler);
document.removeEventListener("touchend", handler);
};
setTimeout(() => {
document.addEventListener("click", handler);
document.addEventListener("touchend", 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 (skip if wiring)
plane.addEventListener("mousedown", (e) => {
if ((e as MouseEvent).button !== 0) return;
if (this.#wiringActive) 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 tube + particle click/right-click events
this.#attachFlowEvents();
// Wiring mode: output chip clicks
this.#shadow.querySelectorAll<HTMLElement>(".io-chip--out").forEach(chip => {
chip.style.cursor = "pointer";
chip.addEventListener("click", (e) => {
e.stopPropagation();
const feedId = chip.dataset.feedId!;
const feedKind = chip.dataset.feedKind as FlowKind;
const chipLayerId = chip.dataset.layerId!;
if (this.#wiringActive && this.#wiringSourceFeedId === feedId && this.#wiringSourceLayerId === chipLayerId) {
this.#cancelWiring();
} else {
if (this.#wiringActive) this.#cancelWiring();
this.#enterWiring(chipLayerId, feedId, feedKind);
}
});
});
// Wiring mode: input chip clicks
this.#shadow.querySelectorAll<HTMLElement>(".io-chip--in").forEach(chip => {
chip.addEventListener("click", (e) => {
e.stopPropagation();
if (!this.#wiringActive || !this.#wiringSourceKind) return;
const acceptKind = chip.dataset.acceptKind as FlowKind;
const targetLayerId = chip.dataset.layerId!;
if (targetLayerId === this.#wiringSourceLayerId) return;
if (acceptKind !== this.#wiringSourceKind) return;
const flowId = `flow-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
this.dispatchEvent(new CustomEvent("flow-create", {
detail: {
flow: {
id: flowId,
kind: this.#wiringSourceKind,
sourceLayerId: this.#wiringSourceLayerId,
targetLayerId,
strength: 0.5,
active: true,
meta: { sourceFeedId: this.#wiringSourceFeedId },
}
},
bubbles: true,
}));
this.#cancelWiring();
});
});
// Cancel wiring on empty space click
if (sceneContainer) {
sceneContainer.addEventListener("click", (e) => {
if (this.#wiringActive && !(e.target as HTMLElement).closest(".io-chip")) {
this.#cancelWiring();
}
});
}
// 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 ? "⏸" : "▶";
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;
min-height: 36px;
padding: 0 8px;
overflow: visible;
}
.tabs-scroll {
display: flex;
align-items: center;
gap: 2px;
flex: 1;
min-width: 0;
overflow-x: auto;
overflow-y: visible;
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;
color: var(--rs-text-muted);
background: transparent;
}
.tab:hover {
background: var(--rs-bg-hover);
color: var(--rs-text-primary);
}
.tab.active {
background: var(--rs-btn-secondary-bg);
color: var(--rs-text-primary);
}
/* 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.35;
transition: opacity 0.15s, background 0.15s;
padding: 0;
line-height: 1;
}
.tab:hover .tab-close { opacity: 0.6; }
.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: 28px;
height: 28px;
min-width: 44px;
min-height: 44px;
border: 1px dashed rgba(148,163,184,0.3);
border-radius: 5px;
background: transparent;
color: #64748b;
font-size: 1rem;
cursor: pointer;
transition: border-color 0.15s, color 0.15s, background 0.15s;
flex-shrink: 0;
margin-left: 4px;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
position: relative;
z-index: 2;
}
.tab-add:hover, .tab-add:active {
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;
background: var(--rs-bg-surface);
border: 1px solid var(--rs-border);
}
.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: 8px 10px;
min-height: 40px;
border: none;
border-radius: 6px;
background: transparent;
color: inherit;
font-size: 0.8rem;
cursor: pointer;
text-align: left;
transition: background 0.12s;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
.add-menu-item:hover, .add-menu-item:active { background: var(--rs-bg-hover); }
.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-item--open {
opacity: 0.55;
}
.add-menu-open-dot {
margin-left: auto;
font-size: 0.5rem;
color: #22d3ee;
flex-shrink: 0;
padding-left: 6px;
}
.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: var(--rs-bg-hover);
color: var(--rs-text-primary);
}
.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;
background: color-mix(in srgb, var(--rs-bg-surface) 50%, transparent);
border-bottom: 1px solid var(--rs-border-subtle);
}
.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;
background: color-mix(in srgb, var(--rs-bg-surface) 65%, transparent);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
color: var(--rs-text-primary);
}
.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);
background: color-mix(in srgb, var(--rs-bg-surface) 85%, transparent);
}
/* 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 tubes ── */
.flow-tube {
position: absolute;
left: 50%;
top: 50%;
border-radius: 3px;
pointer-events: auto;
cursor: pointer;
transform-style: preserve-3d;
background: linear-gradient(to bottom, transparent, var(--color) 15%, var(--color) 85%, transparent);
opacity: 0.6;
transition: opacity 0.2s;
}
.flow-tube:hover {
opacity: 1;
}
.flow-tube::after {
content: '';
position: absolute;
inset: -2px;
border-radius: 3px;
background: var(--color);
filter: blur(4px);
opacity: 0;
animation: tube-pulse 3s ease-in-out infinite;
}
@keyframes tube-pulse {
0%, 100% { opacity: 0; }
50% { opacity: 0.4; }
}
/* ── Flow particles ── */
.flow-particle {
position: absolute;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color);
box-shadow: 0 0 6px var(--color);
left: calc(50% + var(--x-offset, 0px));
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; }
}
/* ── Wiring mode ── */
.io-chip--wiring-source {
animation: wiring-glow 1s ease-in-out infinite;
box-shadow: 0 0 12px var(--chip-color);
z-index: 10;
opacity: 1 !important;
}
@keyframes wiring-glow {
0%, 100% { box-shadow: 0 0 8px var(--chip-color); }
50% { box-shadow: 0 0 20px var(--chip-color); }
}
.io-chip--wiring-target {
animation: wiring-breathe 1.5s ease-in-out infinite;
border-style: solid !important;
cursor: pointer !important;
pointer-events: auto !important;
opacity: 1 !important;
}
@keyframes wiring-breathe {
0%, 100% { opacity: 0.7; }
50% { opacity: 1; box-shadow: 0 0 10px var(--chip-color); }
}
.io-chip--wiring-dimmed {
opacity: 0.2 !important;
pointer-events: none !important;
}
.layer-plane--wiring-dimmed {
opacity: 0.3 !important;
}
/* ── 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);
background: var(--rs-bg-surface);
border: 1px solid var(--rs-border);
color: var(--rs-text-primary);
}
.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 var(--rs-border);
border-radius: 5px;
background: var(--rs-btn-secondary-bg);
color: inherit;
font-size: 0.75rem;
outline: none;
}
.flow-dialog-input:focus { border-color: rgba(34,211,238,0.4); }
.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; }
}
`;