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

2307 lines
65 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* <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: "r🎨", color: "#5eead4" },
rnotes: { badge: "r📝", color: "#fcd34d" },
rpubs: { badge: "r📖", color: "#fda4af" },
rswag: { badge: "r👕", color: "#fda4af" },
rsplat: { badge: "r🔮", color: "#d8b4fe" },
rcal: { badge: "r📅", color: "#7dd3fc" },
rtrips: { badge: "r✈", color: "#6ee7b7" },
rmaps: { badge: "r🗺", color: "#86efac" },
rchats: { badge: "r🗨", color: "#6ee7b7" },
rinbox: { badge: "r📨", color: "#a5b4fc" },
rmail: { badge: "r✉", color: "#93c5fd" },
rforum: { badge: "r💬", color: "#fcd34d" },
rmeets: { badge: "r📹", color: "#67e8f9" },
rchoices: { badge: "r☑", color: "#f0abfc" },
rvote: { badge: "r🗳", color: "#c4b5fd" },
rflows: { badge: "r🌊", color: "#bef264" },
rwallet: { badge: "r💰", color: "#fde047" },
rcart: { badge: "r🛒", color: "#fdba74" },
rauctions: { badge: "r🏛", color: "#fca5a5" },
rtube: { badge: "r🎬", color: "#f9a8d4" },
rphotos: { badge: "r📸", color: "#f9a8d4" },
rnetwork: { badge: "r🌐", color: "#93c5fd" },
rsocials: { badge: "r📢", color: "#7dd3fc" },
rfiles: { badge: "r📁", color: "#67e8f9" },
rbooks: { badge: "r📚", color: "#fda4af" },
rdata: { badge: "r📊", color: "#d8b4fe" },
rtasks: { badge: "r📋", color: "#cbd5e1" },
rschedule: { badge: "r⏱", color: "#a5b4fc" },
rids: { badge: "r🪪", color: "#6ee7b7" },
rstack: { badge: "r✨", color: "" },
};
// 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",
rtasks: "Tasks & 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;
// Cleanup for document-level listeners (prevent leak on re-render)
#docCleanup: (() => 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();
}
}
// ── Space Layer Picker ──
/** Show a popup to pick a space to add as a composable layer. */
async #showSpaceLayerPicker() {
// Remove any existing picker
this.#shadow.querySelector(".space-layer-picker")?.remove();
// Fetch spaces from the API
let spaces: Array<{ slug: string; name: string; icon?: string; role?: string }> = [];
try {
const token = localStorage.getItem("encryptid_session");
const headers: Record<string, string> = {};
if (token) {
try {
const session = JSON.parse(token);
if (session?.accessToken) headers["Authorization"] = `Bearer ${session.accessToken}`;
} catch { /* ignore */ }
}
const res = await fetch("/api/spaces", { headers });
if (res.ok) {
const data = await res.json();
spaces = (data.spaces || []).filter(
(s: any) => s.slug !== this.space && s.role // only spaces user has access to
);
}
} catch { /* offline */ }
// Filter out spaces already added as layers
const existingSpaceSlugs = new Set(
this.#layers.filter(l => l.spaceSlug).map(l => l.spaceSlug)
);
spaces = spaces.filter(s => !existingSpaceSlugs.has(s.slug));
// Build picker HTML
let pickerHtml = `<div class="space-layer-picker">
<div class="space-layer-picker__header">
<span>Add Space Layer</span>
<button class="space-layer-picker__close" id="slp-close">&times;</button>
</div>`;
if (spaces.length === 0) {
pickerHtml += `<div class="space-layer-picker__empty">No other spaces available</div>`;
} else {
for (const s of spaces) {
const roleLabel = s.role === "viewer" ? "view only" : s.role || "";
pickerHtml += `
<button class="space-layer-picker__item" data-slug="${s.slug}" data-name="${(s.name || s.slug).replace(/"/g, '&quot;')}" data-role="${s.role || 'viewer'}">
<span class="space-layer-picker__icon">${s.icon || "🌐"}</span>
<span class="space-layer-picker__name">${s.name || s.slug}</span>
<span class="space-layer-picker__role">${roleLabel}</span>
</button>`;
}
}
pickerHtml += `</div>`;
// Inject into shadow DOM
const container = document.createElement("div");
container.innerHTML = pickerHtml;
const picker = container.firstElementChild!;
this.#shadow.appendChild(picker);
// Close button
this.#shadow.getElementById("slp-close")?.addEventListener("click", () => {
picker.remove();
});
// Space item clicks
picker.querySelectorAll<HTMLElement>(".space-layer-picker__item").forEach(item => {
item.addEventListener("click", () => {
const slug = item.dataset.slug!;
const name = item.dataset.name || slug;
const role = (item.dataset.role || "viewer") as Layer["spaceRole"];
const layer: Layer = {
id: `layer-space-${slug}`,
moduleId: "rspace",
label: `${name}`,
order: this.#layers.length,
color: "",
visible: true,
createdAt: Date.now(),
spaceSlug: slug,
spaceRole: role,
};
this.addLayer(layer);
this.dispatchEvent(new CustomEvent("space-layer-add", {
detail: { layer, spaceSlug: slug, role },
bubbles: true,
}));
picker.remove();
});
});
// Close on outside click
setTimeout(() => {
const handler = (e: Event) => {
if (!picker.contains(e.target as Node)) {
picker.remove();
document.removeEventListener("click", handler);
}
};
document.addEventListener("click", handler);
}, 0);
}
// ── 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.length === 0
? `<div class="tab active tab--dashboard">
<span class="tab-indicator" style="background:#5eead4"></span>
<span class="tab-badge" style="background:#5eead4">
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>
</span>
<span class="tab-label">Dashboard</span>
</div>`
: this.#layers.map(l => this.#renderTab(l, active)).join("")}
</div>
<div class="tab-add-wrap">
<button class="tab-add" id="add-btn" title="Add layer">+</button>
${this.#addMenuOpen ? this.#renderAddMenu() : ""}
</div>
<div class="tab-actions">
<slot></slot>
<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";
const isSpaceLayer = !!layer.spaceSlug;
const spaceLayerClass = isSpaceLayer ? " tab--space-layer" : "";
const spaceTag = isSpaceLayer
? `<span class="tab-space-tag" title="From ${layer.spaceSlug}">${layer.spaceSlug}</span>`
: "";
const readOnlyTag = isSpaceLayer && layer.spaceRole === "viewer"
? `<span class="tab-readonly-tag" title="View only">👁</span>`
: "";
return `
<div class="tab ${isActive ? "active" : ""}${spaceLayerClass}"
data-layer-id="${layer.id}"
data-module-id="${layer.moduleId}"
${isSpaceLayer ? `data-space-slug="${layer.spaceSlug}"` : ""}
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>
${spaceTag}${readOnlyTag}
<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("");
}
// ── Add Space Layer section ──
html += `<div class="add-menu-divider"></div>`;
html += `<button class="add-menu-item add-menu-item--space-layer" id="add-space-layer-btn">
<span class="add-menu-icon">🌐</span>
<div class="add-menu-text">
<span class="add-menu-name">Add Space Layer</span>
<span class="add-menu-desc">Overlay data from another space</span>
</div>
</button>`;
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() {
// Clean up previous document-level listeners to prevent leak
if (this.#docCleanup) { this.#docCleanup(); this.#docCleanup = null; }
// Tab clicks — dispatch event but do NOT set active yet.
// The shell's event handler calls switchTo() and sets active only after success.
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.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);
});
// Add Space Layer button handler
const spaceLayerBtn = this.#shadow.getElementById("add-space-layer-btn");
if (spaceLayerBtn) {
spaceLayerBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.#addMenuOpen = false;
this.#showSpaceLayerPicker();
});
}
// 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 — do NOT set active here, let shell handler confirm
plane.addEventListener("click", (e) => {
if (this.#flowDragSource || this.#orbitDragging) return;
const layer = this.#layers.find(l => l.id === layerId);
if (layer) {
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");
});
});
// 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";
});
// Collect all layer planes + their rects for drag target detection
const layerPlanes = [...this.#shadow.querySelectorAll<HTMLElement>(".layer-plane")];
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)`;
}
// Drag-to-connect: track target via bounding rects (robust for 3D)
if (this.#flowDragSource) {
let newTarget: string | null = null;
for (const p of layerPlanes) {
const r = p.getBoundingClientRect();
if (e.clientX >= r.left && e.clientX <= r.right &&
e.clientY >= r.top && e.clientY <= r.bottom &&
p.dataset.layerId !== this.#flowDragSource) {
newTarget = p.dataset.layerId!;
break;
}
}
if (newTarget !== this.#flowDragTarget) {
this.#shadow.querySelectorAll(".flow-drag-target")
.forEach(el => el.classList.remove("flow-drag-target"));
this.#flowDragTarget = newTarget;
if (newTarget) {
this.#shadow.querySelector(`.layer-plane[data-layer-id="${newTarget}"]`)
?.classList.add("flow-drag-target");
}
}
}
};
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 (cleaned up on re-render)
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
this.#docCleanup = () => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("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: 4px;
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: auto;
}
/* ── Individual tab ── */
.tab {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 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, border-color 0.15s;
user-select: none;
position: relative;
flex-shrink: 0;
color: var(--rs-text-muted);
background: rgba(255,255,255,0.03);
border: 1px solid transparent;
border-bottom: none;
}
.tab:hover {
background: var(--rs-bg-hover);
color: var(--rs-text-primary);
border-color: var(--rs-input-border);
}
.tab.active {
background: var(--rs-bg-surface);
color: var(--rs-text-primary);
border-color: var(--rs-input-border);
}
.tab--dashboard {
cursor: default;
pointer-events: none;
opacity: 0.8;
}
.tab--dashboard .tab-badge svg {
display: block;
}
/* Active indicator line at bottom */
.tab-indicator {
position: absolute;
bottom: -1px;
left: 0;
right: 0;
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;
min-width: 20px;
height: 20px;
border-radius: 4px;
font-size: 0.6rem;
font-weight: 900;
color: #1e293b;
line-height: 1;
flex-shrink: 0;
white-space: nowrap;
padding: 0 2px;
}
.tab-label {
font-size: 0.8rem;
}
.tab-close {
display: none;
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.active .tab-close { display: flex; opacity: 0.6; }
.tab.active .tab-close:hover { opacity: 1; background: rgba(239,68,68,0.2); color: #ef4444; }
/* Space layer tab styling */
.tab--space-layer {
border: 1px dashed rgba(99,102,241,0.4);
background: rgba(99,102,241,0.05);
}
.tab--space-layer.active {
border-color: rgba(99,102,241,0.6);
background: rgba(99,102,241,0.1);
}
.tab-space-tag {
font-size: 0.6rem;
padding: 1px 4px;
border-radius: 3px;
background: rgba(99,102,241,0.15);
color: #a5b4fc;
max-width: 50px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 0;
}
.tab-readonly-tag {
font-size: 0.65rem;
flex-shrink: 0;
opacity: 0.7;
}
/* ── Drag states ── */
.tab { cursor: grab; }
.tab.dragging { opacity: 0.4; cursor: grabbing; }
.tab.drag-over { box-shadow: inset 2px 0 0 #22d3ee; }
/* ── Add button ── */
.tab-add-wrap {
position: relative;
flex-shrink: 0;
margin-left: 2px;
z-index: 1000;
}
.tab-add {
display: flex;
align-items: center;
justify-content: center;
padding: 4px 12px;
min-height: 44px;
border: none;
border-radius: 6px 6px 0 0;
background: transparent;
color: var(--rs-text-muted);
font-size: 0.95rem;
cursor: pointer;
transition: background 0.15s, color 0.15s;
flex-shrink: 0;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
white-space: nowrap;
user-select: none;
}
.tab-add:hover, .tab-add:active {
background: var(--rs-bg-hover);
color: var(--rs-text-primary);
}
/* ── Add menu ── */
.add-menu {
position: absolute;
top: 100%;
left: 0;
right: auto;
margin-top: 4px;
min-width: 260px;
max-height: 400px;
overflow-y: auto;
border-radius: 10px;
padding: 4px;
z-index: 1000;
box-shadow: var(--rs-shadow-lg);
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 var(--rs-border-subtle);
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;
min-width: 24px;
height: 24px;
border-radius: 5px;
font-size: 0.65rem;
font-weight: 900;
color: #1e293b;
flex-shrink: 0;
white-space: nowrap;
padding: 0 3px;
}
.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;
}
.add-menu-divider {
height: 1px;
margin: 4px 8px;
background: var(--rs-border-subtle);
}
.add-menu-item--space-layer {
border-top: none;
}
.add-menu-item--space-layer .add-menu-icon {
font-size: 1rem;
}
/* ── Space Layer Picker ── */
.space-layer-picker {
position: absolute;
top: 100%;
right: 0;
min-width: 240px;
max-height: 320px;
overflow-y: auto;
background: var(--rs-bg-surface);
border: 1px solid var(--rs-border);
border-radius: 12px;
box-shadow: var(--rs-shadow-lg);
z-index: 10002;
margin-top: 4px;
}
.space-layer-picker__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px 6px;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--rs-text-secondary);
}
.space-layer-picker__close {
background: none;
border: none;
color: var(--rs-text-muted);
font-size: 1.1rem;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
line-height: 1;
}
.space-layer-picker__close:hover {
color: var(--rs-text-primary);
background: var(--rs-bg-hover);
}
.space-layer-picker__item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 14px;
border: none;
background: none;
cursor: pointer;
transition: background 0.12s;
text-align: left;
font-family: inherit;
color: var(--rs-text-primary);
}
.space-layer-picker__item:hover {
background: var(--rs-bg-hover);
}
.space-layer-picker__icon {
font-size: 1.1rem;
flex-shrink: 0;
}
.space-layer-picker__name {
font-size: 0.85rem;
font-weight: 500;
flex: 1;
}
.space-layer-picker__role {
font-size: 0.7rem;
color: var(--rs-text-muted);
flex-shrink: 0;
}
.space-layer-picker__empty {
padding: 16px;
text-align: center;
font-size: 0.8rem;
color: var(--rs-text-muted);
}
/* ── View toggle ── */
.view-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--rs-text-muted);
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;
min-width: 24px;
height: 24px;
border-radius: 5px;
font-size: 0.65rem;
font-weight: 900;
color: #1e293b;
flex-shrink: 0;
white-space: nowrap;
padding: 0 3px;
}
.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%, var(--rs-text-primary));
}
.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: var(--rs-shadow-xl);
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.65rem;
font-weight: 900;
color: #1e293b;
white-space: nowrap;
}
.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: var(--rs-bg-hover); }
.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: var(--rs-text-inverse);
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 var(--rs-border);
background: transparent;
color: inherit;
opacity: 0.6;
}
.flow-dialog-cancel:hover { opacity: 1; }
.flow-dialog-create {
border: none;
background: #22d3ee;
color: var(--rs-text-inverse);
}
.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; }
}
`;