From e4783c31f2141e81b8834944547133d9e120d52a Mon Sep 17 00:00:00 2001 From: Ilya Ashikhmin Date: Thu, 2 Jul 2026 23:24:32 +0200 Subject: [PATCH] feat(ui): React shell with stencil, property panel, toolbar and settings Three-pane editor: searchable grouped symbol stencil with drag-drop, typed/grouped property panel (string/int/float/enum/color/catalogue/list editors), toolbar (file, undo/redo, edit, zoom, run-flow), settings dialog (grid, snap, jumpover, color scheme, theme) and flow legend. Wires global keyboard shortcuts and seeds a demo network. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/main/index.ts | 2 +- src/renderer/App.tsx | 147 +++++++++ src/renderer/components/CanvasView.tsx | 42 +++ src/renderer/components/FlowLegend.tsx | 20 ++ src/renderer/components/NodePanel.tsx | 67 +++++ src/renderer/components/PropertyPanel.tsx | 246 +++++++++++++++ src/renderer/components/SettingsDialog.tsx | 81 +++++ src/renderer/components/Toolbar.tsx | 53 ++++ src/renderer/diagram/controller.ts | 47 ++- src/renderer/global.d.ts | 9 + src/renderer/index.html | 13 + src/renderer/main.tsx | 10 + src/renderer/styles/app.css | 335 +++++++++++++++++++++ 13 files changed, 1065 insertions(+), 7 deletions(-) create mode 100644 src/renderer/App.tsx create mode 100644 src/renderer/components/CanvasView.tsx create mode 100644 src/renderer/components/FlowLegend.tsx create mode 100644 src/renderer/components/NodePanel.tsx create mode 100644 src/renderer/components/PropertyPanel.tsx create mode 100644 src/renderer/components/SettingsDialog.tsx create mode 100644 src/renderer/components/Toolbar.tsx create mode 100644 src/renderer/global.d.ts create mode 100644 src/renderer/index.html create mode 100644 src/renderer/main.tsx create mode 100644 src/renderer/styles/app.css diff --git a/src/main/index.ts b/src/main/index.ts index f1ef91d..b750e53 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -14,7 +14,7 @@ function createWindow(): void { title: 'Pipeline Diagram Editor', backgroundColor: '#1e1f26', webPreferences: { - preload: join(__dirname, '../preload/index.js'), + preload: join(__dirname, '../preload/index.mjs'), contextIsolation: true, nodeIntegration: false, sandbox: false diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx new file mode 100644 index 0000000..6482970 --- /dev/null +++ b/src/renderer/App.tsx @@ -0,0 +1,147 @@ +import { useCallback, useEffect, useMemo, useReducer, useState } from 'react' +import { DiagramController } from '@diagram/controller' +import { cellValues, schemaForCell } from '@diagram/shapes' +import { getSymbol } from '@diagram/symbols' +import type { DiagramSettings } from '@diagram/settings' +import { NodePanel } from './components/NodePanel' +import { PropertyPanel, type PropertyTarget } from './components/PropertyPanel' +import { CanvasView } from './components/CanvasView' +import { Toolbar } from './components/Toolbar' +import { SettingsDialog } from './components/SettingsDialog' +import { FlowLegend } from './components/FlowLegend' + +export function App(): JSX.Element { + const [ctrl, setCtrl] = useState(null) + const [, force] = useReducer((x: number) => x + 1, 0) + const [settingsOpen, setSettingsOpen] = useState(false) + + // Subscribe to controller events → re-render. + useEffect(() => { + if (!ctrl) return + const offs = (['selection', 'change', 'settings', 'flow'] as const).map((e) => ctrl.on(e, force)) + return () => offs.forEach((off) => off()) + }, [ctrl]) + + // Global keyboard shortcuts. + useEffect(() => { + if (!ctrl) return + const handler = (e: KeyboardEvent): void => { + const t = e.target as HTMLElement + if (t && (t.tagName === 'INPUT' || t.tagName === 'SELECT' || t.tagName === 'TEXTAREA')) return + const mod = e.ctrlKey || e.metaKey + if (e.key === 'Delete' || e.key === 'Backspace') { + ctrl.deleteSelected() + e.preventDefault() + } else if (mod && e.key.toLowerCase() === 'z') { + e.shiftKey ? ctrl.redo() : ctrl.undo() + e.preventDefault() + } else if (mod && e.key.toLowerCase() === 'y') { + ctrl.redo() + e.preventDefault() + } else if (mod && e.key.toLowerCase() === 'c') { + ctrl.copySelected() + } else if (mod && e.key.toLowerCase() === 'v') { + ctrl.paste() + } else if (mod && e.key.toLowerCase() === 'd') { + ctrl.duplicateSelected() + e.preventDefault() + } else if (mod && e.key.toLowerCase() === 'a') { + ctrl.selectAll() + e.preventDefault() + } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [ctrl]) + + const selection = ctrl ? ctrl.getSelection() : [] + + const target: PropertyTarget | null = useMemo(() => { + if (!ctrl || selection.length !== 1) return null + const cell = selection[0] + const values = cellValues(cell) + const kind = cell.get('kind') === 'edge' ? 'edge' : 'node' + const sym = getSymbol(String(cell.get('symbolId'))) + const title = String(values.title || sym?.label || (kind === 'edge' ? 'Connector' : 'Node')) + return { id: cell.id as string, kind, title, schema: schemaForCell(cell), values } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ctrl, selection.map((c) => c.id).join(','), JSON.stringify(selection[0] ? cellValues(selection[0]) : {})]) + + const onAddCenter = useCallback( + (symbolId: string) => { + if (!ctrl) return + const box = ctrl.paper.el.getBoundingClientRect() + ctrl.addNodeAtClient(symbolId, box.left + box.width / 2, box.top + box.height / 2) + }, + [ctrl] + ) + + const onSave = useCallback(async () => { + if (!ctrl) return + await window.diagramFile.save(ctrl.toJSON()) + }, [ctrl]) + + const onOpen = useCallback(async () => { + if (!ctrl) return + const res = await window.diagramFile.open() + if (res.ok && res.json) ctrl.loadJSON(res.json) + }, [ctrl]) + + const onExport = useCallback(() => { + if (!ctrl) return + const svg = ctrl.paper.svg.cloneNode(true) as SVGSVGElement + const blob = new Blob([new XMLSerializer().serializeToString(svg)], { type: 'image/svg+xml' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = 'diagram.svg' + a.click() + URL.revokeObjectURL(url) + }, [ctrl]) + + const toggleFlow = useCallback(() => { + if (!ctrl) return + ctrl.isFlowing() ? ctrl.stopFlow() : ctrl.runFlow() + }, [ctrl]) + + return ( +
+ 0} + flowing={ctrl?.isFlowing() ?? false} + onNew={() => ctrl?.newDiagram()} + onOpen={onOpen} + onSave={onSave} + onExport={onExport} + onUndo={() => ctrl?.undo()} + onRedo={() => ctrl?.redo()} + onDelete={() => ctrl?.deleteSelected()} + onDuplicate={() => ctrl?.duplicateSelected()} + onZoomIn={() => ctrl?.zoom(1.2)} + onZoomOut={() => ctrl?.zoom(1 / 1.2)} + onFit={() => ctrl?.fitContent()} + onToggleFlow={toggleFlow} + onSettings={() => setSettingsOpen(true)} + /> + + + {ctrl && } + + ctrl?.applyValues(id, values)} + /> + + {settingsOpen && ctrl && ( + ) => ctrl.setSettings(patch)} + onClose={() => setSettingsOpen(false)} + /> + )} +
+ ) +} diff --git a/src/renderer/components/CanvasView.tsx b/src/renderer/components/CanvasView.tsx new file mode 100644 index 0000000..584068c --- /dev/null +++ b/src/renderer/components/CanvasView.tsx @@ -0,0 +1,42 @@ +import { useEffect, useRef, type ReactNode } from 'react' +import { DiagramController } from '@diagram/controller' + +interface Props { + onReady: (ctrl: DiagramController) => void + empty: boolean + children?: ReactNode +} + +export function CanvasView({ onReady, empty, children }: Props): JSX.Element { + const hostRef = useRef(null) + const ctrlRef = useRef(null) + + useEffect(() => { + if (!hostRef.current) return + const ctrl = new DiagramController(hostRef.current) + ctrlRef.current = ctrl + ctrl.seedSample() + onReady(ctrl) + return () => ctrl.dispose() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return ( +
{ + e.preventDefault() + e.dataTransfer.dropEffect = 'copy' + }} + onDrop={(e) => { + e.preventDefault() + const id = e.dataTransfer.getData('application/x-symbol-id') + if (id && ctrlRef.current) ctrlRef.current.addNodeAtClient(id, e.clientX, e.clientY) + }} + > +
+ {empty &&
Drag a symbol from the left panel to start
} + {children} +
+ ) +} diff --git a/src/renderer/components/FlowLegend.tsx b/src/renderer/components/FlowLegend.tsx new file mode 100644 index 0000000..10a15a1 --- /dev/null +++ b/src/renderer/components/FlowLegend.tsx @@ -0,0 +1,20 @@ +import type { FlowSolution } from '@diagram/flow' + +export function FlowLegend({ solution }: { solution: FlowSolution | null }): JSX.Element | null { + if (!solution) return null + const max = solution.maxMagnitude + return ( +
+
Flow magnitude (m³/h)
+
+
+ 0 + {(max / 2).toFixed(1)} + {max.toFixed(1)} +
+
+ residual {solution.residual.toFixed(2)} +
+
+ ) +} diff --git a/src/renderer/components/NodePanel.tsx b/src/renderer/components/NodePanel.tsx new file mode 100644 index 0000000..4e9e7db --- /dev/null +++ b/src/renderer/components/NodePanel.tsx @@ -0,0 +1,67 @@ +import { useMemo, useState } from 'react' +import { SYMBOLS, SYMBOL_CATEGORIES, symbolSvgMarkup, type SymbolDef } from '@diagram/symbols' + +interface Props { + /** Add the symbol at canvas center (double-click / keyboard). */ + onAdd: (symbolId: string) => void +} + +export function NodePanel({ onAdd }: Props): JSX.Element { + const [query, setQuery] = useState('') + const [collapsed, setCollapsed] = useState>({}) + + const grouped = useMemo(() => { + const q = query.trim().toLowerCase() + const match = (s: SymbolDef): boolean => + !q || s.label.toLowerCase().includes(q) || s.category.toLowerCase().includes(q) + return SYMBOL_CATEGORIES.map((cat) => ({ + cat, + items: SYMBOLS.filter((s) => s.category === cat && match(s)) + })).filter((g) => g.items.length > 0) + }, [query]) + + return ( + + ) +} diff --git a/src/renderer/components/PropertyPanel.tsx b/src/renderer/components/PropertyPanel.tsx new file mode 100644 index 0000000..ff767fb --- /dev/null +++ b/src/renderer/components/PropertyPanel.tsx @@ -0,0 +1,246 @@ +import { useEffect, useState } from 'react' +import type { PropertyDef, PropertySchema, PropertyValue } from '@diagram/properties' +import { coerce, validate } from '@diagram/properties' +import { getCatalogue, seedsFromItem } from '@diagram/catalogue' + +export interface PropertyTarget { + id: string + kind: 'node' | 'edge' + title: string + schema: PropertySchema + values: Record +} + +interface Props { + target: PropertyTarget | null + multi: number + onApply: (id: string, values: Record) => void +} + +export function PropertyPanel({ target, multi, onApply }: Props): JSX.Element { + const [draft, setDraft] = useState>({}) + + useEffect(() => { + setDraft(target ? { ...target.values } : {}) + }, [target?.id]) + + if (multi > 1) { + return ( + + ) + } + if (!target) { + return ( + + ) + } + + const errors = validate(target.schema, draft).errors + + const commit = (next: Record): void => { + setDraft(next) + onApply(target.id, next) + } + + const setField = (def: PropertyDef, raw: unknown): void => { + const next = { ...draft, [def.key]: coerce(def, raw) } + // Catalogue selection seeds physics defaults. + if (def.type === 'catalogueItem' && def.catalogue) { + const seeds = seedsFromItem(def.catalogue, String(raw)) + Object.assign(next, seeds) + } + commit(next) + } + + return ( + + ) +} + +function Field({ + def, + value, + error, + onChange +}: { + def: PropertyDef + value: PropertyValue + error?: string + onChange: (raw: unknown) => void +}): JSX.Element { + return ( + <> +
+ + + {error ?
{error}
: null} +
+ + ) +} + +function Editor({ + def, + value, + error, + onChange +}: { + def: PropertyDef + value: PropertyValue + error?: string + onChange: (raw: unknown) => void +}): JSX.Element { + const invalid = error ? 'invalid' : '' + switch (def.type) { + case 'int': + case 'float': + return ( + onChange(e.target.value)} + /> + ) + case 'color': + return ( + onChange(e.target.value)} /> + ) + case 'enum': + return ( + + ) + case 'catalogueItem': { + const cat = def.catalogue ? getCatalogue(def.catalogue) : undefined + return ( + + ) + } + case 'catalogueItems': + return + case 'listOfValues': + return + case 'string': + default: + return ( + onChange(e.target.value)} + /> + ) + } +} + +function CatalogueItemsEditor({ + def, + value, + onChange +}: { + def: PropertyDef + value: string[] + onChange: (raw: unknown) => void +}): JSX.Element { + const cat = def.catalogue ? getCatalogue(def.catalogue) : undefined + const remaining = cat?.items.filter((i) => !value.includes(i.id)) ?? [] + return ( +
+
+ {value.map((id) => { + const item = cat?.items.find((i) => i.id === id) + return ( + + {item?.label ?? id} + + + ) + })} +
+ {remaining.length > 0 && ( + + )} +
+ ) +} + +function ListEditor({ + def, + value, + onChange +}: { + def: PropertyDef + value: (string | number)[] + onChange: (raw: unknown) => void +}): JSX.Element { + const numeric = def.itemType && def.itemType !== 'string' + return ( +
+ {value.map((v, i) => ( +
+ { + const next = [...value] + next[i] = e.target.value + onChange(next) + }} + /> + +
+ ))} + +
+ ) +} diff --git a/src/renderer/components/SettingsDialog.tsx b/src/renderer/components/SettingsDialog.tsx new file mode 100644 index 0000000..a380ccc --- /dev/null +++ b/src/renderer/components/SettingsDialog.tsx @@ -0,0 +1,81 @@ +import type { DiagramSettings } from '@diagram/settings' +import { COLOR_SCHEMES } from '@diagram/relations' + +interface Props { + settings: DiagramSettings + onChange: (patch: Partial) => void + onClose: () => void +} + +export function SettingsDialog({ settings, onChange, onClose }: Props): JSX.Element { + return ( +
+
e.stopPropagation()}> +

Diagram settings

+ +
+ + onChange({ gridSize: Number(e.target.value) || 16 })} + /> +
+
+ + onChange({ snapToGrid: e.target.checked })} + /> +
+
+ + onChange({ showGrid: e.target.checked })} + /> +
+
+ + onChange({ jumpover: e.target.checked })} + /> +
+
+ + +
+
+ + +
+ +
+ +
+
+
+ ) +} diff --git a/src/renderer/components/Toolbar.tsx b/src/renderer/components/Toolbar.tsx new file mode 100644 index 0000000..88b6356 --- /dev/null +++ b/src/renderer/components/Toolbar.tsx @@ -0,0 +1,53 @@ +interface Props { + canUndo: boolean + canRedo: boolean + hasSelection: boolean + flowing: boolean + onNew: () => void + onOpen: () => void + onSave: () => void + onExport: () => void + onUndo: () => void + onRedo: () => void + onDelete: () => void + onDuplicate: () => void + onZoomIn: () => void + onZoomOut: () => void + onFit: () => void + onToggleFlow: () => void + onSettings: () => void +} + +export function Toolbar(p: Props): JSX.Element { + return ( +
+ ⬡ Pipeline Editor +
+ + + + +
+
+ + +
+
+ + +
+
+ + + +
+
+
+ + +
+
+ ) +} diff --git a/src/renderer/diagram/controller.ts b/src/renderer/diagram/controller.ts index 834f591..f2e5e75 100644 --- a/src/renderer/diagram/controller.ts +++ b/src/renderer/diagram/controller.ts @@ -87,10 +87,10 @@ export class DiagramController { private bindEvents(): void { this.paper.on('element:pointerclick', (view, evt) => { - this.select(view.model.id as string, evt.shiftKey || evt.ctrlKey || evt.metaKey) + this.select(view.model.id as string, Boolean(evt.shiftKey || evt.ctrlKey || evt.metaKey)) }) this.paper.on('link:pointerclick', (view, evt) => { - this.select(view.model.id as string, evt.shiftKey || evt.ctrlKey || evt.metaKey) + this.select(view.model.id as string, Boolean(evt.shiftKey || evt.ctrlKey || evt.metaKey)) }) this.paper.on('blank:pointerclick', () => this.clearSelection()) @@ -339,13 +339,18 @@ export class DiagramController { setSettings(patch: Partial): void { this.settings = { ...this.settings, ...patch } const s = this.settings + // Some grid runtime methods are undeclared in the shipped d.ts. + const paper = this.paper as unknown as { + drawGrid: (opt: unknown) => void + clearGrid: () => void + } this.paper.options.gridSize = s.gridSize this.paper.setGridSize(s.gridSize) - this.paper.drawGrid(s.showGrid ? { name: 'mesh', args: { color: '#2a2c36' } } : false) - if (!s.showGrid) this.paper.clearGrid() + if (s.showGrid) paper.drawGrid({ name: 'mesh', args: { color: '#2a2c36' } }) + else paper.clearGrid() this.paper.drawBackground({ color: s.theme === 'dark' ? '#181920' : '#f4f5f7' }) - this.paper.options.defaultRouter = routerConfig(s) as dia.Path.Segment[] | never - this.paper.options.defaultConnector = connectorConfig(s) as never + this.paper.options.defaultRouter = routerConfig(s) + this.paper.options.defaultConnector = connectorConfig(s) for (const link of this.graph.getLinks()) { link.router(routerConfig(s).name, routerConfig(s).args) link.connector(connectorConfig(s).name, connectorConfig(s).args) @@ -457,6 +462,36 @@ export class DiagramController { return defaultsFor(edgeSchema()) } + /** Build a small demo network so the canvas isn't empty on first launch. */ + seedSample(): void { + this.restoring = true + const s = this.settings.colorScheme + const add = (sym: string, x: number, y: number): dia.Element => { + const n = createNode(sym, x, y, s) + n.addTo(this.graph) + return n + } + const reservoir = add('reservoir', 64, 208) + const pump = add('pump', 240, 214) + const valve = add('valve', 416, 220) + const tee = add('tee', 576, 222) + const outletA = add('outlet', 736, 128) + const outletB = add('outlet', 736, 320) + const link = (a: dia.Element, ap: string, b: dia.Element, bp: string): void => { + createLink({ id: a.id as string, port: ap }, { id: b.id as string, port: bp }).addTo(this.graph) + } + link(reservoir, 'out', pump, 'in') + link(pump, 'out', valve, 'a') + link(valve, 'b', tee, 'a') + link(tee, 'b', outletA, 'in') + link(tee, 'c', outletB, 'in') + for (const n of this.graph.getElements()) this.setPortsVisible(n, false) + this.restoring = false + this.history.reset(JSON.stringify(this.graph.toJSON())) + this.setSettings({}) + this.fire('change') + } + dispose(): void { this.animator.stop() this.paper.remove() diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts new file mode 100644 index 0000000..9fbcdc5 --- /dev/null +++ b/src/renderer/global.d.ts @@ -0,0 +1,9 @@ +import type { DiagramFileApi } from '../preload' + +declare global { + interface Window { + diagramFile: DiagramFileApi + } +} + +export {} diff --git a/src/renderer/index.html b/src/renderer/index.html new file mode 100644 index 0000000..a418dff --- /dev/null +++ b/src/renderer/index.html @@ -0,0 +1,13 @@ + + + + + + + Pipeline Diagram Editor + + +
+ + + diff --git a/src/renderer/main.tsx b/src/renderer/main.tsx new file mode 100644 index 0000000..0d4c0ef --- /dev/null +++ b/src/renderer/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import { createRoot } from 'react-dom/client' +import { App } from './App' +import './styles/app.css' + +createRoot(document.getElementById('root')!).render( + + + +) diff --git a/src/renderer/styles/app.css b/src/renderer/styles/app.css new file mode 100644 index 0000000..3acde38 --- /dev/null +++ b/src/renderer/styles/app.css @@ -0,0 +1,335 @@ +:root { + --bg: #181920; + --panel: #1e1f26; + --panel-2: #23252e; + --border: #2f313c; + --text: #d7dae0; + --text-dim: #8a8f99; + --accent: #3f8ae0; + --accent-2: #f0c000; + --danger: #e0562f; + font-family: Inter, system-ui, -apple-system, sans-serif; + font-size: 13px; +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + margin: 0; + height: 100%; + background: var(--bg); + color: var(--text); + overflow: hidden; + user-select: none; +} + +.app { + display: grid; + grid-template-rows: auto 1fr; + grid-template-columns: 240px 1fr 320px; + grid-template-areas: + 'toolbar toolbar toolbar' + 'stencil canvas properties'; + height: 100%; +} + +/* ---- Toolbar ---- */ +.toolbar { + grid-area: toolbar; + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + background: var(--panel); + border-bottom: 1px solid var(--border); +} +.toolbar .title { + font-weight: 600; + margin-right: 12px; +} +.toolbar .spacer { + flex: 1; +} +.toolbar .group { + display: flex; + gap: 2px; + padding: 0 6px; + border-right: 1px solid var(--border); +} +.toolbar .group:last-child { + border-right: none; +} + +button { + background: var(--panel-2); + color: var(--text); + border: 1px solid var(--border); + border-radius: 5px; + padding: 5px 9px; + cursor: pointer; + font-size: 12px; +} +button:hover:not(:disabled) { + background: #2c2e38; + border-color: #3d3f4b; +} +button:disabled { + opacity: 0.4; + cursor: default; +} +button.primary { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} +button.danger:hover { + border-color: var(--danger); + color: var(--danger); +} +button.active { + background: var(--accent-2); + border-color: var(--accent-2); + color: #1a1a1a; +} + +/* ---- Stencil ---- */ +.stencil { + grid-area: stencil; + background: var(--panel); + border-right: 1px solid var(--border); + overflow-y: auto; + padding: 8px; +} +.stencil .search { + width: 100%; + padding: 6px 8px; + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: 5px; + color: var(--text); + margin-bottom: 8px; +} +.stencil-group-title { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-dim); + margin: 10px 4px 6px; + cursor: pointer; + display: flex; + justify-content: space-between; +} +.stencil-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; +} +.stencil-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 8px 4px; + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: 6px; + cursor: grab; +} +.stencil-item:hover { + border-color: var(--accent); +} +.stencil-item svg { + width: 40px; + height: 40px; +} +.stencil-item .name { + font-size: 10px; + color: var(--text-dim); + text-align: center; + line-height: 1.2; +} + +/* ---- Canvas ---- */ +.canvas-wrap { + grid-area: canvas; + position: relative; + overflow: hidden; + background: var(--bg); +} +.canvas-host { + width: 100%; + height: 100%; +} +.canvas-host .joint-paper { + background: transparent !important; +} +.empty-hint { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-dim); + pointer-events: none; + font-size: 14px; +} + +/* ---- Properties ---- */ +.properties { + grid-area: properties; + background: var(--panel); + border-left: 1px solid var(--border); + overflow-y: auto; + padding: 10px; +} +.prop-empty { + color: var(--text-dim); + padding: 20px 6px; + text-align: center; +} +.prop-header { + font-weight: 600; + margin-bottom: 4px; +} +.prop-sub { + color: var(--text-dim); + font-size: 11px; + margin-bottom: 10px; +} +.prop-group { + border: 1px solid var(--border); + border-radius: 6px; + margin-bottom: 8px; + overflow: hidden; +} +.prop-group-title { + background: var(--panel-2); + padding: 6px 10px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-dim); +} +.prop-row { + display: grid; + grid-template-columns: 44% 56%; + align-items: center; + gap: 6px; + padding: 5px 10px; +} +.prop-row label { + color: var(--text-dim); + font-size: 12px; +} +.prop-row .unit { + color: var(--text-dim); + font-size: 10px; +} +.prop-row input[type='text'], +.prop-row input[type='number'], +.prop-row select { + width: 100%; + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text); + padding: 4px 6px; + font-size: 12px; +} +.prop-row input.invalid { + border-color: var(--danger); +} +.prop-error { + grid-column: 1 / 3; + color: var(--danger); + font-size: 10px; +} +.chips { + display: flex; + flex-wrap: wrap; + gap: 4px; +} +.chip { + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: 10px; + padding: 2px 8px; + font-size: 11px; + display: flex; + gap: 4px; + align-items: center; +} +.chip button { + border: none; + background: none; + padding: 0; + color: var(--text-dim); +} +.list-editor input { + margin-bottom: 4px; +} + +/* ---- Dialog ---- */ +.dialog-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 50; +} +.dialog { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + padding: 18px; + width: 380px; +} +.dialog h3 { + margin: 0 0 14px; +} +.dialog .prop-row { + padding: 6px 0; +} +.dialog .actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 14px; +} + +/* ---- Flow legend ---- */ +.flow-legend { + position: absolute; + bottom: 12px; + left: 12px; + background: rgba(30, 31, 38, 0.9); + border: 1px solid var(--border); + border-radius: 6px; + padding: 8px 10px; + font-size: 11px; +} +.flow-legend .bar { + width: 160px; + height: 8px; + border-radius: 4px; + background: linear-gradient(90deg, #3a6ea5, #3fb6c0, #e0b62f, #e0562f); + margin: 4px 0; +} +.flow-legend .scale { + display: flex; + justify-content: space-between; + color: var(--text-dim); +} +.zoom-controls { + position: absolute; + bottom: 12px; + right: 12px; + display: flex; + gap: 4px; +}