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

1081 lines
29 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 } from "../../lib/layer-types";
import { FLOW_COLORS, FLOW_LABELS } from "../../lib/layer-types";
// Re-export badge info so the tab bar can show module colors
const MODULE_BADGES: Record<string, { badge: string; color: string }> = {
canvas: { badge: "rS", color: "#5eead4" },
notes: { badge: "rN", color: "#fcd34d" },
pubs: { badge: "rP", color: "#fda4af" },
swag: { badge: "rSw", color: "#fda4af" },
splat: { badge: "r3", color: "#d8b4fe" },
cal: { badge: "rC", color: "#7dd3fc" },
trips: { badge: "rT", color: "#6ee7b7" },
maps: { badge: "rM", color: "#86efac" },
chats: { badge: "rCh", color: "#6ee7b7" },
inbox: { badge: "rI", color: "#a5b4fc" },
mail: { badge: "rMa", color: "#93c5fd" },
forum: { badge: "rFo", color: "#fcd34d" },
choices: { badge: "rCo", color: "#f0abfc" },
vote: { badge: "rV", color: "#c4b5fd" },
funds: { badge: "rF", color: "#bef264" },
wallet: { badge: "rW", color: "#fde047" },
cart: { badge: "rCt", color: "#fdba74" },
auctions: { badge: "rA", color: "#fca5a5" },
providers: { badge: "rPr", color: "#fdba74" },
tube: { badge: "rTu", color: "#f9a8d4" },
photos: { badge: "rPh", color: "#f9a8d4" },
network: { badge: "rNe", color: "#93c5fd" },
socials: { badge: "rSo", color: "#7dd3fc" },
files: { badge: "rFi", color: "#67e8f9" },
books: { badge: "rB", color: "#fda4af" },
data: { badge: "rD", color: "#d8b4fe" },
work: { badge: "rWo", color: "#cbd5e1" },
};
export class RStackTabBar extends HTMLElement {
#shadow: ShadowRoot;
#layers: Layer[] = [];
#flows: LayerFlow[] = [];
#viewMode: "flat" | "stack" = "flat";
#draggedTabId: string | null = null;
#addMenuOpen = false;
#flowDragSource: string | null = null;
#flowDragTarget: string | null = null;
#flowDialogOpen = false;
#flowDialogSourceId = "";
#flowDialogTargetId = "";
constructor() {
super();
this.#shadow = this.attachShadow({ mode: "open" });
}
static get observedAttributes() {
return ["active", "space", "view-mode"];
}
get active(): string {
return this.getAttribute("active") || "";
}
set active(val: string) {
this.setAttribute("active", val);
}
get space(): string {
return this.getAttribute("space") || "";
}
get viewMode(): "flat" | "stack" {
return (this.getAttribute("view-mode") as "flat" | "stack") || "flat";
}
set viewMode(val: "flat" | "stack") {
this.setAttribute("view-mode", val);
}
connectedCallback() {
this.#render();
}
attributeChangedCallback() {
this.#viewMode = this.viewMode;
this.#render();
}
/** Set the layer list (call from outside) */
setLayers(layers: Layer[]) {
this.#layers = [...layers].sort((a, b) => a.order - b.order);
this.#render();
}
/** Set the inter-layer flows (for stack view) */
setFlows(flows: LayerFlow[]) {
this.#flows = flows;
if (this.#viewMode === "stack") this.#render();
}
/** Add a single layer */
addLayer(layer: Layer) {
this.#layers.push(layer);
this.#layers.sort((a, b) => a.order - b.order);
this.#render();
}
/** Remove a layer by ID */
removeLayer(layerId: string) {
this.#layers = this.#layers.filter(l => l.id !== layerId);
this.#render();
}
/** Update a layer */
updateLayer(layerId: string, updates: Partial<Layer>) {
const layer = this.#layers.find(l => l.id === layerId);
if (layer) {
Object.assign(layer, updates);
this.#layers.sort((a, b) => a.order - b.order);
this.#render();
}
}
// ── 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 {
// Group available modules (show ones not yet added as layers)
const existingModuleIds = new Set(this.#layers.map(l => l.moduleId));
const available = Object.entries(MODULE_BADGES)
.filter(([id]) => !existingModuleIds.has(id))
.map(([id, info]) => ({ id, ...info }));
if (available.length === 0) {
return `<div class="add-menu" id="add-menu"><div class="add-menu-empty">All modules added</div></div>`;
}
return `
<div class="add-menu" id="add-menu">
${available.map(m => `
<button class="add-menu-item" data-add-module="${m.id}">
<span class="add-menu-badge" style="background:${m.color}">${m.badge}</span>
<span>${m.id}</span>
</button>
`).join("")}
</div>
`;
}
#renderStackView(): string {
const layerCount = this.#layers.length;
if (layerCount === 0) return "";
const layerHeight = 60;
const layerGap = 40;
const totalHeight = layerCount * layerHeight + (layerCount - 1) * layerGap + 80;
const width = 600;
// Build layer rects and flow arcs
let layersSvg = "";
let flowsSvg = "";
const layerPositions = new Map<string, { x: number; y: number; w: number; h: number }>();
this.#layers.forEach((layer, i) => {
const y = 40 + i * (layerHeight + layerGap);
const x = 40;
const w = width - 80;
const badge = MODULE_BADGES[layer.moduleId];
const color = layer.color || badge?.color || "#94a3b8";
layerPositions.set(layer.id, { x, y, w, h: layerHeight });
const isActive = layer.id === this.active;
layersSvg += `
<g class="stack-layer ${isActive ? "stack-layer--active" : ""}"
data-layer-id="${layer.id}">
<rect x="${x}" y="${y}" width="${w}" height="${layerHeight}"
rx="8" fill="${color}20" stroke="${color}" stroke-width="${isActive ? 2 : 1}"
style="cursor:pointer" />
<text x="${x + 14}" y="${y + layerHeight / 2 + 5}"
fill="${color}" font-size="13" font-weight="600">${layer.label}</text>
<text x="${x + w - 14}" y="${y + layerHeight / 2 + 5}"
fill="${color}80" font-size="11" text-anchor="end">${badge?.badge || layer.moduleId}</text>
</g>
`;
});
// Draw flows as curved arcs on the right side
for (const flow of this.#flows) {
const src = layerPositions.get(flow.sourceLayerId);
const tgt = layerPositions.get(flow.targetLayerId);
if (!src || !tgt) continue;
const srcY = src.y + src.h / 2;
const tgtY = tgt.y + tgt.h / 2;
const rightX = src.x + src.w;
const arcOut = 30 + flow.strength * 40;
const color = flow.color || FLOW_COLORS[flow.kind] || "#94a3b8";
const strokeWidth = 1.5 + flow.strength * 3;
// Going down or up — arc curves right
const direction = tgtY > srcY ? 1 : -1;
const midY = (srcY + tgtY) / 2;
flowsSvg += `
<g class="stack-flow" data-flow-id="${flow.id}">
<path d="M ${rightX} ${srcY}
C ${rightX + arcOut} ${srcY},
${rightX + arcOut} ${tgtY},
${rightX} ${tgtY}"
fill="none" stroke="${color}" stroke-width="${strokeWidth}"
stroke-dasharray="${flow.active ? "none" : "4 4"}"
opacity="${flow.active ? 0.8 : 0.4}"
style="cursor:pointer" />
<circle cx="${rightX}" cy="${srcY}" r="3" fill="${color}" />
<circle cx="${rightX}" cy="${tgtY}" r="3" fill="${color}" />
${flow.label ? `
<text x="${rightX + arcOut + 6}" y="${midY + 4}"
fill="${color}" font-size="10" opacity="0.7">${flow.label}</text>
` : ""}
<!-- Arrow head -->
<polygon points="${rightX},${tgtY} ${rightX + 7},${tgtY - 4 * direction} ${rightX + 7},${tgtY + 4 * direction}"
fill="${color}" opacity="${flow.active ? 0.8 : 0.4}" />
</g>
`;
}
// Flow kind legend
const activeKinds = new Set(this.#flows.map(f => f.kind));
let legendSvg = "";
let legendX = 50;
for (const kind of activeKinds) {
const color = FLOW_COLORS[kind];
legendSvg += `
<circle cx="${legendX}" cy="${totalHeight - 16}" r="4" fill="${color}" />
<text x="${legendX + 8}" y="${totalHeight - 12}" fill="${color}" font-size="10">${FLOW_LABELS[kind]}</text>
`;
legendX += FLOW_LABELS[kind].length * 7 + 24;
}
// Flow creation hint
const hintSvg = this.#layers.length >= 2 ? `
<text x="${width / 2}" y="${totalHeight - 2}" fill="#475569" font-size="9" text-anchor="middle">
Drag from one layer to another to create a flow
</text>
` : "";
return `
<div class="stack-view">
<svg width="100%" height="${totalHeight}" viewBox="0 0 ${width} ${totalHeight}">
${flowsSvg}
${layersSvg}
${legendSvg}
${hintSvg}
</svg>
${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"];
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 => `
<button class="flow-kind-btn" data-kind="${k}" style="--kind-color:${FLOW_COLORS[k]}">
<span class="flow-kind-dot" style="background:${FLOW_COLORS[k]}"></span>
${FLOW_LABELS[k]}
</button>
`).join("")}
</div>
</div>
<div class="flow-dialog-field">
<label class="flow-dialog-label">Label (optional)</label>
<input class="flow-dialog-input" id="flow-label-input" type="text" placeholder="e.g. Budget allocation" />
</div>
<div class="flow-dialog-field">
<label class="flow-dialog-label">Strength</label>
<input class="flow-dialog-range" id="flow-strength-input" type="range" min="0.1" max="1" step="0.1" value="0.5" />
</div>
<div class="flow-dialog-actions">
<button class="flow-dialog-cancel" id="flow-cancel">Cancel</button>
<button class="flow-dialog-create" id="flow-create">Create Flow</button>
</div>
</div>
`;
}
#openFlowDialog(sourceLayerId: string, targetLayerId: string) {
this.#flowDialogOpen = true;
this.#flowDialogSourceId = sourceLayerId;
this.#flowDialogTargetId = targetLayerId;
this.#render();
}
#closeFlowDialog() {
this.#flowDialogOpen = false;
this.#flowDialogSourceId = "";
this.#flowDialogTargetId = "";
this.#render();
}
// ── Events ──
#attachEvents() {
// Tab clicks
this.#shadow.querySelectorAll<HTMLElement>(".tab").forEach(tab => {
tab.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
if (target.classList.contains("tab-close")) return;
const layerId = tab.dataset.layerId!;
const moduleId = tab.dataset.moduleId!;
this.active = layerId;
this.dispatchEvent(new CustomEvent("layer-switch", {
detail: { layerId, moduleId },
bubbles: true,
}));
});
// Drag-and-drop reorder
tab.addEventListener("dragstart", (e) => {
this.#draggedTabId = tab.dataset.layerId!;
tab.classList.add("dragging");
(e as DragEvent).dataTransfer!.effectAllowed = "move";
});
tab.addEventListener("dragend", () => {
tab.classList.remove("dragging");
this.#draggedTabId = null;
});
tab.addEventListener("dragover", (e) => {
e.preventDefault();
(e as DragEvent).dataTransfer!.dropEffect = "move";
tab.classList.add("drag-over");
});
tab.addEventListener("dragleave", () => {
tab.classList.remove("drag-over");
});
tab.addEventListener("drop", (e) => {
e.preventDefault();
tab.classList.remove("drag-over");
if (!this.#draggedTabId || this.#draggedTabId === tab.dataset.layerId) return;
const targetIdx = this.#layers.findIndex(l => l.id === tab.dataset.layerId);
this.dispatchEvent(new CustomEvent("layer-reorder", {
detail: { layerId: this.#draggedTabId, newIndex: targetIdx },
bubbles: true,
}));
});
});
// Close buttons
this.#shadow.querySelectorAll<HTMLElement>(".tab-close").forEach(btn => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const layerId = (btn as HTMLElement).dataset.close!;
this.dispatchEvent(new CustomEvent("layer-close", {
detail: { layerId },
bubbles: true,
}));
});
});
// Add button
const addBtn = this.#shadow.getElementById("add-btn");
addBtn?.addEventListener("click", (e) => {
e.stopPropagation();
this.#addMenuOpen = !this.#addMenuOpen;
this.#render();
});
// Add menu items
this.#shadow.querySelectorAll<HTMLElement>(".add-menu-item").forEach(item => {
item.addEventListener("click", (e) => {
e.stopPropagation();
const moduleId = item.dataset.addModule!;
this.#addMenuOpen = false;
this.dispatchEvent(new CustomEvent("layer-add", {
detail: { moduleId },
bubbles: true,
}));
});
});
// Close add menu on outside click
if (this.#addMenuOpen) {
const handler = () => {
this.#addMenuOpen = false;
this.#render();
document.removeEventListener("click", handler);
};
setTimeout(() => document.addEventListener("click", handler), 0);
}
// View toggle
const viewToggle = this.#shadow.getElementById("view-toggle");
viewToggle?.addEventListener("click", () => {
const newMode = this.#viewMode === "flat" ? "stack" : "flat";
this.#viewMode = newMode;
this.setAttribute("view-mode", newMode);
this.dispatchEvent(new CustomEvent("view-toggle", {
detail: { mode: newMode },
bubbles: true,
}));
this.#render();
});
// Stack view layer clicks + drag-to-connect
this.#shadow.querySelectorAll<SVGGElement>(".stack-layer").forEach(g => {
const layerId = g.dataset.layerId!;
// Click to switch layer
g.addEventListener("click", () => {
if (this.#flowDragSource) return; // ignore if mid-drag
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
g.addEventListener("mousedown", (e) => {
if ((e as MouseEvent).button !== 0) return;
this.#flowDragSource = layerId;
g.classList.add("flow-drag-source");
});
g.addEventListener("mouseenter", () => {
if (this.#flowDragSource && this.#flowDragSource !== layerId) {
this.#flowDragTarget = layerId;
g.classList.add("flow-drag-target");
}
});
g.addEventListener("mouseleave", () => {
if (this.#flowDragTarget === layerId) {
this.#flowDragTarget = null;
g.classList.remove("flow-drag-target");
}
});
});
// Global mouseup completes the flow drag
const svgEl = this.#shadow.querySelector<SVGElement>(".stack-view svg");
if (svgEl) {
svgEl.addEventListener("mouseup", () => {
if (this.#flowDragSource && this.#flowDragTarget) {
this.#openFlowDialog(this.#flowDragSource, this.#flowDragTarget);
}
// Reset drag state
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");
});
});
}
// Stack view flow clicks — select or delete
this.#shadow.querySelectorAll<SVGGElement>(".stack-flow").forEach(g => {
g.addEventListener("click", (e) => {
e.stopPropagation();
const flowId = g.dataset.flowId!;
this.dispatchEvent(new CustomEvent("flow-select", {
detail: { flowId },
bubbles: true,
}));
});
// Right-click to delete flow
g.addEventListener("contextmenu", (e) => {
e.preventDefault();
const flowId = g.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,
}));
}
});
});
// Flow dialog events
if (this.#flowDialogOpen) {
let selectedKind: FlowKind = "data";
this.#shadow.querySelectorAll<HTMLElement>(".flow-kind-btn").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 "data" by default
const defaultBtn = this.#shadow.querySelector('.flow-kind-btn[data-kind="data"]');
defaultBtn?.classList.add("selected");
this.#shadow.getElementById("flow-cancel")?.addEventListener("click", () => {
this.#closeFlowDialog();
});
this.#shadow.getElementById("flow-create")?.addEventListener("click", () => {
const label = (this.#shadow.getElementById("flow-label-input") as HTMLInputElement)?.value || "";
const strength = parseFloat((this.#shadow.getElementById("flow-strength-input") as HTMLInputElement)?.value || "0.5");
const flowId = `flow-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
this.dispatchEvent(new CustomEvent("flow-create", {
detail: {
flow: {
id: flowId,
kind: selectedKind,
sourceLayerId: this.#flowDialogSourceId,
targetLayerId: this.#flowDialogTargetId,
label: label || undefined,
strength,
active: true,
}
},
bubbles: true,
}));
this.#closeFlowDialog();
});
}
}
static define(tag = "rstack-tab-bar") {
if (!customElements.get(tag)) customElements.define(tag, RStackTabBar);
}
}
// ── SVG Icons ──
const ICON_STACK = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="2" y="2" width="12" height="3" rx="1" />
<rect x="2" y="6.5" width="12" height="3" rx="1" />
<rect x="2" y="11" width="12" height="3" rx="1" />
</svg>`;
const ICON_FLAT = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="1" y="4" width="14" height="9" rx="1.5" />
<path d="M1 6h14" />
<circle cx="3.5" cy="5" r="0.5" fill="currentColor" />
<circle cx="5.5" cy="5" r="0.5" fill="currentColor" />
<circle cx="7.5" cy="5" r="0.5" fill="currentColor" />
</svg>`;
// ── Styles ──
const STYLES = `
:host { display: block; }
/* ── Tab bar (flat mode) ── */
.tab-bar {
display: flex;
align-items: center;
gap: 0;
height: 36px;
padding: 0 8px;
overflow: hidden;
}
.tabs-scroll {
display: flex;
align-items: center;
gap: 2px;
flex: 1;
min-width: 0;
overflow-x: auto;
scrollbar-width: none;
position: relative;
}
.tabs-scroll::-webkit-scrollbar { display: none; }
.tab-actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
margin-left: 4px;
}
/* ── Individual tab ── */
.tab {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: 6px 6px 0 0;
cursor: pointer;
white-space: nowrap;
font-size: 0.8rem;
font-weight: 500;
transition: background 0.15s, opacity 0.15s;
user-select: none;
position: relative;
flex-shrink: 0;
}
:host-context([data-theme="dark"]) .tab {
color: #94a3b8;
background: transparent;
}
:host-context([data-theme="dark"]) .tab:hover {
background: rgba(255,255,255,0.05);
color: #e2e8f0;
}
:host-context([data-theme="dark"]) .tab.active {
background: rgba(255,255,255,0.08);
color: #f1f5f9;
}
:host-context([data-theme="light"]) .tab {
color: #64748b;
background: transparent;
}
:host-context([data-theme="light"]) .tab:hover {
background: rgba(0,0,0,0.04);
color: #1e293b;
}
:host-context([data-theme="light"]) .tab.active {
background: rgba(0,0,0,0.06);
color: #0f172a;
}
/* Active indicator line at bottom */
.tab-indicator {
position: absolute;
bottom: 0;
left: 8px;
right: 8px;
height: 2px;
border-radius: 2px 2px 0 0;
opacity: 0;
transition: opacity 0.15s;
}
.tab.active .tab-indicator { opacity: 1; }
.tab-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 4px;
font-size: 0.55rem;
font-weight: 900;
color: #0f172a;
line-height: 1;
flex-shrink: 0;
}
.tab-label {
font-size: 0.8rem;
}
.tab-close {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border: none;
border-radius: 3px;
background: transparent;
color: inherit;
font-size: 0.85rem;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s, background 0.15s;
padding: 0;
line-height: 1;
}
.tab:hover .tab-close { opacity: 0.5; }
.tab-close:hover { opacity: 1 !important; background: rgba(239,68,68,0.2); color: #ef4444; }
/* ── Drag states ── */
.tab.dragging { opacity: 0.4; }
.tab.drag-over { box-shadow: inset 2px 0 0 #22d3ee; }
/* ── Add button ── */
.tab-add {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: 1px dashed rgba(148,163,184,0.3);
border-radius: 5px;
background: transparent;
color: #64748b;
font-size: 0.9rem;
cursor: pointer;
transition: border-color 0.15s, color 0.15s, background 0.15s;
flex-shrink: 0;
margin-left: 4px;
}
.tab-add:hover {
border-color: #22d3ee;
color: #22d3ee;
background: rgba(34,211,238,0.08);
}
/* ── Add menu ── */
.add-menu {
position: absolute;
top: 100%;
left: auto;
right: 0;
margin-top: 4px;
min-width: 180px;
max-height: 300px;
overflow-y: auto;
border-radius: 8px;
padding: 4px;
z-index: 100;
box-shadow: 0 8px 30px rgba(0,0,0,0.25);
}
:host-context([data-theme="dark"]) .add-menu {
background: #1e293b;
border: 1px solid rgba(255,255,255,0.1);
}
:host-context([data-theme="light"]) .add-menu {
background: white;
border: 1px solid rgba(0,0,0,0.1);
}
.add-menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 10px;
border: none;
border-radius: 5px;
background: transparent;
color: inherit;
font-size: 0.8rem;
cursor: pointer;
text-align: left;
transition: background 0.12s;
}
:host-context([data-theme="dark"]) .add-menu-item:hover { background: rgba(255,255,255,0.06); }
:host-context([data-theme="light"]) .add-menu-item:hover { background: rgba(0,0,0,0.04); }
.add-menu-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 5px;
font-size: 0.55rem;
font-weight: 900;
color: #0f172a;
flex-shrink: 0;
}
.add-menu-empty {
padding: 12px;
text-align: center;
font-size: 0.75rem;
opacity: 0.5;
}
/* ── View toggle ── */
.view-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 6px;
background: transparent;
color: #64748b;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.view-toggle:hover {
background: rgba(255,255,255,0.08);
color: #e2e8f0;
}
:host-context([data-theme="light"]) .view-toggle:hover {
background: rgba(0,0,0,0.05);
color: #1e293b;
}
.view-toggle.active {
color: #22d3ee;
background: rgba(34,211,238,0.1);
}
/* ── Stack view ── */
.stack-view {
padding: 12px;
overflow: auto;
max-height: 50vh;
transition: max-height 0.3s ease;
}
:host-context([data-theme="dark"]) .stack-view {
background: rgba(15,23,42,0.5);
border-bottom: 1px solid rgba(255,255,255,0.06);
}
:host-context([data-theme="light"]) .stack-view {
background: rgba(248,250,252,0.8);
border-bottom: 1px solid rgba(0,0,0,0.06);
}
.stack-view svg {
display: block;
margin: 0 auto;
}
.stack-layer { cursor: pointer; }
.stack-layer:hover rect { stroke-width: 2.5; }
.stack-layer--active rect { stroke-dasharray: none; }
/* Drag-to-connect visual states */
.stack-layer.flow-drag-source rect { stroke-dasharray: 4 2; stroke-width: 2.5; }
.stack-layer.flow-drag-target rect { stroke-width: 3; filter: brightness(1.3); }
.stack-flow { cursor: pointer; }
.stack-flow:hover path { stroke-width: 4 !important; opacity: 1 !important; }
/* ── Flow creation dialog ── */
.flow-dialog {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 280px;
border-radius: 12px;
padding: 16px;
z-index: 50;
box-shadow: 0 12px 40px rgba(0,0,0,0.4);
}
:host-context([data-theme="dark"]) .flow-dialog {
background: #1e293b;
border: 1px solid rgba(255,255,255,0.1);
color: #e2e8f0;
}
:host-context([data-theme="light"]) .flow-dialog {
background: white;
border: 1px solid rgba(0,0,0,0.1);
color: #1e293b;
}
.flow-dialog-header {
font-size: 0.85rem;
font-weight: 700;
margin-bottom: 10px;
}
.flow-dialog-route {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
justify-content: center;
}
.flow-dialog-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 3px 8px;
border-radius: 5px;
font-size: 0.6rem;
font-weight: 900;
color: #0f172a;
}
.flow-dialog-arrow {
font-size: 1rem;
opacity: 0.4;
}
.flow-dialog-field {
margin-bottom: 10px;
}
.flow-dialog-label {
display: block;
font-size: 0.6rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.5;
margin-bottom: 4px;
}
.flow-kind-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
}
.flow-kind-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 8px;
border: 1px solid transparent;
border-radius: 5px;
background: transparent;
color: inherit;
font-size: 0.7rem;
cursor: pointer;
transition: background 0.12s, border-color 0.12s;
}
.flow-kind-btn:hover { background: rgba(255,255,255,0.05); }
.flow-kind-btn.selected {
border-color: var(--kind-color);
background: color-mix(in srgb, var(--kind-color) 10%, transparent);
}
.flow-kind-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.flow-dialog-input {
width: 100%;
padding: 6px 8px;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 5px;
background: rgba(255,255,255,0.04);
color: inherit;
font-size: 0.75rem;
outline: none;
}
.flow-dialog-input:focus { border-color: rgba(34,211,238,0.4); }
:host-context([data-theme="light"]) .flow-dialog-input {
border-color: rgba(0,0,0,0.1);
background: rgba(0,0,0,0.02);
}
.flow-dialog-range {
width: 100%;
accent-color: #22d3ee;
}
.flow-dialog-actions {
display: flex;
gap: 6px;
justify-content: flex-end;
margin-top: 4px;
}
.flow-dialog-cancel, .flow-dialog-create {
padding: 5px 14px;
border-radius: 6px;
font-size: 0.7rem;
font-weight: 600;
cursor: pointer;
}
.flow-dialog-cancel {
border: 1px solid rgba(255,255,255,0.1);
background: transparent;
color: inherit;
opacity: 0.6;
}
.flow-dialog-cancel:hover { opacity: 1; }
.flow-dialog-create {
border: none;
background: #22d3ee;
color: #0f172a;
}
.flow-dialog-create:hover { opacity: 0.85; }
/* ── Responsive ── */
@media (max-width: 640px) {
.tab-label { display: none; }
.tab { padding: 4px 8px; }
.stack-view { max-height: 40vh; }
.flow-dialog { width: 240px; }
}
`;