a5effba553
Backend
- snapshot: GET /api/v1/snapshot aggregates targets, devices, sources,
presets and system into one payload for the HA coordinator, collapsing
the prior ~2N+M request fan-out; per-section ?include= gating.
- graph: GET /api/v1/graph{,/schema,/dependents} backed by a pure,
unit-tested graph_schema engine — one authoritative connectable-field
registry so the editor no longer hard-codes topology in two places.
- devices: thread mqtt_source_id through DeviceCreate/Update/Response and
the routes for multi-broker MQTT; shared validate_mqtt_source_exists
(_mqtt_validation.py) reused by device + output-target routes; stop
update_device masking intentional 4xx as 500.
- shutdown: bound uvicorn graceful-shutdown via GRACEFUL_SHUTDOWN_TIMEOUT
(shared by __main__, android_entry, demo) so a lingering events WebSocket
can't strand LED targets or block process exit.
- access log: structured _access_log middleware attributing each request to
its authenticated token label (never the secret); uvicorn access_log off.
Frontend
- graph editor: generic schema-driven port/edge rendering, layout and
connection handling; service-worker refresh.
- device modals: MQTT broker EntitySelect for device_type=mqtt in add-device
and settings, wired into load/save/validate/dirty-check/clone.
- i18n: en/ru/zh keys.
Tests: graph routes + schema, snapshot routes, access log, mqtt_source_id
device regressions, bounded-shutdown entrypoint. 1614 passed.
1422 lines
29 KiB
CSS
1422 lines
29 KiB
CSS
/* ── Graph Editor ─────────────────────────────────────── */
|
|
|
|
/* Full viewport: hide footer & scroll-to-top when graph is active */
|
|
#tab-graph {
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* Hide the global scrollbar when graph tab is active */
|
|
html:has(#tab-graph.active) {
|
|
overflow: hidden;
|
|
}
|
|
|
|
#tab-graph #graph-editor-content {
|
|
padding: 0;
|
|
margin: 0;
|
|
}
|
|
|
|
.graph-container {
|
|
position: relative;
|
|
width: 100%;
|
|
height: calc(100vh - var(--header-height, 60px) - 20px);
|
|
overflow: hidden;
|
|
background: transparent;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.graph-toolbar {
|
|
position: absolute;
|
|
top: 12px;
|
|
left: 12px;
|
|
display: flex;
|
|
gap: 4px;
|
|
z-index: 20;
|
|
background: linear-gradient(180deg,
|
|
var(--lux-bg-1, var(--card-bg)) 0%,
|
|
var(--lux-bg-2, var(--card-bg)) 100%);
|
|
border: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color));
|
|
border-radius: var(--lux-r-md, 6px);
|
|
padding: 4px;
|
|
box-shadow: var(--lux-shadow-rack, 0 2px 8px var(--shadow-color));
|
|
backdrop-filter: blur(6px);
|
|
-webkit-backdrop-filter: blur(6px);
|
|
}
|
|
|
|
.graph-toolbar-drag {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 20px;
|
|
cursor: grab;
|
|
color: var(--text-muted);
|
|
font-size: 0.85rem;
|
|
user-select: none;
|
|
border-right: 1px solid var(--border-color);
|
|
margin-right: 2px;
|
|
padding-right: 2px;
|
|
letter-spacing: -1px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.graph-toolbar-drag:active,
|
|
.graph-toolbar-drag.dragging {
|
|
cursor: grabbing;
|
|
}
|
|
|
|
/* Vertical toolbar (docked to left/right side) */
|
|
.graph-toolbar.vertical {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.graph-toolbar.vertical .graph-toolbar-drag {
|
|
width: auto;
|
|
height: 16px;
|
|
border-right: none;
|
|
border-bottom: 1px solid var(--border-color);
|
|
margin-right: 0;
|
|
margin-bottom: 2px;
|
|
padding-right: 0;
|
|
padding-bottom: 2px;
|
|
}
|
|
|
|
.graph-toolbar.vertical .graph-toolbar-sep {
|
|
width: 100%;
|
|
height: 1px;
|
|
}
|
|
|
|
.graph-toolbar.vertical .graph-zoom-label {
|
|
text-align: center;
|
|
}
|
|
|
|
/* Dock position indicators during toolbar drag */
|
|
.graph-dock-indicators {
|
|
position: absolute;
|
|
inset: 0;
|
|
pointer-events: none;
|
|
z-index: 19;
|
|
}
|
|
|
|
.graph-dock-dot {
|
|
position: absolute;
|
|
width: 10px;
|
|
height: 10px;
|
|
margin-left: -5px;
|
|
margin-top: -5px;
|
|
border-radius: 50%;
|
|
background: var(--text-muted);
|
|
opacity: 0.25;
|
|
transition: opacity 0.15s, transform 0.15s, background 0.15s;
|
|
}
|
|
|
|
.graph-dock-dot.nearest {
|
|
opacity: 1;
|
|
background: var(--primary-color);
|
|
transform: scale(1.6);
|
|
box-shadow: 0 0 8px var(--primary-color);
|
|
}
|
|
|
|
.graph-toolbar .btn-icon {
|
|
width: 32px;
|
|
height: 32px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border: none;
|
|
background: transparent;
|
|
color: var(--text-color);
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
font-size: 1rem;
|
|
transition: background 0.15s;
|
|
}
|
|
|
|
.graph-toolbar .btn-icon:hover {
|
|
background: var(--bg-secondary);
|
|
}
|
|
|
|
.graph-toolbar .btn-icon.active {
|
|
background: var(--primary-color);
|
|
color: var(--primary-contrast);
|
|
}
|
|
|
|
.graph-toolbar .btn-icon:disabled {
|
|
opacity: 0.25;
|
|
cursor: default;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.graph-toolbar-sep {
|
|
width: 1px;
|
|
background: var(--border-color);
|
|
margin: 4px 2px;
|
|
}
|
|
|
|
/* ── Toolbar overflow button (hidden on wide screens) ── */
|
|
|
|
.graph-tb-overflow-btn {
|
|
display: none;
|
|
}
|
|
|
|
/* ── Toolbar overflow menu ── */
|
|
|
|
.graph-overflow-menu {
|
|
display: none;
|
|
position: absolute;
|
|
z-index: 25;
|
|
min-width: 170px;
|
|
background: var(--card-bg);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
padding: 4px;
|
|
box-shadow: 0 4px 16px var(--shadow-color);
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
}
|
|
|
|
.graph-overflow-menu.open {
|
|
display: flex;
|
|
}
|
|
|
|
.graph-overflow-menu.flip-up {
|
|
transform: translateY(-100%);
|
|
}
|
|
|
|
.graph-overflow-menu button {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
width: 100%;
|
|
padding: 6px 10px;
|
|
border: none;
|
|
background: transparent;
|
|
color: var(--text-color);
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
font-size: 0.85rem;
|
|
white-space: nowrap;
|
|
transition: background 0.15s;
|
|
}
|
|
|
|
.graph-overflow-menu button:hover {
|
|
background: var(--bg-secondary);
|
|
}
|
|
|
|
.graph-overflow-menu button.active {
|
|
background: var(--primary-color);
|
|
color: var(--primary-contrast);
|
|
}
|
|
|
|
.graph-overflow-menu button:disabled {
|
|
opacity: 0.3;
|
|
cursor: default;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.graph-overflow-menu .icon {
|
|
width: 16px;
|
|
height: 16px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.graph-overflow-sep {
|
|
height: 1px;
|
|
background: var(--border-color);
|
|
margin: 2px 4px;
|
|
}
|
|
|
|
/* ── Responsive: collapse toolbar on narrow viewports ── */
|
|
|
|
@media (max-width: 700px) {
|
|
.graph-toolbar [data-collapse] {
|
|
display: none;
|
|
}
|
|
.graph-tb-overflow-btn {
|
|
display: flex;
|
|
}
|
|
}
|
|
|
|
/* ── Legend panel ── */
|
|
|
|
.graph-legend {
|
|
position: absolute;
|
|
top: 12px;
|
|
z-index: 20;
|
|
background: var(--card-bg);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 8px var(--shadow-color);
|
|
font-size: 0.8rem;
|
|
display: none;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.graph-legend.visible {
|
|
display: block;
|
|
}
|
|
|
|
.graph-legend-header {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 4px 10px;
|
|
background: var(--bg-secondary);
|
|
border-bottom: 1px solid var(--border-color);
|
|
cursor: grab;
|
|
user-select: none;
|
|
}
|
|
|
|
.graph-legend-header.dragging {
|
|
cursor: grabbing;
|
|
}
|
|
|
|
.graph-legend-title {
|
|
font-weight: 600;
|
|
font-size: 0.7rem;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.graph-legend-body {
|
|
padding: 6px 12px;
|
|
max-height: 55vh;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.graph-legend-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 2px 0;
|
|
color: var(--text-secondary);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.graph-legend-dot {
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 3px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
/* ── Minimap ── */
|
|
|
|
.graph-minimap {
|
|
position: absolute;
|
|
z-index: 20;
|
|
background: var(--card-bg);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 8px var(--shadow-color);
|
|
overflow: hidden;
|
|
display: none;
|
|
min-width: 120px;
|
|
min-height: 80px;
|
|
}
|
|
|
|
.graph-minimap.visible {
|
|
display: block;
|
|
}
|
|
|
|
.graph-minimap svg {
|
|
width: 100%;
|
|
height: 100%;
|
|
display: block;
|
|
cursor: crosshair;
|
|
}
|
|
|
|
.graph-minimap-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 3px 6px;
|
|
cursor: grab;
|
|
background: var(--bg-secondary);
|
|
border-bottom: 1px solid var(--border-color);
|
|
font-size: 0.7rem;
|
|
color: var(--text-muted);
|
|
user-select: none;
|
|
}
|
|
|
|
.graph-minimap-header.dragging {
|
|
cursor: grabbing;
|
|
}
|
|
|
|
.graph-minimap-resize {
|
|
position: absolute;
|
|
bottom: 0;
|
|
width: 14px;
|
|
height: 14px;
|
|
z-index: 2;
|
|
}
|
|
|
|
.graph-minimap-resize::after {
|
|
content: '';
|
|
position: absolute;
|
|
bottom: 2px;
|
|
width: 8px;
|
|
height: 8px;
|
|
border-bottom: 2px solid var(--text-muted);
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.graph-minimap-resize-br {
|
|
right: 0;
|
|
cursor: se-resize;
|
|
}
|
|
|
|
.graph-minimap-resize-br::after {
|
|
right: 2px;
|
|
border-right: 2px solid var(--text-muted);
|
|
}
|
|
|
|
.graph-minimap-resize-bl {
|
|
left: 0;
|
|
cursor: sw-resize;
|
|
}
|
|
|
|
.graph-minimap-resize-bl::after {
|
|
left: 2px;
|
|
border-left: 2px solid var(--text-muted);
|
|
}
|
|
|
|
.graph-minimap-viewport {
|
|
fill: var(--primary-color);
|
|
fill-opacity: 0.08;
|
|
stroke: var(--primary-color);
|
|
stroke-width: 2;
|
|
stroke-opacity: 0.7;
|
|
vector-effect: non-scaling-stroke;
|
|
}
|
|
|
|
.graph-minimap-node {
|
|
rx: 2;
|
|
ry: 2;
|
|
}
|
|
|
|
/* ── SVG canvas ── */
|
|
|
|
.graph-svg {
|
|
width: 100%;
|
|
height: 100%;
|
|
cursor: grab;
|
|
}
|
|
|
|
.graph-svg.panning {
|
|
cursor: grabbing;
|
|
}
|
|
|
|
.graph-svg.connecting {
|
|
cursor: crosshair;
|
|
}
|
|
|
|
/* ── Grid background ── */
|
|
|
|
.graph-grid-dot {
|
|
fill: var(--lux-line, var(--border-color));
|
|
opacity: 0.32;
|
|
}
|
|
|
|
/* ── Node styles ── */
|
|
|
|
.graph-node {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.graph-node.dragging {
|
|
cursor: grabbing;
|
|
opacity: 0.85;
|
|
}
|
|
|
|
.graph-node.dragging .graph-node-overlay {
|
|
display: none !important;
|
|
}
|
|
|
|
.graph-node-body {
|
|
fill: var(--lux-bg-1, var(--card-bg));
|
|
stroke: var(--lux-line, var(--border-color));
|
|
stroke-width: 1;
|
|
rx: 6;
|
|
ry: 6;
|
|
transition: stroke 0.15s, stroke-width 0.15s, filter 0.2s ease;
|
|
}
|
|
|
|
.graph-node:hover .graph-node-body {
|
|
stroke: var(--lux-line-bold, var(--text-secondary));
|
|
stroke-width: 1;
|
|
filter: drop-shadow(0 4px 14px rgba(0, 0, 0, 0.25));
|
|
}
|
|
|
|
.graph-node.selected .graph-node-body {
|
|
stroke: var(--ch-signal, var(--primary-color));
|
|
stroke-width: 2;
|
|
filter: drop-shadow(0 0 8px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 50%, transparent));
|
|
}
|
|
|
|
.graph-node-color-bar {
|
|
rx: 8;
|
|
ry: 8;
|
|
}
|
|
|
|
/* Clip the right side of color bar to be square */
|
|
.graph-node-color-bar-clip rect {
|
|
rx: 8;
|
|
ry: 8;
|
|
}
|
|
|
|
.graph-node-title {
|
|
fill: var(--lux-ink, var(--text-color));
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
/* Body font, not display — Big Shoulders is condensed and reads as
|
|
* "stretched" at 12 px in a node label. Display font is for hero
|
|
* headers only. */
|
|
font-family: var(--font-body, 'Manrope', 'DM Sans', sans-serif);
|
|
letter-spacing: 0;
|
|
}
|
|
|
|
.graph-node-subtitle {
|
|
fill: var(--lux-ink-dim, var(--text-secondary));
|
|
font-size: 9.5px;
|
|
font-weight: 600;
|
|
font-family: var(--font-mono, monospace);
|
|
letter-spacing: 0.04em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.graph-node-icon {
|
|
stroke: var(--lux-ink-mute, var(--text-muted));
|
|
fill: none;
|
|
stroke-width: 1.5;
|
|
stroke-linecap: round;
|
|
stroke-linejoin: round;
|
|
opacity: 0.55;
|
|
}
|
|
|
|
.graph-node.running .graph-node-icon {
|
|
stroke: var(--ch-signal, var(--primary-color));
|
|
opacity: 0.95;
|
|
}
|
|
|
|
/* Custom per-entity icon: the embedded SVG strokes with currentColor, so it is
|
|
tinted via `color` (default muted; the node's icon_color overrides inline). */
|
|
.graph-node-custom-icon {
|
|
color: var(--lux-ink-mute, var(--text-muted));
|
|
opacity: 0.9;
|
|
}
|
|
|
|
.graph-node.running .graph-node-custom-icon {
|
|
color: var(--ch-signal, var(--primary-color));
|
|
opacity: 1;
|
|
}
|
|
|
|
/* ── Running indicator (animated gradient border + signal-flow glow) ── */
|
|
|
|
.graph-node.running .graph-node-body {
|
|
stroke: url(#running-gradient);
|
|
stroke-width: 2;
|
|
filter: drop-shadow(0 0 12px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 50%, transparent));
|
|
}
|
|
|
|
@keyframes graph-running-rotate {
|
|
from { transform: rotate(0deg); }
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
.graph-node.running .graph-node-running-ring {
|
|
animation: graph-running-rotate 3s linear infinite;
|
|
transform-origin: center;
|
|
}
|
|
|
|
/* Running dot indicator */
|
|
.graph-node-running-dot {
|
|
r: 4;
|
|
fill: var(--success-color);
|
|
}
|
|
|
|
/* ── Ports ── */
|
|
|
|
.graph-port {
|
|
stroke: var(--bg-color);
|
|
stroke-width: 2;
|
|
opacity: 0.85;
|
|
transition: r 0.15s, opacity 0.15s;
|
|
pointer-events: all;
|
|
}
|
|
|
|
.graph-node:hover .graph-port,
|
|
.graph-node.selected .graph-port {
|
|
r: 5;
|
|
opacity: 1;
|
|
}
|
|
|
|
/* Larger touch targets for ports on touch devices */
|
|
@media (pointer: coarse) {
|
|
.graph-port { r: 6; }
|
|
.graph-node:hover .graph-port,
|
|
.graph-node.selected .graph-port { r: 7; }
|
|
}
|
|
|
|
/* Port labels — hidden by default, shown on node hover, positioned outside node */
|
|
.graph-port-label {
|
|
font-size: 9px;
|
|
font-weight: 700;
|
|
font-family: var(--font-mono, monospace);
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
fill: var(--lux-ink-dim, var(--text-color));
|
|
pointer-events: none;
|
|
opacity: 0;
|
|
transition: opacity 0.15s;
|
|
paint-order: stroke fill;
|
|
stroke: var(--lux-bg-0, var(--bg-color));
|
|
stroke-width: 3px;
|
|
stroke-linejoin: round;
|
|
}
|
|
|
|
.graph-node:hover .graph-port-label,
|
|
.graph-node.selected .graph-port-label {
|
|
opacity: 1;
|
|
}
|
|
|
|
/* Port output cursor: draggable */
|
|
.graph-port-out {
|
|
cursor: crosshair;
|
|
}
|
|
|
|
/* Port interaction states during connection drag */
|
|
.graph-port-compatible {
|
|
r: 6 !important;
|
|
opacity: 1 !important;
|
|
cursor: pointer;
|
|
filter: drop-shadow(0 0 3px currentColor);
|
|
}
|
|
|
|
.graph-port-incompatible {
|
|
opacity: 0.15 !important;
|
|
}
|
|
|
|
.graph-port-drop-target {
|
|
r: 7 !important;
|
|
stroke: var(--ch-signal, var(--primary-color)) !important;
|
|
stroke-width: 3 !important;
|
|
filter: drop-shadow(0 0 6px var(--ch-signal, var(--primary-color)));
|
|
}
|
|
|
|
/* Whole-node drop targets: a source can be dropped on any compatible node to
|
|
wire one of its slots — including empty slots that have no input port yet. */
|
|
.graph-svg.connecting .graph-node-compatible .graph-node-body {
|
|
stroke: var(--ch-signal, var(--primary-color));
|
|
stroke-dasharray: 4 3;
|
|
stroke-width: 1.5;
|
|
}
|
|
|
|
.graph-node-drop-target .graph-node-body {
|
|
stroke: var(--ch-signal, var(--primary-color)) !important;
|
|
stroke-width: 2.5 !important;
|
|
stroke-dasharray: none !important;
|
|
filter: drop-shadow(0 0 8px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 55%, transparent));
|
|
}
|
|
|
|
/* ── Edges ── */
|
|
|
|
.graph-edge {
|
|
fill: none;
|
|
stroke-width: 2;
|
|
opacity: 0.6;
|
|
cursor: pointer;
|
|
transition: opacity 0.15s, stroke-width 0.15s;
|
|
}
|
|
|
|
.graph-edge:hover {
|
|
opacity: 1;
|
|
stroke-width: 3;
|
|
}
|
|
|
|
/* Wider invisible hit area for thin edges */
|
|
.graph-edge {
|
|
stroke-linecap: round;
|
|
}
|
|
|
|
.graph-edge-arrow {
|
|
fill: currentColor;
|
|
opacity: 0.6;
|
|
}
|
|
|
|
.graph-edge.highlighted {
|
|
opacity: 1;
|
|
stroke-width: 2.5;
|
|
}
|
|
|
|
.graph-edge.dimmed,
|
|
.graph-edge.dimmed.graph-edge-active {
|
|
opacity: 0.12;
|
|
filter: none;
|
|
}
|
|
|
|
/* Nested edges (composite layers, zones) — not drag-editable */
|
|
.graph-edge-nested {
|
|
stroke-dasharray: 2 2;
|
|
opacity: 0.4;
|
|
}
|
|
|
|
/* Edge field labels — hidden until zoomed in enough to read them. */
|
|
.graph-edge-label {
|
|
font-size: 9px;
|
|
font-weight: 600;
|
|
font-family: var(--font-mono, monospace);
|
|
fill: var(--text-secondary);
|
|
paint-order: stroke;
|
|
stroke: var(--lux-bg-1, var(--card-bg));
|
|
stroke-width: 3px;
|
|
stroke-linejoin: round;
|
|
pointer-events: none;
|
|
opacity: 0;
|
|
transition: opacity 0.15s ease;
|
|
}
|
|
|
|
.graph-edges.show-labels .graph-edge-label {
|
|
opacity: 0.85;
|
|
}
|
|
|
|
/* Edge type colors */
|
|
.graph-edge-picture { stroke: #42A5F5; color: #42A5F5; }
|
|
.graph-edge-colorstrip { stroke: #66BB6A; color: #66BB6A; }
|
|
.graph-edge-value { stroke: #FFA726; color: #FFA726; }
|
|
.graph-edge-device { stroke: #78909C; color: #78909C; }
|
|
.graph-edge-clock { stroke: #26C6DA; color: #26C6DA; stroke-dasharray: 6 3; }
|
|
.graph-edge-audio { stroke: #EF5350; color: #EF5350; }
|
|
.graph-edge-template { stroke: #AB47BC; color: #AB47BC; stroke-dasharray: 4 2; }
|
|
.graph-edge-scene { stroke: #CE93D8; color: #CE93D8; }
|
|
.graph-edge-default { stroke: var(--text-muted); color: var(--text-muted); }
|
|
|
|
/* ── Active edge flow (hybrid: thicker + saturated + animated dash) ── */
|
|
|
|
.graph-edge.graph-edge-active {
|
|
opacity: 1;
|
|
stroke-width: 2.5;
|
|
filter: drop-shadow(0 0 3px currentColor);
|
|
}
|
|
|
|
/* Clear dash patterns on active edges so glow looks clean */
|
|
.graph-edge-clock.graph-edge-active,
|
|
.graph-edge-template.graph-edge-active { stroke-dasharray: none; }
|
|
.graph-edge-nested.graph-edge-active { stroke-dasharray: none; }
|
|
|
|
/* Flow dots on active edges */
|
|
.graph-edge-flow { pointer-events: none; }
|
|
.graph-edge-flow circle { filter: drop-shadow(0 0 2px currentColor); }
|
|
|
|
/* ── Drag connection preview ── */
|
|
|
|
.graph-drag-edge {
|
|
fill: none;
|
|
stroke: var(--primary-color);
|
|
stroke-width: 2;
|
|
stroke-dasharray: 6 3;
|
|
opacity: 0.7;
|
|
pointer-events: none;
|
|
}
|
|
|
|
/* ── Edge context menu ── */
|
|
|
|
.graph-edge-menu {
|
|
position: absolute;
|
|
z-index: 40;
|
|
background: var(--card-bg);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
box-shadow: 0 4px 16px var(--shadow-color);
|
|
padding: 4px;
|
|
min-width: 120px;
|
|
}
|
|
|
|
.graph-edge-menu-item {
|
|
display: block;
|
|
width: 100%;
|
|
padding: 8px 12px;
|
|
border: none;
|
|
background: transparent;
|
|
color: var(--text-color);
|
|
font-size: 0.85rem;
|
|
font-family: inherit;
|
|
text-align: left;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
transition: background 0.1s;
|
|
}
|
|
|
|
.graph-edge-menu-item:hover {
|
|
background: var(--bg-secondary);
|
|
}
|
|
|
|
.graph-edge-menu-item.danger {
|
|
color: var(--danger-color);
|
|
}
|
|
|
|
.graph-edge-menu-item.danger:hover {
|
|
background: var(--danger-color);
|
|
color: var(--primary-contrast);
|
|
}
|
|
|
|
/* ── Hover overlay (action buttons) ── */
|
|
|
|
.graph-node-overlay {
|
|
display: none;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.graph-node:hover .graph-node-overlay,
|
|
.graph-node.selected .graph-node-overlay {
|
|
display: block;
|
|
}
|
|
|
|
.graph-node-overlay-bg {
|
|
fill: var(--lux-bg-1, var(--card-bg));
|
|
stroke: var(--border-color);
|
|
stroke-width: 1;
|
|
rx: 6;
|
|
ry: 6;
|
|
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));
|
|
}
|
|
|
|
.graph-node-overlay-btn {
|
|
cursor: pointer;
|
|
pointer-events: all;
|
|
}
|
|
|
|
.graph-node-overlay-btn rect {
|
|
fill: transparent;
|
|
rx: 4;
|
|
ry: 4;
|
|
}
|
|
|
|
.graph-node-overlay-btn:hover rect {
|
|
fill: var(--bg-secondary);
|
|
}
|
|
|
|
.graph-node-overlay-btn text {
|
|
fill: var(--text-color);
|
|
font-size: 14px;
|
|
text-anchor: middle;
|
|
dominant-baseline: central;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.graph-node-overlay-btn.danger:hover rect {
|
|
fill: var(--danger-color);
|
|
}
|
|
|
|
.graph-node-overlay-btn.danger:hover text {
|
|
fill: var(--primary-contrast);
|
|
}
|
|
|
|
.graph-node-overlay-btn.success:hover rect {
|
|
fill: var(--success-color);
|
|
}
|
|
|
|
.graph-node-overlay-btn.success:hover text {
|
|
fill: var(--primary-contrast);
|
|
}
|
|
|
|
/* ── Selection rectangle ── */
|
|
|
|
.graph-selection-rect {
|
|
fill: var(--primary-color);
|
|
fill-opacity: 0.08;
|
|
stroke: var(--primary-color);
|
|
stroke-width: 1;
|
|
stroke-dasharray: 4 2;
|
|
}
|
|
|
|
/* ── Orphan warning ── */
|
|
|
|
.graph-node.orphan .graph-node-body {
|
|
stroke: var(--warning-color);
|
|
stroke-width: 1;
|
|
stroke-dasharray: 4 3;
|
|
}
|
|
|
|
/* ── Health overlay: configuration issues (broken refs / cycles) ── */
|
|
.graph-node.has-issue .graph-node-body {
|
|
stroke: var(--danger-color);
|
|
stroke-width: 2;
|
|
stroke-dasharray: 5 3;
|
|
}
|
|
|
|
.graph-node-issue {
|
|
color: var(--danger-color);
|
|
}
|
|
|
|
/* ── Search highlight ── */
|
|
|
|
.graph-node.search-match .graph-node-body {
|
|
stroke: var(--info-color);
|
|
stroke-width: 2.5;
|
|
}
|
|
|
|
/* ── Zoom label (inline in toolbar) ── */
|
|
|
|
.graph-zoom-label {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-width: 40px;
|
|
font-size: 0.7rem;
|
|
color: var(--text-muted);
|
|
user-select: none;
|
|
padding: 0 2px;
|
|
}
|
|
|
|
/* ── Empty state ── */
|
|
|
|
.graph-empty {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
text-align: center;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.graph-empty h3 {
|
|
font-size: 1.2rem;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.graph-empty p {
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
/* ── Graph command palette ── */
|
|
|
|
.graph-search {
|
|
position: absolute;
|
|
top: 60px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
z-index: 30;
|
|
width: 360px;
|
|
background: var(--card-bg);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 10px;
|
|
box-shadow: 0 8px 24px var(--shadow-color);
|
|
display: none;
|
|
}
|
|
|
|
.graph-search.visible {
|
|
display: block;
|
|
}
|
|
|
|
.graph-search-input {
|
|
width: 100%;
|
|
padding: 10px 14px;
|
|
border: none;
|
|
border-bottom: 1px solid var(--border-color);
|
|
background: transparent;
|
|
color: var(--text-color);
|
|
font-size: 0.9rem;
|
|
font-family: inherit;
|
|
outline: none;
|
|
border-radius: 10px 10px 0 0;
|
|
}
|
|
|
|
.graph-search-results {
|
|
max-height: 280px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.graph-search-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 8px 14px;
|
|
cursor: pointer;
|
|
transition: background 0.1s;
|
|
}
|
|
|
|
.graph-search-item:hover,
|
|
.graph-search-item.active {
|
|
background: var(--bg-secondary);
|
|
}
|
|
|
|
.graph-search-item-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 2px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.graph-search-item-name {
|
|
font-size: 0.85rem;
|
|
color: var(--text-color);
|
|
flex: 1;
|
|
overflow: hidden;
|
|
white-space: nowrap;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.graph-search-item-type {
|
|
font-size: 0.75rem;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
/* ── Filter bar ── */
|
|
|
|
.graph-filter {
|
|
position: absolute;
|
|
top: 12px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
display: none;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
background: var(--bg-color);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
padding: 8px 10px;
|
|
z-index: 30;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
|
min-width: 260px;
|
|
max-width: 480px;
|
|
}
|
|
|
|
.graph-filter.visible {
|
|
display: flex;
|
|
}
|
|
|
|
.graph-filter-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
width: 100%;
|
|
}
|
|
|
|
.graph-filter-actions {
|
|
gap: 4px;
|
|
}
|
|
|
|
.graph-filter-pill {
|
|
background: transparent;
|
|
border: 1px solid var(--pill-color, var(--border-color));
|
|
color: var(--pill-color, var(--text-muted));
|
|
border-radius: 12px;
|
|
padding: 2px 8px;
|
|
font-size: 0.7rem;
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
transition: background 0.15s, color 0.15s;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.graph-filter-pill:hover {
|
|
background: color-mix(in srgb, var(--pill-color, var(--border-color)) 15%, transparent);
|
|
}
|
|
|
|
.graph-filter-pill.active {
|
|
background: var(--pill-color, var(--primary-color));
|
|
color: #fff;
|
|
border-color: var(--pill-color, var(--primary-color));
|
|
}
|
|
|
|
/* ── Types button + popover ── */
|
|
|
|
.graph-filter-types-btn {
|
|
background: transparent;
|
|
border: 1px solid var(--border-color);
|
|
color: var(--text-muted);
|
|
border-radius: 12px;
|
|
padding: 2px 10px;
|
|
font-size: 0.7rem;
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
|
line-height: 1.4;
|
|
margin-right: auto;
|
|
}
|
|
|
|
.graph-filter-types-btn:hover {
|
|
border-color: var(--primary-color);
|
|
color: var(--text-color);
|
|
}
|
|
|
|
.graph-filter-types-badge {
|
|
display: none;
|
|
background: var(--primary-color);
|
|
color: #fff;
|
|
border-radius: 8px;
|
|
font-size: 0.6rem;
|
|
font-weight: 700;
|
|
min-width: 14px;
|
|
height: 14px;
|
|
line-height: 14px;
|
|
text-align: center;
|
|
padding: 0 3px;
|
|
}
|
|
|
|
.graph-filter-types-badge.visible {
|
|
display: inline-block;
|
|
}
|
|
|
|
/* Issues toolbar button + count badge */
|
|
.graph-issues-btn {
|
|
position: relative;
|
|
color: var(--danger-color);
|
|
}
|
|
|
|
.graph-issues-count {
|
|
position: absolute;
|
|
top: 0;
|
|
right: 0;
|
|
transform: translate(35%, -35%);
|
|
background: var(--danger-color);
|
|
color: #fff;
|
|
border-radius: 8px;
|
|
font-size: 0.6rem;
|
|
font-weight: 700;
|
|
min-width: 14px;
|
|
height: 14px;
|
|
line-height: 14px;
|
|
text-align: center;
|
|
padding: 0 3px;
|
|
}
|
|
|
|
.graph-issues-count:empty {
|
|
display: none;
|
|
}
|
|
|
|
.graph-filter-types-popover {
|
|
display: none;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
background: var(--bg-color);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
padding: 8px;
|
|
max-height: 320px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.graph-filter-types-popover.visible {
|
|
display: flex;
|
|
}
|
|
|
|
.graph-filter-type-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1px;
|
|
}
|
|
|
|
.graph-filter-type-group-header {
|
|
font-size: 0.65rem;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
color: var(--text-muted);
|
|
padding: 2px 4px;
|
|
cursor: pointer;
|
|
border-radius: 4px;
|
|
user-select: none;
|
|
}
|
|
|
|
.graph-filter-type-group-header:hover {
|
|
background: var(--hover-bg, rgba(255,255,255,0.05));
|
|
color: var(--text-color);
|
|
}
|
|
|
|
.graph-filter-type-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 3px 4px 3px 8px;
|
|
font-size: 0.75rem;
|
|
color: var(--text-color);
|
|
cursor: pointer;
|
|
border-radius: 4px;
|
|
user-select: none;
|
|
}
|
|
|
|
.graph-filter-type-item:hover {
|
|
background: var(--hover-bg, rgba(255,255,255,0.05));
|
|
}
|
|
|
|
.graph-filter-type-item input[type="checkbox"] {
|
|
width: 14px;
|
|
height: 14px;
|
|
accent-color: var(--primary-color);
|
|
cursor: pointer;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.graph-filter-type-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.graph-filter-icon {
|
|
flex-shrink: 0;
|
|
stroke: var(--text-muted);
|
|
fill: none;
|
|
stroke-width: 2;
|
|
stroke-linecap: round;
|
|
stroke-linejoin: round;
|
|
}
|
|
|
|
.graph-filter-input {
|
|
flex: 1;
|
|
border: none;
|
|
background: transparent;
|
|
color: var(--text-color);
|
|
font-size: 0.85rem;
|
|
outline: none;
|
|
min-width: 0;
|
|
}
|
|
|
|
.graph-filter-clear {
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-muted);
|
|
font-size: 1.1rem;
|
|
cursor: pointer;
|
|
padding: 0 2px;
|
|
line-height: 1;
|
|
}
|
|
|
|
.graph-filter-clear:hover {
|
|
color: var(--text-color);
|
|
}
|
|
|
|
.graph-filter-btn.active {
|
|
color: var(--primary-color);
|
|
}
|
|
|
|
/* ── Filtered-out state ── */
|
|
|
|
.graph-node.graph-filtered-out {
|
|
opacity: 0.12;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.graph-edge.graph-filtered-out {
|
|
opacity: 0.06;
|
|
}
|
|
|
|
/* ── Loading overlay for relayout ── */
|
|
|
|
/* ── Add entity menu ── */
|
|
|
|
.graph-add-entity-menu {
|
|
position: absolute;
|
|
z-index: 30;
|
|
background: var(--card-bg);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 10px;
|
|
box-shadow: 0 8px 24px var(--shadow-color);
|
|
padding: 6px;
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 2px;
|
|
min-width: 280px;
|
|
}
|
|
|
|
.graph-add-entity-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 8px 10px;
|
|
border: none;
|
|
background: transparent;
|
|
color: var(--text-color);
|
|
font-size: 0.8rem;
|
|
font-family: inherit;
|
|
text-align: left;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
transition: background 0.1s;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.graph-add-entity-item:hover {
|
|
background: var(--bg-secondary);
|
|
}
|
|
|
|
.graph-add-entity-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 2px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.graph-add-entity-icon {
|
|
font-size: 1rem;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
/* ── Fullscreen mode ── */
|
|
|
|
.graph-container:fullscreen {
|
|
background: var(--bg-color);
|
|
height: 100vh;
|
|
}
|
|
|
|
.graph-container:fullscreen #bg-anim-canvas {
|
|
position: absolute;
|
|
inset: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
z-index: 0;
|
|
}
|
|
|
|
.graph-container:fullscreen .graph-svg {
|
|
position: relative;
|
|
z-index: 1;
|
|
}
|
|
|
|
/* ── Loading overlay for relayout ── */
|
|
|
|
.graph-loading-overlay {
|
|
position: absolute;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, 0.3);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 50;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
/* ── Keyboard shortcuts help panel ── */
|
|
|
|
.graph-help-panel {
|
|
position: absolute;
|
|
z-index: 30;
|
|
background: var(--bg-color);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 12px rgba(0,0,0,0.2);
|
|
width: 300px;
|
|
display: none;
|
|
flex-direction: column;
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.graph-help-panel.visible {
|
|
display: flex;
|
|
}
|
|
|
|
.graph-help-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 8px 12px;
|
|
font-weight: 600;
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
|
|
.graph-help-body {
|
|
padding: 8px 12px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
max-height: 360px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.graph-help-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.graph-help-row kbd,
|
|
.graph-help-row .graph-help-mouse {
|
|
min-width: 80px;
|
|
text-align: right;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.graph-help-row kbd {
|
|
background: var(--card-bg);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 4px;
|
|
padding: 1px 5px;
|
|
font-size: 0.7rem;
|
|
font-family: var(--font-mono, monospace);
|
|
}
|
|
|
|
.graph-help-row .graph-help-mouse {
|
|
color: var(--text-muted);
|
|
font-size: 0.7rem;
|
|
}
|
|
|
|
.graph-help-row span:last-child {
|
|
color: var(--text-color);
|
|
}
|
|
|
|
.graph-help-sep {
|
|
height: 1px;
|
|
background: var(--border-color);
|
|
margin: 4px 0;
|
|
}
|
|
|
|
/* ── Node hover FPS tooltip ── */
|
|
|
|
.graph-node-tooltip {
|
|
position: absolute;
|
|
z-index: 50;
|
|
background: var(--card-bg);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--radius-md, 6px);
|
|
box-shadow: 0 4px 14px var(--shadow-color, rgba(0,0,0,0.25));
|
|
padding: 8px 12px;
|
|
pointer-events: none;
|
|
font-size: 0.8rem;
|
|
width: 200px;
|
|
color: var(--text-color);
|
|
}
|
|
|
|
.graph-node-tooltip .gnt-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 12px;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.graph-node-tooltip .gnt-label {
|
|
color: var(--text-muted);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.graph-node-tooltip .gnt-value {
|
|
font-variant-numeric: tabular-nums;
|
|
font-weight: 500;
|
|
text-align: right;
|
|
min-width: 72px;
|
|
display: inline-block;
|
|
font-family: var(--font-mono, 'Consolas', 'Monaco', monospace);
|
|
}
|
|
|
|
.graph-node-tooltip .gnt-fps-row {
|
|
margin-top: 4px;
|
|
padding: 2px 0;
|
|
background: transparent;
|
|
}
|
|
|
|
.graph-node-tooltip.gnt-fade-in {
|
|
animation: gntFadeIn 0.15s ease-out forwards;
|
|
}
|
|
.graph-node-tooltip.gnt-fade-out {
|
|
animation: gntFadeOut 0.12s ease-in forwards;
|
|
}
|
|
@keyframes gntFadeIn {
|
|
from { opacity: 0; transform: translateY(-4px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
@keyframes gntFadeOut {
|
|
from { opacity: 1; transform: translateY(0); }
|
|
to { opacity: 0; transform: translateY(4px); }
|
|
}
|