// tdd-guard:skip — UI composition layer (canvas pan/zoom state + DOM drag refs); pure logic in vsm-geometry/-metrics/-excel (tested)
// VSM editor (R-05 refactor 2026-04-19):
//  - Pure helpers moved to `vsm-geometry.ts`, `vsm-metrics.ts`, `vsm-excel.ts`.
//  - Palette config lives in `vsm-config.ts`.
//  - This file still owns canvas pan/zoom state and the DOM-bound drag refs;
//    splitting those further without real-browser behaviour tests is not safe
//    (see R-05 follow-up in the risk register).
'use client'

import { useState, useEffect, useRef, useMemo, useCallback } from'react'
import Link from'next/link'
import { useRouter } from'next/navigation'
import { ChevronLeft, Timer, Download, RefreshCw, GitBranch, GitCompare, LayoutGrid, Table2, Undo2, Redo2 } from'lucide-react'
import type { ValueStreamMap, VsmNode, VsmConnection, NodeType, ProcessType, VaClass, VsmNodeEditableFieldKey, ConnectionKind, InventoryKind, ValueProvenance, FlowControl } from'@/lib/vsm-types'
import { createClient } from'@/lib/supabase/client'
import { NODE_CONFIG, NODE_TYPE_LABEL_KEYS, CONNECTION_KIND_LABELS, CONNECTION_KIND_LABEL_KEYS, FLOW_CONTROL_LABELS, FLOW_CONTROL_LABEL_KEYS, INVENTORY_KIND_LABELS, INVENTORY_KIND_LABEL_KEYS, PROVENANCE_LABELS, PROVENANCE_LABEL_KEYS, SCENARIO_KIND_LABELS, SCENARIO_KIND_LABEL_KEYS, VSM_SYMBOL_LABELS, VSM_SYMBOL_LABEL_KEYS, isProvenanceDowngrade, resolveNodeSymbolKey, resolveNodeSymbolLabel, resolveVaBadgeState } from './vsm-config'
import {
  NODE_W,
  getNodeH,
  getOutputPortPos,
  getInputPortPos,
  buildPath,
  computeFitView,
  computeContentBounds,
  computeInformationEdgeRoutes,
  parseViewportFromLayout,
  parseShiftModelFromLayout,
  parseZonesFromLayout,
  parseTimelineOffsetYFromLayout,
  parsePreferredScenarioIdFromLayout,
  resolveTimelineOffsetY,
  computeZoneBounds,
  computeZoneDividers,
  shiftModelWireValue,
  sanitizeConnections,
  DEFAULT_PAN,
  DEFAULT_ZOOM,
  ZOOM_MIN,
  ZOOM_MAX,
} from './vsm-geometry'
import { computeTimeline, hasCtWarning, computeDemandUnitsPerDay } from './vsm-metrics'
import { mapExcelRowsToPreview, buildLinearConnectionPairs, importRowToNodeTimeFields, type VsmImportRow } from './vsm-excel'
import { computeAutoLayout } from './vsm-auto-layout'
import { computeBottleneckV2, computeEffectiveCapacity, computeUtilization, computeTakt, type ShiftModelInput } from '@/lib/vsm-engine'
import { calculateAvailabilityFromMtbfMttr } from '@/lib/oee'
// Review-Fix F7 (adversarial review, PR #355): reused via the qaf-differences
// module's PUBLIC barrel, NOT a deep import into its internal/ (that
// module's own index.ts doc comment: "no deep imports into internal/" —
// checked against eslint-plugin-boundaries' element-types rule too: both
// this module and qaf-differences are plain "module"-type under ADR 015's
// layer map, so a deep import would have passed check:boundaries anyway,
// but the barrel is the already-established, less-surprising path in).
import { normalizeCurrency } from '@/lib/qaf-differences'
import VsmTimelineLadder from './vsm-timeline-ladder'
import VsmTableView from './vsm-table-view'
import VsmScenarioNav from './vsm-scenario-nav'
import VsmScenarioCompareView from './vsm-scenario-compare-view'
import type { ScenarioLinkMeta, ScenarioCompareSide } from './vsm-scenario-compare'
import VsmStoppuhrPickerModal from './vsm-stoppuhr-picker-modal'
import VsmExcelImportModal from './vsm-excel-import-modal'
import CopilotExportButton from'@/components/copilot-export/copilot-export-button'
import { exportWertstromCopilotDocx, exportWertstromCopilotMd } from'@/app/wertstrom/copilot-export-actions'
import VsmManagementAnalysisView from './vsm-management-analysis-view'
import VsmCanvasTimelineBand from './vsm-canvas-timeline'
import VsmCtTaktChart from './vsm-ct-takt-chart'
import VsmPresentationMode from './vsm-presentation-mode'
import VsmExportDialog from './vsm-export-dialog'
import VsmLscImportModal from './vsm-lsc-import-modal'
import VsmQafSourceModal from './vsm-qaf-source-modal'
import QvsReimportDialog from './qvs-reimport-dialog'
import VsmMeasureDialog, { type MeasureCreationContext } from './vsm-measure-dialog'
import { resolveLinkedMeasures, deriveOpenMeasureInputs, measureTitle, isMeasureAlreadyLinked, WORKSHOP_STATUS_LABELS_DE, WORKSHOP_STATUS_LABEL_KEYS, type ProjectMeasuresQuery } from './vsm-measures'
import VsmLinkMeasureDialog from './vsm-link-measure-dialog'
import type { LinkMeasureOutcome } from './vsm-measure-dialog'
import FieldHint from '@/components/ui/field-hint'
import { VsmSymbolByKey, VsmSymbolKaizen, VsmSymbolBottleneck, VsmSymbolInfoElectronic, VsmSymbolTimelineMarker, VsmSymbolPushArrow, VsmSymbolKanbanPull } from './vsm-symbols'
import { buildTooltipMap, type TooltipRow } from '@/lib/lsc/tooltips'
import { useI18n, interpolate } from '@/lib/i18n/i18n-context'
import {
  deriveFieldStatusOnEdit,
  deriveIsValueAddedFromVaClass,
  deriveVaClassSelection,
  hasFieldValueChanged,
  isEditableFieldKey,
  legacyValueAddedHint,
} from './vsm-field-status'
import { computeQafVsMeasuredDeltaPercent, findMeasuredMatch, type MeasuredStep } from './vsm-qaf-measured-compare'
// Review-Fix (minor, KAR-974 adversarial review): single source of truth —
// this constant used to be defined only here, so the QVS reimport dialog's
// vaClass field-delta rows had no label source to reuse and fell back to the
// raw enum value. Moved to qvs-field-labels.ts, imported from there now.
import { VA_CLASS_LABELS, VA_CLASS_LABEL_KEYS } from './qvs-field-labels'

/** Small, muted "importiert"/"geändert" tag next to a field label — the
 * "geändert"-Status from qafSource-Lineage (ux-flow.md §8). Renders nothing
 * for a field with no fieldStatus entry (manual nodes, or an imported field
 * qafSource never actually captured a value for). */
function FieldStatusTag({ status }: { status?: 'imported' | 'modified' }) {
  const { t } = useI18n()
  if (!status) return null
  return (
    <span
      className={`text-[8px] px-1 rounded font-normal normal-case tracking-normal ${
        status === 'modified' ? 'bg-status-yellow/50 text-foreground' : 'bg-muted text-muted-foreground'
      }`}
    >
      {status === 'modified' ? t('wertstrom.editor.panel.fieldStatusModified', 'geändert') : t('wertstrom.editor.panel.fieldStatusImported', 'importiert')}
    </span>
  )
}

/** Wertstrom P2 (A12, KAR-878/KAR-986): read-only "Herkunft" badge for
 * `VsmNode.provenance` — the minimal-UI scope for this phase is display
 * only (see lib/vsm-types.ts ValueProvenance doc comment for why this is
 * one scalar per node, not a per-field map). Renders nothing when absent
 * ("unbekannt" is never shown as a badge on every node — same "absence is
 * quiet" posture as FieldStatusTag above).
 *
 * Review-Fix F11 (adversarial review, PR #353): rendered next to the
 * Zykluszeit field (where FieldStatusTag already sits), NOT the node
 * header — all 3 write paths (LSC-Import, Inline-Stoppuhr, Stoppuhr-Picker)
 * couple provenance exclusively with cycleTimeSec, so a header placement
 * read as a node-wide claim it never was, conflicting with the OTHER
 * fields' own independent FieldStatusTag ("importiert"/"geändert") in the
 * same panel. Tooltip renamed to match. */
function ProvenanceTag({ provenance }: { provenance?: ValueProvenance }) {
  const { t } = useI18n()
  if (!provenance) return null
  return (
    <span
      className="ml-auto shrink-0 text-[9px] px-1.5 py-0.5 rounded-full bg-muted text-muted-foreground font-normal normal-case tracking-normal"
      title={t('wertstrom.editor.panel.provenanceTagTitle', 'Herkunft der Zykluszeit')}
    >
      {t(PROVENANCE_LABEL_KEYS[provenance], PROVENANCE_LABELS[provenance])}
    </span>
  )
}

/** Wertstrom P4 (A13, Capability-Matrix, KAR-878/KAR-986): a collapsible
 * Properties-Panel tier ("Advanced"/"Expert", §10.2 Progressive Disclosure)
 * — a native `<details>`/`<summary>` so open/close is keyboard-operable and
 * screen-reader-correct with zero extra JS state. Closed by default (§10.2
 * "keep advanced sections closed by default"), independent per instance —
 * opening Advanced does not affect Expert or vice versa. Only rendered by
 * the NEW tiered panel (`wertstromUxV2Enabled`); the old flat panel never
 * uses this. */
function PanelSection({ title, children }: { title: string; children: React.ReactNode }) {
  return (
    <details className="border-t border-border pt-3">
      <summary className="cursor-pointer text-[10px] font-semibold text-muted-foreground uppercase tracking-wider select-none hover:text-primary">
        {/* Review-Fix F13 (adversarial review, PR #355): visually this reads
            as a section heading (bold, uppercase — same styling the "Basis"
            tier title uses), but plain <summary> text has no Heading-List
            presence for a screen reader (NVDA/JAWS Insert+F7, VoiceOver
            Rotor) — an AT user could not jump directly between Basis/
            Erweitert/Experte, only linear Tab/Browse. role="heading" +
            aria-level gives it that presence without changing focus/
            keyboard behavior (native <details>/<summary> toggle semantics
            stay exactly as they were — this is a semantics-only addition,
            not a new element). Not a real <h4>: <summary>'s own implicit
            button-like role already carries the click/Enter/Space toggle
            behavior; nesting a heading element inside it would be redundant
            markup for the same visual text. */}
        <span role="heading" aria-level={4}>{title}</span>
      </summary>
      <div className="mt-3 space-y-3">{children}</div>
    </details>
  )
}

/** Wertstrom P4 (B3, Capability-Matrix, KAR-878/KAR-986): read-only display
 * of `lib/vsm-engine`'s `computeEffectiveCapacity`/`computeUtilization` for
 * ONE process node — the UI never recomputes, same "UI rendert NUR, rechnet
 * NICHT selbst" doctrine `vsm-timeline-ladder.tsx` already documents for
 * itself. `availabilityPct` is passed as a `NodeCapacityOverrides` override
 * (never written into `node.oee` — see VsmNode.availabilityPct doc
 * comment). Renders nothing useful (an honest "nicht berechenbar") when the
 * node has no cycle time yet, rather than a fabricated number. */
function EffectiveCapacityDisplay({ node, taktTimeSec }: { node: Pick<VsmNode, 'id' | 'cycleTimeSec' | 'partsPerCycle' | 'capacityPerHour' | 'oee' | 'availabilityPct'>; taktTimeSec: number | null }) {
  const { t } = useI18n()
  const capacity = computeEffectiveCapacity(node, node.availabilityPct != null ? { availabilityPct: node.availabilityPct } : undefined)
  const utilization = computeUtilization(capacity.effectiveCycleTimeSec, taktTimeSec)
  if (capacity.effectiveCycleTimeSec == null) {
    return <p className="text-[10px] text-muted-foreground">{t('wertstrom.editor.panel.effectiveCapacityNotComputable', 'Effektive Kapazität: nicht berechenbar (keine Zykluszeit).')}</p>
  }
  return (
    <div className="text-[10px] text-muted-foreground space-y-0.5">
      <p>
        {t('wertstrom.editor.panel.effectiveCycleTime', 'Effektive Zykluszeit:')} <span className="text-foreground font-medium">{capacity.effectiveCycleTimeSec.toFixed(1)}s</span>
        {capacity.effectiveCapacityPerHour != null && <> · {Math.round(capacity.effectiveCapacityPerHour)} {t('wertstrom.editor.panel.unitsPerHour', 'Stk/h')}</>}
      </p>
      <p>{t('wertstrom.editor.panel.utilization', 'Auslastung:')} {utilization.utilizationPct != null ? <span className="text-foreground font-medium">{Math.round(utilization.utilizationPct)}%</span> : t('wertstrom.editor.panel.utilizationNotComputable', 'nicht berechenbar (kein Kundentakt)')}</p>
    </div>
  )
}

// Referenzwertstrom-Auftrag (Kais §12.3, KAR-878): "Ergänze bei wichtigen
// Begriffen kurze Hilfetexte" — Kais' drei Beispieltexte, WÖRTLICH
// übernommen. Local (not DB-backed via lsc_tooltips/'vsm.*', unlike every
// other FieldHint in this file) — Takt/Zykluszeit/Durchlaufzeit as concepts
// have no existing 'vsm.*' tooltip key (checked: 'vsm.cycle_time' explains
// the FIELD "Zykluszeit (s)" on a process node, not the general concept;
// no 'vsm.takt'/'vsm.durchlaufzeit' key exists at all) and adding one would
// need a new migration (programme rule: KEINE Migration/DDL). Attached to
// the two new Referenzwertstrom sections (Zickzack-Zeitlinie/CT-Takt-
// Diagramm) — genuinely new UI with no pre-existing hint coverage to
// duplicate, reusing the EXISTING FieldHint component/contract unchanged.
type TFn = (key: string, fallback?: string) => string

// P8.2c (KAR-987 §24, i18n): these 3 were module-level constants — no `t()`
// access outside a component — now small builder functions, each called once
// at its single render call site with that component's own `t`.
function taktzeitHint(t: TFn) {
  return {
    label: t('wertstrom.editor.hints.taktzeit.label', 'Taktzeit'),
    content: t('wertstrom.editor.hints.taktzeit.content', 'Taktzeit beschreibt, in welchem zeitlichen Abstand ein Produkt fertiggestellt werden muss, um den Kundenbedarf zu erfüllen.'),
    formula: null,
    example: null,
  }
}
function zykluszeitHint(t: TFn) {
  return {
    label: t('wertstrom.editor.hints.zykluszeit.label', 'Zykluszeit'),
    content: t('wertstrom.editor.hints.zykluszeit.content', 'Zykluszeit beschreibt, wie lange ein Prozess tatsächlich für ein Produkt benötigt.'),
    formula: null,
    example: null,
  }
}
function durchlaufzeitHint(t: TFn) {
  return {
    label: t('wertstrom.editor.hints.durchlaufzeit.label', 'Durchlaufzeit'),
    content: t('wertstrom.editor.hints.durchlaufzeit.content', 'Durchlaufzeit umfasst Bearbeitungszeit, Wartezeit, Lagerzeit und Transportzeit.'),
    formula: null,
    example: null,
  }
}

interface Props {
 vsm: ValueStreamMap
 projects: Array<{ id: string; project_code: string | null; supplier_name: string }>
 /** measuredAvgSec/measuredCount (QVS-P3, KAR-972): additive, optional —
  * absent/undefined for any caller that predates the Stoppuhr-Soll/Ist-
  * Vergleich (ux-flow.md §8), e.g. vsm-stoppuhr-picker-modal's own
  * StoppuhrStep type only ever needed the first 3 fields. */
 processSteps: Array<{ id: string; station_name: string; planned_cycle_time_sec: number | null; measuredAvgSec?: number | null; measuredCount?: number }>
 taktTimeSec: number | null
 tooltipRows: TooltipRow[]
 /** copilotExports feature flag (config/profiles) — resolved server-side by
  * page.tsx and passed down (getProfile() reads process.env, must not be
  * called from this client component). */
 copilotExportsEnabled: boolean
 /** Wertstrom P2 review-fix F2 (KAR-878/KAR-986, PR #353): `wertstromScenarios`
  * feature flag (config/profiles), resolved server-side by page.tsx and
  * passed down — same convention as copilotExportsEnabled. Gates the whole
  * A9 create-UI (toolbar button + scenario badge): `false` in every profile
  * until P5 ships (CAPABILITY-MATRIX.md scopes the create-ACTION to P5; P2
  * only owns the underlying scenario_kind/parent_value_stream_id data
  * model). */
 wertstromScenariosEnabled: boolean
 /** Wertstrom P3 (KAR-878/KAR-986): `wertstromUxV2` feature flag
  * (config/profiles), resolved server-side by page.tsx and passed down —
  * same convention as the flags above. Gates the "Automatisch anordnen"
  * toolbar button and the Timeline-Leiter v2 (replaces the 1-Prozentbalken
  * only while true; the old bar renders byte-identical when false — see
  * CAPABILITY-MATRIX.md A2/A4, P3-Brief Nicht-Scope "Regression = No-Go"). */
 wertstromUxV2Enabled: boolean
 /** Wertstrom P5 (A9, KAR-878/KAR-986): the linked project's display label
  * (project_code ?? supplier_name, or null for "kein Projekt"), resolved
  * server-side alongside taktTimeSec — used only by the Vergleichs-Ansicht's
  * context-mismatch banner (vsm-scenario-compare.ts describeContextMismatch).
  * Optional/defaulted (null) so this component stays byte-compatible for any
  * caller that predates this feature — same convention as
  * sourceFileNameByVsmId (vsm-list-client.tsx). */
 projectLabel?: string | null
 /** Wertstrom P5 (A9, KAR-878/KAR-986, execution-prompt §9.3/§9.4) —
  * Szenarien-Navigation + Vergleichs-Daten, all resolved server-side by
  * page.tsx from the EXISTING value_stream_maps GET surface (no new API
  * route, P5-Brief). scenarioParent/scenarioChildren are lightweight
  * identity-only metadata (id/title/scenario_kind) — always populated
  * regardless of whether a comparison is ever opened. compareTarget is the
  * OTHER side's FULL bundle (nodes + its own takt/shiftModel context),
  * non-null only once a comparison is actually being viewed: eagerly for a
  * scenario (its parent is the only possible target, one cheap row), or
  * after navigating to ?compare=<scenarioId> for an Ist map (which can have
  * several scenarios — see vsm-scenario-nav.tsx). All four optional/
  * defaulted (null/[]/false) — same byte-compatibility convention as
  * projectLabel above; a caller that omits them simply never sees the
  * Szenarien-Navigation/Vergleich UI (its own render conditions all check
  * for a real value, never assume presence). */
 scenarioParent?: ScenarioLinkMeta | null
 scenarioChildren?: ScenarioLinkMeta[]
 compareTarget?: ScenarioCompareSide | null
 /** A ?compare= id was given but did not resolve to a real child of this
  * map — P5-Brief "ehrlicher Hinweis", not a silently ignored param. */
 compareTargetRequestedButMissing?: boolean
 /** Wertstrom P8.1 (A18, KAR-878/KAR-986, execution-prompt §15.5, P7-
  * Anschluss): every `workshop_actions` row of the LINKED project, resolved
  * server-side by page.tsx (EIN Query, RLS) — the ONLY data source
  * `VsmNode.measureRefs` is checked against (Node-Panel "Verknüpfte
  * Maßnahmen", Management-Analyse "offene Maßnahmen"). `null` = no project
  * linked to this card (page.tsx never queried) — distinct from `[]` (a
  * linked project with zero measures), same "null = no data source, not a
  * fabricated empty result" convention `openMeasures` itself documents.
  * `'error'` (K2-Fix, RLS-Sichtbarkeits-Ehrlichkeit, PR #363 adversarial
  * review): the query itself failed — kept distinguishable from a real `[]`
  * so a caller never renders "0 Maßnahmen" for a result it could not
  * confirm (see vsm-measures.ts `ProjectMeasuresQuery` doc). Defaults to
  * `null` for byte-compat with any caller (test fixture) that predates this
  * prop. */
 projectMeasures?: ProjectMeasuresQuery
 /** Wertstrom P8.1 fix (K2, RLS-Sichtbarkeits-Ehrlichkeit — PR #363
  * adversarial review, CONFIRMED critical/major): resolved server-side by
  * page.tsx from the SAME id `workshop_actions_own`'s RLS policy itself
  * compares against (project.user_id === current user) — an insert would be
  * RLS-rejected for a non-owner regardless of what this app shows, so the
  * "Maßnahme erstellen" action greys out with an honest hint instead of
  * letting the user hit that failure at submit time. Defaults to `true` for
  * byte-compat with any caller (test fixture) that predates this prop —
  * every pre-K2 test exercises the (implicit) owner path. */
 canManageMeasures?: boolean
}

type DragState =
 | { type:'pan'; startScreenX: number; startScreenY: number; startPan: { x: number; y: number } }
 | { type:'node'; nodeId: string; startScreenX: number; startScreenY: number; startNodeX: number; startNodeY: number }
 | { type:'palette'; nodeType: NodeType; startScreenX: number; startScreenY: number }
 // Baustein 2 (VSM-Standard-Visualisierung, KAR-878): the canvas timeline
 // band's drag-Griff — same "startScreenY + start value, live-delta on
 // move" shape as 'node' above, just for one vertical offsetY instead of
 // x/y.
 | { type:'timeline-offset'; startScreenY: number; startOffsetY: number }

// Baustein 3 (Undo/Redo, KAR-878/KAR-986/KAR-987, P8.2b): see the
// historyPast/historyFuture state's own doc comment for why this is a full
// snapshot (not a diff) of exactly the two graph-shaped editor states.
interface VsmHistorySnapshot {
 nodes: VsmNode[]
 connections: VsmConnection[]
}

export default function VsmEditor({
 vsm,
 projects,
 processSteps,
 // Referenzwertstrom-Falle (Kais-Auftrag, KAR-878): this prop is
 // `projects.customer_takt_time_sec` — always `null` for a Wertstrom with no
 // linked project (`project_id: null`, e.g. the standalone Referenz-
 // Beispielwertstrom "Elektronisches Steuergerät"). Renamed here so the
 // component body can shadow it with a live-computed fallback below
 // (`taktTimeSec` = this value, or — only when it is absent — a takt derived
 // fresh from `shiftModel` + `demandUnitsPerDay` via the SAME `computeTakt`
 // formula `vsm-wizard-logic.ts` already uses once at creation time).
 taktTimeSec: taktTimeSecFromProject,
 tooltipRows,
 copilotExportsEnabled,
 wertstromScenariosEnabled,
 wertstromUxV2Enabled,
 projectLabel = null,
 scenarioParent = null,
 scenarioChildren = [],
 compareTarget = null,
 compareTargetRequestedButMissing = false,
 projectMeasures = null,
 canManageMeasures = true,
}: Props) {
 const router = useRouter()
 // KAR-726 — ⓘ-Feld-Hinweise (admin-editierbar via lsc_tooltips, Keys 'vsm.*')
 const { locale, t } = useI18n()
 const hints = useMemo(() => buildTooltipMap(tooltipRows, locale), [tooltipRows, locale])
 const [nodes, setNodes] = useState<VsmNode[]>(vsm.nodes)
 // Wertstrom P8.1 fix (K1 Orphan-Maßnahme + C3 Closure-Race — PR #363
 // adversarial review): CURRENT-value-at-fire-time ref, same convention as
 // updatedAtRef/panRef/zoomRef below — `linkMeasureToNode` needs a
 // synchronous read of the LATEST `nodes` from an async (post-`await`)
 // continuation, where a `setNodes(prev => …)` updater callback is NOT
 // guaranteed to run synchronously (verified: it does not, in this exact
 // call context — the updater fires on a later microtask/render here, so
 // a side-channel variable written inside it cannot be read back
 // immediately after the `setNodes(...)` call returns).
 const nodesRef = useRef(nodes)
 useEffect(() => { nodesRef.current = nodes }, [nodes])
 // Review-Fix F4: drop dangling connections (fromNodeId/toNodeId pointing
 // at a node not in this Wertstrom) already on load — see sanitizeConnections'
 // own doc comment. Without this, a row with a pre-existing dangling
 // connection (historical bug/race, not caused by this editor) could never
 // be saved again: the server's referential-integrity check would reject
 // every PUT, since the editor always resends the full graph.
 const [connections, setConnections] = useState<VsmConnection[]>(() => sanitizeConnections(vsm.nodes, vsm.connections))
 // Baustein 3 (Undo/Redo, KAR-878/KAR-986/KAR-987, P8.2b): same
 // current-value-at-fire-time convention as nodesRef above — history-snapshot
 // capture (captureHistorySnapshot below) always reads the LATEST connections
 // even from inside a useCallback whose own deps don't include `connections`.
 const connectionsRef = useRef(connections)
 useEffect(() => { connectionsRef.current = connections }, [connections])
 // Baustein 3 (Undo/Redo, KAR-878/KAR-986/KAR-987, P8.2b): History über die
 // EDITOR-States nodes/connections (Brief) — NICHT über Server-Saves. Node-
 // Positionen (x/y) sind Teil von `nodes`, daher deckt dieses Paar auch
 // Drag/Pfeiltasten-Bewegung ab; siehe die Push-Stellen weiter unten für den
 // Judgment-Call, warum `pan`/`zoom`/`timelineOffsetY` bewusst NICHT Teil
 // des Snapshots sind. Voller Array-Snapshot (kein Diff) pro Eintrag — macht
 // "Undo von Delete stellt measureRefs mit Objekt-Identität wieder her"
 // automatisch wahr (das gelöschte Node-Objekt war im Snapshot immer
 // vollständig enthalten). Begrenzter Stack (Brief: ~50 Einträge).
 // historyPastRef/historyFutureRef (current-value-at-fire-time, wie
 // nodesRef/connectionsRef oben) sind PFLICHT hier, nicht optional: der
 // bestehende globale Keyboard-Effect unten hat `[selectedNodeId]` als
 // einzige Deps und würde sonst bei jedem Undo/Redo-Push eine veraltete
 // history-Closure einfangen (Ctrl+Z würde einen STALE Stapel sehen) —
 // undo()/redo() lesen ausschließlich über die Refs, nie über den State
 // direkt, und bleiben dadurch unabhängig davon korrekt, von wo/wann sie
 // aufgerufen werden.
 const HISTORY_LIMIT = 50
 const [historyPast, setHistoryPast] = useState<VsmHistorySnapshot[]>([])
 const [historyFuture, setHistoryFuture] = useState<VsmHistorySnapshot[]>([])
 const historyPastRef = useRef(historyPast)
 const historyFutureRef = useRef(historyFuture)
 useEffect(() => { historyPastRef.current = historyPast }, [historyPast])
 useEffect(() => { historyFutureRef.current = historyFuture }, [historyFuture])
 // Wertstrom P0 (KAR-878): restore the last-saved viewport (layout.viewport)
 // if there is one; a Wertstrom with no saved viewport yet (pre-existing rows,
 // the jsonb '{}' default) falls back to Fit-View on first render (effect below)
 // instead of always landing on the old fixed pan/zoom.
 const initialViewport = parseViewportFromLayout(vsm.layout)
 const [pan, setPan] = useState(initialViewport ? { x: initialViewport.x, y: initialViewport.y } : DEFAULT_PAN)
 const [zoom, setZoom] = useState(initialViewport ? initialViewport.zoom : DEFAULT_ZOOM)
 // Wertstrom P4 (A14, KAR-878/KAR-986): Canvas <-> Tabelle — same nodes/
 // connections state either view reads/writes (see updateNode/handleSave
 // below, unchanged by which view is active). Only reachable behind
 // wertstromUxV2Enabled; 'canvas' is the only value ever used when the flag
 // is off (no toggle rendered — see toolbar), so the old layout stays
 // byte-identical.
 // Wertstrom P5 (A9): 'compare' added the same way — only reachable behind
 // wertstromScenariosEnabled AND an actually-available compareTarget/
 // compareTargetRequestedButMissing (see toolbar). On an Ist page, initializes
 // straight into 'compare' when the page was navigated here via a
 // Vergleichen-Link (?compare=… resolved, or resolved-but-missing), so a
 // shared/bookmarked comparison link opens directly on the comparison
 // instead of requiring an extra click. A SCENARIO page never auto-opens
 // (see the initializer body below) — its parent is eagerly loaded
 // regardless of intent, so presence alone would wrongly open every visit.
 const [viewMode, setViewMode] = useState<'canvas' | 'table' | 'compare'>(() => {
 // Defense-in-depth on top of page.tsx's own flag-gated fetch (Flag AUS
 // muss byte-gleich bleiben, auch für einen getippten/gebookmarkten
 // ?compare=-Link) — this component never trusts compareTarget/
 // compareTargetRequestedButMissing alone without also checking the flag.
 if (!wertstromScenariosEnabled) return'canvas'
 // On a SCENARIO page, compareTarget (its parent) is ALWAYS eagerly loaded
 // once it resolves — completely independent of user intent (no ?compare=
 // picker on that side, see page.tsx). Its mere presence can therefore NOT
 // be read as "the user wants the compare view right now" the way it can
 // on an Ist page (where compareTarget only ever exists BECAUSE of an
 // explicit ?compare= navigation) — auto-opening here would drop every
 // scenario-page visit straight into Vergleich instead of Canvas. Only the
 // Ist-page path below auto-opens; a scenario page always starts on Canvas,
 // "Vergleichen" (vsm-scenario-nav.tsx) is an explicit local click.
 const isScenarioPage = !(!vsm.scenario_kind || vsm.scenario_kind ==='current')
 if (isScenarioPage) return'canvas'
 return (compareTarget != null || compareTargetRequestedButMissing) ?'compare':'canvas'
 })
 const tableHeadingRef = useRef<HTMLHeadingElement>(null)
 const compareHeadingRef = useRef<HTMLHeadingElement>(null)
 // A11y (P4-Brief): focus follows the newly-shown content when switching
 // INTO table mode — same "focus the new content's heading" convention the
 // Quick-Start-Wizard's per-step headingRef already uses. Switching back to
 // Canvas needs no equivalent effect: the toggle button that was just
 // clicked already keeps focus by default browser behavior, and the canvas
 // itself has no single natural focus target the way a table's caption does.
 useEffect(() => {
 if (viewMode === 'table') tableHeadingRef.current?.focus()
 // Wertstrom P5 (A9): same focus-follows-heading convention for the new
 // Vergleichs-Ansicht.
 if (viewMode === 'compare') compareHeadingRef.current?.focus()
 }, [viewMode])
 // Wertstrom P4 (B2, Capability-Matrix, KAR-878/KAR-986): the simple
 // Arbeitszeitmodell (§8.2), persisted in layout.shiftModel (see
 // parseShiftModelFromLayout's own doc comment for why no migration is
 // needed). Feeds the Timeline-Leiter's demand/shiftModel params (below) —
 // today those are ALWAYS undefined (vsm-timeline-ladder.tsx's own doc
 // comment: "the plain editor has no persisted place to source those from
 // without a new DB column"); this closes exactly that gap using the
 // EXISTING jsonb bucket, not a new one.
 const [shiftModel, setShiftModel] = useState<ShiftModelInput | undefined>(() => parseShiftModelFromLayout(vsm.layout))
 // Review-Fix F3+F5 (adversarial review, PR #355): true once `shiftModel`
 // has held a COMPLETE model at any point this session — loaded already-
 // complete from the server, or completed by the user via ShiftModelEditor
 // (onShiftModelChange below). Read by shiftModelWireValue (vsm-geometry.ts)
 // at save-time to tell "form was never touched" apart from "form WAS
 // complete and has since been cleared" — both look identical as a bare
 // `undefined` local-state value, but only the second one is a real delete.
 // A ref, not state: it must never itself trigger a re-render, and its
 // value only ever needs to be read at the moment a save fires.
 const shiftModelEverCompleteRef = useRef(shiftModel !== undefined)
 // Referenzwertstrom-Fixrunde (KAR-878, K-ZONEN): layout.zones (Kais §11
 // "Die Grenzen zwischen 2nd Tier, Tier 1 und BMW müssen visuell erkennbar // allow-customer-string
 // sein") — read-only for this phase (no zone-editing UI, same "additive,
 // no schema-Zwang" scope as shiftModel's own P4 introduction), so a plain
 // memo on the layout PROP is enough (no local editable state needed, unlike
 // shiftModel above). `undefined` on every pre-existing Wertstrom — zero
 // rendering change for any card that never defines zones.
 const zones = useMemo(() => parseZonesFromLayout(vsm.layout), [vsm.layout])
 const zoneBounds = useMemo(() => (zones ? computeZoneBounds(nodes, zones) : []), [zones, nodes])
 const zoneDividerXs = useMemo(() => computeZoneDividers(zoneBounds), [zoneBounds])
 const zoneDividerSpan = useMemo(() => computeContentBounds(nodes), [nodes])
 // Baustein 4 (Informationsfluss entwirren, KAR-878): ALL information-kind
 // routes computed together (deterministic lane fan-out needs the full set,
 // not one edge at a time) — see computeInformationEdgeRoutes's own doc.
 const informationEdgeRoutes = useMemo(() => computeInformationEdgeRoutes(nodes, connections), [nodes, connections])
 // K-ZONEN also resolves Kais §9's separate Tier-1-only scope for the
 // CT-Takt-Diagramm (`role === 'tier-1-supplier'`) — one mechanism for both
 // gaps, see VsmCtTaktChart's `tier1NodeIds` prop doc.
 const tier1NodeIds = useMemo(() => {
   const tier1Zone = zones?.find((z) => z.role === 'tier-1-supplier')
   return tier1Zone ? new Set(tier1Zone.nodeIds) : undefined
 }, [zones])
 const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
 // P8.2a (Baustein 3, "Push/Pull/Kanban-Kantensemantik", KAR-878/KAR-986):
 // mirrors selectedNodeId — mutually exclusive with it (selecting a node
 // clears this, selecting a connection clears selectedNodeId, clicking empty
 // canvas/Escape clears both). Only ever set for a materialFlow connection
 // (the click-handler that sets it is itself gated to kind !== 'information',
 // see the connections-<svg> render loop) and only while wertstromUxV2Enabled
 // (Panel-UI/Interaktion selbst hinter Flag, Brief) — a flag-off session
 // never wires the click handler at all, so this stays null unconditionally.
 //
 // K2-Fix (P8.2a-Review Fix-Runde, KAR-878/KAR-986): the "mutually exclusive"
 // claim above used to be enforced by hand at every call site that sets
 // either field — handleCanvasMouseDown/handleNodeMouseDown/
 // handleConnectionMouseDown/the Escape handler all did it correctly, but
 // selectAndRevealNode (Zeitlinien-Segment/CT-Takt-Diagramm), addNode,
 // deleteNode, and VsmTableView's onSelectNode passthrough did NOT clear the
 // other field — a selected connection could stay visually "selected" (Zeile
 // ~1990's isSelected) after a node was picked via one of those paths, and a
 // stale selectedConnectionId could resurface the Verbindungs-Panel after its
 // own node was deleted. Fixed by routing EVERY selection path through the
 // two setters below instead of the raw useState setters — see their doc
 // comments.
 const [selectedConnectionId, setSelectedConnectionId] = useState<string | null>(null)

 // K2-Fix (P8.2a-Review Fix-Runde, KAR-878/KAR-986): the ONE pair of setters
 // every selection path in this file must use — each unconditionally nulls
 // the OTHER field, so "mutually exclusive" is a structural property of
 // calling these functions, not a discipline every new call site has to
 // remember to uphold by hand (which is exactly how this regressed:
 // selectAndRevealNode/addNode/deleteNode/VsmTableView's onSelectNode each
 // called the raw `setSelectedNodeId` directly and simply forgot the
 // `setSelectedConnectionId(null)` half). `selectNode(null)` doubles as the
 // "clear everything" call (both fields become null) used by
 // handleCanvasMouseDown, deleteNode, and the Escape handler.
 const selectNode = useCallback((nodeId: string | null) => {
 setSelectedNodeId(nodeId)
 setSelectedConnectionId(null)
 }, [])

 const selectConnection = useCallback((connectionId: string | null) => {
 setSelectedConnectionId(connectionId)
 setSelectedNodeId(null)
 }, [])
 // Referenzwertstrom-Auftrag (Kais §8/§9, KAR-878): the Zickzack-Zeitlinie/
 // CT-Takt-Diagramm <details> sections below are closed by default (P4-
 // Panel-Muster) — their CONTENT is only mounted while open (not merely
 // CSS-hidden by the native <details> element), so their per-node text
 // labels never sit in the DOM (and never collide with `getByText(nodeName)`
 // elsewhere) while collapsed. `onToggle` (native, fires on both open AND
 // close) keeps this in sync with the disclosure widget's own real state.
 const [ctTaktOpen, setCtTaktOpen] = useState(false)
 // VSM-Standard-Visualisierung, Baustein 2 (Kais-Live-Feedback 23.07.,
 // KAR-878): the canvas timeline band's vertical nudge — see
 // vsm-geometry.ts's TIMELINE_BAND_* doc comment for the geometry this
 // feeds. Initialized from layout.timelineOffsetY (already resolved/
 // clamped, same "0 default, never NaN" posture parseViewportFromLayout's
 // sibling helpers use) so a reload keeps the user's last position.
 const [timelineOffsetY, setTimelineOffsetY] = useState<number>(() => resolveTimelineOffsetY(parseTimelineOffsetYFromLayout(vsm.layout)))
 const [connectingFrom, setConnectingFrom] = useState<string | null>(null)
 // Wertstrom P2 (A7, KAR-878/KAR-986): the connection kind chosen for the
 // connect-gesture currently in progress — reset to the default
 // ('materialFlow') every time a NEW connect gesture starts (handleOutputPortClick),
 // never carried over from the previous connection, so leaving "Information"
 // selected can't silently affect an unrelated later connection. frequency is
 // free text, only ever sent when kind === 'information'.
 const [connectingKind, setConnectingKind] = useState<ConnectionKind>('materialFlow')
 const [connectingFrequency, setConnectingFrequency] = useState('')
 // P8.2a (Baustein 3): same reset-per-gesture discipline as connectingKind/
 // connectingFrequency above — only ever sent when connectingKind !==
 // 'information' (the connect-time picker below only renders for that case).
 const [connectingFlowControl, setConnectingFlowControl] = useState<FlowControl | undefined>(undefined)
 const [mouseWorldPos, setMouseWorldPos] = useState({ x: 0, y: 0 })
 const [isDirty, setIsDirty] = useState(false)
 const [saving, setSaving] = useState(false)
 const [saveStatus, setSaveStatus] = useState<'idle'|'saved'|'error'|'conflict'>('idle')
 // Wertstrom P2 (A9): "Soll-Zustand erstellen" — separate from saveStatus
 // (an unrelated concern: this creates a NEW row via a dedicated endpoint,
 // it does not save the current one).
 const [creatingFutureState, setCreatingFutureState] = useState(false)
 const [futureStateError, setFutureStateError] = useState<string | null>(null)
 // Optimistic Locking ohne Migration (Wertstrom P0, KAR-878): das zuletzt
 // vom Server bestätigte updated_at — wird bei jedem Save mitgesendet
 // (expectedUpdatedAt) und aus der Response übernommen. conflictRef (nicht
 // nur saveStatus) pausiert die Autosave nach einem 409 bis zum Reload —
 // die Autosave-Closure braucht den AKTUELLEN Wert, kein Re-Render-Trigger.
 const [updatedAt, setUpdatedAt] = useState(vsm.updated_at)
 const updatedAtRef = useRef(updatedAt)
 useEffect(() => { updatedAtRef.current = updatedAt }, [updatedAt])
 const conflictRef = useRef(false)
 const [paletteDragType, setPaletteDragType] = useState<NodeType | null>(null)
 const [paletteDragPos, setPaletteDragPos] = useState({ x: 0, y: 0 })
 const [showStoppuhrPicker, setShowStoppuhrPicker] = useState(false)
 const [showExcelImport, setShowExcelImport] = useState(false)
 const [excelPreview, setExcelPreview] = useState<VsmImportRow[] | null>(null)
 const [showQafSource, setShowQafSource] = useState(false)
 // QVS-P5 (KAR-974): Reimport/Sync-Dialog — Entry-Point-Gate ist reine
 // qafSource-Präsenz (kein zusätzliches Flag-Prop nötig, s. Kommentar am
 // Toolbar-Button unten), exakt dasselbe Muster wie die vaClass-4-Wege-Auswahl
 // und "Quelle anzeigen" (P3): qafSource entsteht ausschließlich über den
 // flag-gated Import-Pfad, daher ist seine Existenz das korrekte Gate.
 const [showReimport, setShowReimport] = useState(false)
 const hasQafImportedNodes = useMemo(() => nodes.some(n => n.qafSource), [nodes])
 // Wertstrom P8.1 (A18, KAR-878/KAR-986, §15.5, P7-Anschluss): local copy of
 // the server-fetched `projectMeasures` prop so a newly created measure
 // (VsmMeasureDialog's onCreated below) is visible immediately (Node-Panel
 // "Verknüpfte Maßnahmen", Management-Analyse "offene Maßnahmen") without a
 // full page reload — same "prop seeds local state" convention `nodes`/
 // `connections` themselves already use for `vsm.nodes`/`vsm.connections`.
 const [measures, setMeasures] = useState<ProjectMeasuresQuery>(projectMeasures)
 // Non-null while the "Maßnahme erstellen" dialog is open — set from either
 // the Node-Panel button or the Management-Analyse Engpass-Aktion, each with
 // its own prefill context (Baustein 2: "EIN Dialog").
 const [measureDialogContext, setMeasureDialogContext] = useState<MeasureCreationContext | null>(null)
 // P8.2a (Baustein 4, "Bestehende Maßnahme verknüpfen", KAR-878/KAR-986):
 // non-null while THIS dialog is open — the target node id is all it needs
 // (unlike measureDialogContext above, no title/origin prefill, since
 // nothing is being created here).
 const [linkMeasureDialogNodeId, setLinkMeasureDialogNodeId] = useState<string | null>(null)
 // Review-Fix F3 (adversarial review, PR #353): "Soll-Zustand erstellen" may
 // only run from an Ist-Wertstrom — an absent scenario_kind is a pre-P2 row
 // (Altzeile), treated as 'current' too (same defensive posture the route's
 // own check uses).
 const isCurrentScenario = !vsm.scenario_kind || vsm.scenario_kind ==='current'
 // Wertstrom P5 (A9): the "Vergleich" toolbar toggle only makes sense once
 // there is actually something to show — a loaded compareTarget, or an
 // honest "requested but missing" state to render inside the compare view.
 // On an Ist map this is only ever true AFTER navigating via
 // vsm-scenario-nav.tsx's per-scenario "Vergleichen" link (no target is
 // auto-selected among possibly several scenarios); on a scenario map it is
 // true whenever the parent resolved (or honestly didn't).
 const canShowCompareToggle = wertstromScenariosEnabled && (compareTarget != null || compareTargetRequestedButMissing)

 // Review-Fix F4 (adversarial review, PR #356): the lazy useState
 // initializer above only derives viewMode ONCE, at mount — it never reacts
 // to a LATER compareTarget/compareTargetRequestedButMissing prop change.
 // On an Ist page, Next.js App Router keeps this SAME component instance
 // mounted across a `?compare=<id>` navigation (same route segment, only the
 // search param changes — see node_modules/next/dist/client/components/
 // layout-router.js's own createRouterCacheKey(..., true) convention: the
 // cache key for the page segment deliberately excludes search params), so
 // clicking "Vergleichen" in vsm-scenario-nav.tsx's dropdown from an
 // ALREADY-mounted Ist page never re-ran that initializer and silently
 // stayed on Canvas. This effect closes exactly that gap by watching a
 // derived compare-target IDENTITY (not the object reference, which is a
 // fresh literal every render in page.tsx) and only reacting to the actual
 // EDGE between "no target" and "target present" — never on every render
 // (which would fight a user who manually switches back to Canvas/Tabelle
 // while the same compareTarget stays loaded; the pre-existing "Auto-Sprung"
 // regression test below must stay green).
 const compareTargetIdentity = compareTarget?.id ?? (compareTargetRequestedButMissing ? '__missing__' : null)
 const previousCompareTargetIdentityRef = useRef(compareTargetIdentity)
 useEffect(() => {
 const previousIdentity = previousCompareTargetIdentityRef.current
 previousCompareTargetIdentityRef.current = compareTargetIdentity
 if (previousIdentity === compareTargetIdentity) return
 // Defense-in-depth (Flag-Disziplin, same posture as the useState
 // initializer above): never act on compareTarget/
 // compareTargetRequestedButMissing without also checking the flag.
 if (!wertstromScenariosEnabled) return
 // Scenario pages eagerly load their parent as compareTarget completely
 // independent of user intent (see the useState initializer above) — this
 // effect stays Ist-page-only too, or every scenario-page visit would jump
 // straight into Vergleich the instant its parent resolves.
 if (!isCurrentScenario) return
 if (previousIdentity === null && compareTargetIdentity !== null) setViewMode('compare')
 else if (previousIdentity !== null && compareTargetIdentity === null) setViewMode('canvas')
 }, [compareTargetIdentity, wertstromScenariosEnabled, isCurrentScenario])

 // Inline stopwatch (5.3)
 const [swRunning, setSwRunning] = useState(false)
 const [swElapsed, setSwElapsed] = useState(0)
 const [swShowPanel, setSwShowPanel] = useState(false)
 const swIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
 const swStartRef = useRef<number>(0)

 // LSC Workshop import (5.4)
 const [showLscImport, setShowLscImport] = useState(false)
 const [lscProjectId, setLscProjectId] = useState('')
 const [lscImporting, setLscImporting] = useState(false)

 const canvasRef = useRef<HTMLDivElement>(null)
 const drag = useRef<DragState | null>(null)
 // Baustein 1 (Pointer-Events-Refactor, KAR-878/KAR-986/KAR-987, P8.2b):
 // the pointerId that STARTED the currently-active `drag.current` gesture
 // (or `null` when no gesture is active). Set alongside every `drag.current
 // = {...}` assignment below (the pointer-start handlers), read by the
 // global pointermove/pointerup listener to ignore any OTHER simultaneous
 // pointer (Brief: "zweiter Finger bricht nichts... nur primary pointer
 // verarbeiten, isPrimary-Guard") — the isPrimary check at gesture-START
 // already stops a second touch from ever OVERWRITING `drag.current`, this
 // additionally stops a second touch's OWN pointermove/pointerup stream
 // from feeding into/ending the FIRST pointer's still-active gesture (a
 // stricter guarantee than the Brief's stated minimum, but a one-ref
 // addition with no extra risk).
 const activePointerIdRef = useRef<number | null>(null)
 // Baustein 3 (Undo/Redo, P8.2b): the pre-drag nodes/connections snapshot,
 // taken at Drag-START (handleNodeMouseDown) and committed at Drag-ENDE
 // (onUp's 'node' branch below, via commitHistorySnapshotIfChanged) — ONE
 // Undo-Eintrag per Drag-Geste, not one per pointermove tick (Brief:
 // "Drag-Ende" ist der Push-Punkt, nicht jede Zwischenposition).
 const dragHistorySnapshotRef = useRef<VsmHistorySnapshot | null>(null)
 const panRef = useRef(pan)
 const zoomRef = useRef(zoom)
 // Baustein 2 (KAR-878): same "live ref, read at autosave-fire-time" pattern
 // as panRef/zoomRef above — timelineOffsetY behaves like pan/zoom (a live
 // UI preference the autosave timer piggybacks along, never itself a dirty-
 // triggering change), not like shiftModel (real business data).
 const timelineOffsetYRef = useRef(timelineOffsetY)
 const connectingFromRef = useRef<string | null>(null)

 useEffect(() => { panRef.current = pan }, [pan])
 useEffect(() => { zoomRef.current = zoom }, [zoom])
 useEffect(() => { timelineOffsetYRef.current = timelineOffsetY }, [timelineOffsetY])
 useEffect(() => { connectingFromRef.current = connectingFrom }, [connectingFrom])

 // Wertstrom P0 (KAR-878): "Einpassen" (new toolbar button) + "Zurücksetzen"
 // (existing button, now calls this instead of the old hardcoded pan/zoom) —
 // both fit every node's bounding box into the canvas viewport, clamped to
 // the existing zoom range. Pure geometry lives in vsm-geometry.ts; this
 // just measures the live canvas and applies the result.
 const handleFitView = useCallback(() => {
 const rect = canvasRef.current?.getBoundingClientRect()
 const fitted = computeFitView(nodes, { width: rect?.width ?? 0, height: rect?.height ?? 0 })
 setPan({ x: fitted.x, y: fitted.y })
 setZoom(fitted.zoom)
 }, [nodes])

 // Referenzwertstrom-Falle (Kais-Auftrag, KAR-878, §9): "Beim Anklicken
 // eines Balkens soll der zugehörige Prozess im Wertstrom hervorgehoben UND
 // IN DEN SICHTBAREN BEREICH GEBRACHT werden" — selecting a node alone
 // (setSelectedNodeId) only drives the Properties-Panel/canvas-border
 // highlight, it never moves the viewport. Centers the node in the CURRENT
 // viewport at the CURRENT zoom (no re-zoom — a re-zoom on every chart/
 // Zeitlinie click would be a jarring, unrequested side effect; Kais only
 // asks for visibility, not a specific zoom level). Used by the CT-Takt-
 // Diagramm and the Zickzack-Zeitlinie's onSelectNode below — a direct
 // canvas click does NOT call this (the node is already visible there by
 // definition, an unprompted re-center on every ordinary click would be
 // unwanted UX churn).
 const panToNode = useCallback((nodeId: string) => {
 const node = nodes.find(n => n.id === nodeId)
 const rect = canvasRef.current?.getBoundingClientRect()
 if (!node || !rect) return
 const nodeCenterX = node.x + NODE_W / 2
 const nodeCenterY = node.y + getNodeH(node.type) / 2
 setPan({
 x: rect.width / 2 - nodeCenterX * zoomRef.current,
 y: rect.height / 2 - nodeCenterY * zoomRef.current,
 })
 }, [nodes])

 const selectAndRevealNode = useCallback((nodeId: string) => {
 selectNode(nodeId)
 panToNode(nodeId)
 }, [panToNode, selectNode])

 // Baustein 2 (VSM-Standard-Visualisierung, Kais-Live-Feedback 23.07.,
 // KAR-878): starts the SAME drag.current state machine 'node'/'pan' already
 // use (see onMove above) — this file "still owns canvas pan/zoom state and
 // the DOM-bound drag refs" (file header comment), the band component
 // itself never manages its own drag state.
 // K12-Fix (Referenzwertstrom-Fixrunde 2, KAR-878): guards against an active
 // connect-gesture with the EXACT same pattern handleNodeMouseDown below
 // uses — `if (connectingFrom) return` BEFORE stopPropagation, not instead
 // of a cancel. Returning early (a) never starts the timeline-offset drag,
 // and (b), by deliberately NOT calling stopPropagation, lets the mousedown
 // bubble up to handleCanvasMouseDown, whose own `if (connectingFrom) {
 // setConnectingFrom(null); return }` branch then cancels the connect-
 // gesture — the same "a new drag-starting gesture anywhere on the canvas
 // resolves an open connect-gesture" invariant handleNodeMouseDown already
 // relies on (Brief: "damit die bestehende Invariante ... auch für den
 // neuen Drag-Typ gilt"), now extended to this drag type too. Before this
 // fix, the handle's unconditional stopPropagation shielded
 // handleCanvasMouseDown from ever seeing the event, so a real
 // timeline-offset drag started INSTEAD of the established cancel, AND the
 // global mousemove handler's unconditional connectingFromRef branch kept
 // updating the connect-preview line in parallel with the band drag.
 // Baustein 1 (Pointer-Events-Refactor): React.MouseEvent -> React.PointerEvent
 // (the prop this feeds, `onOffsetHandlePointerDown`, was already misnamed
 // for a mouse-only handler — it now genuinely matches). Function NAME kept
 // as *MouseDown (Judgment-Call, siehe Abschlussbericht: ein Rename hätte
 // jeden Doc-Kommentar, der diesen Namen zitiert, mit angefasst — reines
 // Cosmetic-Risiko, vom Brief nicht verlangt ["auf Pointer Events
 // umstellen", kein Rename]).
 const handleTimelineOffsetHandleMouseDown = useCallback((e: React.PointerEvent<SVGGElement>) => {
 if (!e.isPrimary) return
 if (connectingFrom) return
 e.stopPropagation()
 e.currentTarget.setPointerCapture?.(e.pointerId)
 activePointerIdRef.current = e.pointerId
 drag.current = { type: 'timeline-offset', startScreenY: e.clientY, startOffsetY: timelineOffsetY }
 }, [timelineOffsetY, connectingFrom])

 // Keyboard-/button-operable alternative to the drag handle (Brief: "Drag
 // am Band-Griff ODER +/−-Steuerung") — used by both the band's own
 // ArrowUp/ArrowDown handling and the toolbar +/− stepper below.
 const handleTimelineOffsetStep = useCallback((deltaPx: number) => {
 setTimelineOffsetY(v => resolveTimelineOffsetY(v + deltaPx))
 }, [])

 // Fallback for a Wertstrom with no saved viewport yet (initialViewport was
 // null): Fit-View once on first render, after the canvas has been measured.
 // didAutoFitRef guards this to run at most once, even though `nodes` (a
 // legitimate dependency — see below) can change afterwards.
 const didAutoFitRef = useRef(false)
 useEffect(() => {
 if (didAutoFitRef.current || initialViewport) return
 didAutoFitRef.current = true
 handleFitView()
 }, [nodes, initialViewport, handleFitView])

 // Baustein 1 (Pointer-Events-Refactor, KAR-878/KAR-986/KAR-987, P8.2b):
 // global mousemove/mouseup -> pointermove/pointerup. PointerEvent is a
 // superset of MouseEvent (same clientX/clientY/button — every existing
 // branch body below is untouched), so this is a mechanical event-type
 // swap, NOT a behavior change for mouse. Additive for touch: the SAME
 // listeners now also fire for touch/pen pointer streams (no separate
 // touchmove/touchend wiring needed).
 //
 // Multi-touch guard (Brief: "zweiter Finger bricht nichts"): the `d`
 // (active-gesture) branch is additionally gated on `e.pointerId ===
 // activePointerIdRef.current` — a second, non-primary pointer's own
 // pointermove stream (its pointerdown was already ignored at the
 // isPrimary-guarded start handlers, so it never became `d` in the first
 // place) can therefore never inject noise into an already-active drag
 // from the FIRST pointer. Deliberately NOT applied to the
 // `connectingFromRef` branch below — that is live cursor/hover tracking
 // for the connect-gesture preview line, not itself a captured drag, and
 // today reacts to ANY mousemove regardless of "which button/pointer" —
 // preserved unchanged.
 useEffect(() => {
 function onMove(e: PointerEvent) {
 const d = drag.current
 if (d && e.pointerId === activePointerIdRef.current) {
 if (d.type ==='pan') {
 setPan({ x: d.startPan.x + e.clientX - d.startScreenX, y: d.startPan.y + e.clientY - d.startScreenY })
 } else if (d.type ==='node') {
 const dx = (e.clientX - d.startScreenX) / zoomRef.current
 const dy = (e.clientY - d.startScreenY) / zoomRef.current
 setNodes(prev => prev.map(n => n.id === d.nodeId ? { ...n, x: d.startNodeX + dx, y: d.startNodeY + dy } : n))
 setIsDirty(true)
 } else if (d.type ==='palette') {
 setPaletteDragPos({ x: e.clientX, y: e.clientY })
 } else if (d.type ==='timeline-offset') {
 // Baustein 2 (KAR-878): "nur nach oben/unten verschiebbar" — a single
 // vertical delta, same zoom-aware screen->world conversion as the
 // 'node' branch above. resolveTimelineOffsetY clamps so the band's
 // upper lane can never crash into the node row above it.
 const dy = (e.clientY - d.startScreenY) / zoomRef.current
 setTimelineOffsetY(resolveTimelineOffsetY(d.startOffsetY + dy))
 }
 }
 if (connectingFromRef.current) {
 const rect = canvasRef.current?.getBoundingClientRect()
 if (rect) {
 setMouseWorldPos({
 x: (e.clientX - rect.left - panRef.current.x) / zoomRef.current,
 y: (e.clientY - rect.top - panRef.current.y) / zoomRef.current,
 })
 }
 }
 }
 function onUp(e: PointerEvent) {
 // Same multi-touch guard as onMove above — a second pointer's own
 // pointerup (or pointercancel, see below) must never end/consume the
 // FIRST pointer's still-active gesture. When no gesture is active at
 // all, `activePointerIdRef.current` is `null`, which no real
 // `e.pointerId` ever equals — this branch is then a no-op, exactly
 // matching the pre-existing behavior (drag.current was already null).
 if (e.pointerId !== activePointerIdRef.current) return
 const d = drag.current
 drag.current = null
 activePointerIdRef.current = null
 if (d?.type ==='palette') {
 const rect = canvasRef.current?.getBoundingClientRect()
 if (rect && e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom) {
 const x = (e.clientX - rect.left - panRef.current.x) / zoomRef.current - NODE_W / 2
 const y = (e.clientY - rect.top - panRef.current.y) / zoomRef.current - getNodeH(d.nodeType) / 2
 // addNode itself pushes a pre-add history entry (pushHistoryBeforeChange)
 // — no separate Drag-Ende-Push needed for a palette drop.
 addNode(d.nodeType, Math.round(x / 20) * 20, Math.round(y / 20) * 20)
 }
 setPaletteDragType(null)
 }
 // Baustein 3 (Undo/Redo, P8.2b): Drag-Ende für einen Node-Drag — commits
 // the pre-drag snapshot ONLY if the drag actually moved something (no-op
 // otherwise, e.g. a click-without-movement that still goes through
 // pointerdown/pointerup). Cleared unconditionally afterwards so a LATER,
 // unrelated drag never accidentally reuses a stale snapshot.
 if (d?.type ==='node') {
 commitHistorySnapshotIfChanged(dragHistorySnapshotRef.current)
 }
 dragHistorySnapshotRef.current = null
 }
 document.addEventListener('pointermove', onMove)
 document.addEventListener('pointerup', onUp)
 // Judgment-Call (über Brief-Minimum hinaus, siehe Abschlussbericht):
 // pointercancel (touch-Geste durch das System abgebrochen, z. B.
 // eingehender Anruf, Browser-Gesten-Übernahme) räumt die Geste über
 // denselben onUp-Pfad sauber auf, statt `drag.current` hängen zu lassen.
 // jsdom feuert `pointercancel` in keinem bestehenden Test — kein
 // Regressionsrisiko für die Suite.
 document.addEventListener('pointercancel', onUp)
 return () => {
 document.removeEventListener('pointermove', onMove)
 document.removeEventListener('pointerup', onUp)
 document.removeEventListener('pointercancel', onUp)
 }
 }, []) // empty deps ok - uses refs

 // Keyboard events
 useEffect(() => {
 function onKey(e: KeyboardEvent) {
 const tag = (e.target as HTMLElement).tagName
 if (tag ==='INPUT'|| tag ==='TEXTAREA'|| tag ==='SELECT') return
 if (e.key ==='Delete'|| e.key ==='Backspace') {
 if (selectedNodeId) {
 deleteNode(selectedNodeId)
 // P8.2b-Review-Fix K2b (KAR-987, tastatur-a11y-2 Substanz-Teil, Brief-
 // Pflicht "kein Fokus-Verlust ins Nichts"): Delete/Backspace ist per
 // Definition ein Tastatur-Event — der gelöschte Node kann also den
 // DOM-Fokus gehalten haben (per Tab erreicht). Ohne dies fiele der
 // Fokus beim Entfernen des Elements ins Leere (document.body). Nur bei
 // Flag AN (canvasRef trägt nur dann tabIndex={-1}, s. u.) — Flag AUS
 // bleibt exakt wie main (dort war ein Node nie fokussierbar, dieser
 // Fall also nie erreichbar).
 if (wertstromUxV2Enabled) canvasRef.current?.focus()
 }
 }
 if (e.key ==='Escape') {
 setConnectingFrom(null)
 // P8.2a (Baustein 3): same escape-clears-selection convention as the
 // node selection above — deliberately NOT wired to Delete/Backspace
 // (connection deletion via keyboard is out of this Baustein's scope,
 // "nur flowControl editierbar machen" — leaving it unwired means the
 // pre-existing Delete/Backspace behavior for nodes is untouched).
 // K2-Fix: selectNode(null) clears BOTH selectedNodeId/selectedConnectionId.
 selectNode(null)
 }
 // Baustein 3 (Undo/Redo, KAR-878/KAR-986/KAR-987, P8.2b): Ctrl+Z /
 // Ctrl+Shift+Z (+ Ctrl+Y Alias), hinter wertstromUxV2 (Brief) — undo()/
 // redo() selbst no-open bereits hinter dem Flag, aber e.preventDefault()
 // nur aufrufen wenn wir wirklich etwas tun, damit ein Flag-AUS-Tab
 // niemals irgendein natives Browser-Verhalten unterdrückt (Flag-Doktrin:
 // "exakt wie main" bei Flag aus). metaKey zusätzlich zu ctrlKey (Cmd auf
 // macOS) — Judgment-Call über den Brief-Wortlaut ("Ctrl+Z") hinaus, s.
 // Abschlussbericht. Der bestehende INPUT/TEXTAREA/SELECT-Tag-Guard oben
 // gilt automatisch auch hier (early return VOR diesem Block) — ein
 // Ctrl+Z im Namensfeld bleibt natives Text-Undo, kein Node-Undo.
 if (wertstromUxV2Enabled && (e.ctrlKey || e.metaKey) && e.key.toLowerCase() ==='z') {
 e.preventDefault()
 if (e.shiftKey) redo()
 else undo()
 }
 if (wertstromUxV2Enabled && (e.ctrlKey || e.metaKey) && e.key.toLowerCase() ==='y') {
 e.preventDefault()
 redo()
 }
 // Baustein 4 (Copy/Paste, KAR-878/KAR-986/KAR-987, P8.2b): Ctrl+C/Ctrl+V,
 // hinter wertstromUxV2 (Judgment-Call, siehe copySelectedNode/pasteNode
 // eigene Docs). Selber Tag-Guard wie oben: in einem Textfeld bleibt
 // Ctrl+C/V natives Text-Kopieren/-Einfügen — der Guard returned bereits
 // vor diesem Block. e.preventDefault() nur bei tatsächlicher Wirkung
 // (kein selektierter Node bzw. leere Zwischenablage lässt den Browser
 // sein eigenes, hier ohnehin folgenloses Ctrl+C/V machen).
 if (wertstromUxV2Enabled && (e.ctrlKey || e.metaKey) && e.key.toLowerCase() ==='c' && selectedNodeId) {
 e.preventDefault()
 copySelectedNode()
 }
 if (wertstromUxV2Enabled && (e.ctrlKey || e.metaKey) && e.key.toLowerCase() ==='v' && clipboardNodeRef.current) {
 e.preventDefault()
 pasteNode()
 }
 }
 document.addEventListener('keydown', onKey)
 return () => document.removeEventListener('keydown', onKey)
 }, [selectedNodeId, wertstromUxV2Enabled])

 // Wheel zoom
 const handleWheel = useCallback((e: WheelEvent) => {
 e.preventDefault()
 const delta = e.deltaY > 0 ? 0.9 : 1.1
 const rect = canvasRef.current?.getBoundingClientRect()
 if (!rect) return
 const mouseX = e.clientX - rect.left
 const mouseY = e.clientY - rect.top
 setZoom(prev => {
 const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, prev * delta))
 setPan(p => ({
 x: mouseX - (mouseX - p.x) * (next / prev),
 y: mouseY - (mouseY - p.y) * (next / prev),
 }))
 return next
 })
 }, [])

 useEffect(() => {
 const el = canvasRef.current
 if (!el) return
 el.addEventListener('wheel', handleWheel, { passive: false })
 return () => el.removeEventListener('wheel', handleWheel)
 }, [handleWheel])

 // P8.2b-Review-Fix K3 (KAR-987, KAR-662-Lehre, pointer-touch-action-
 // inheritance major): non-passive touchmove/preventDefault-Fallback, exakt
 // wie components/notes/note-canvas.tsx (dort war selbst DIREKT auf dem SVG
 // gesetztes touch-action:none auf iOS Safari für Drag-Gesten unzuverlässig,
 // s. Commits 7e41f7b/0d7c72e) — NUR auf dem Canvas-Div, die Seite
 // außerhalb (inkl. Palette links) bleibt normal scrollbar.
 useEffect(() => {
 const el = canvasRef.current
 if (!el) return
 const preventTouchScroll = (e: TouchEvent) => e.preventDefault()
 el.addEventListener('touchmove', preventTouchScroll, { passive: false })
 return () => el.removeEventListener('touchmove', preventTouchScroll)
 }, [])

 // Baustein 3 (Undo/Redo, KAR-878/KAR-986/KAR-987, P8.2b): captures the
 // CURRENT nodes/connections (via the refs — see their own doc comments for
 // why refs, not the state variables directly) as a pre-operation snapshot.
 function captureHistorySnapshot(): VsmHistorySnapshot {
 return { nodes: nodesRef.current, connections: connectionsRef.current }
 }

 function appendHistoryEntry(snapshot: VsmHistorySnapshot) {
 setHistoryPast(prev => {
 const next = [...prev, snapshot]
 return next.length > HISTORY_LIMIT ? next.slice(next.length - HISTORY_LIMIT) : next
 })
 // Brief/Standard-Undo-Redo-Konvention: eine neue Aktion nach einem Undo
 // verwirft die "Zukunft", die man gerade rückgängig gemacht hatte.
 setHistoryFuture([])
 }

 // For call sites that KNOW, at the moment of calling, that a state change
 // is about to happen immediately after (Add/Delete/Paste/flowControl/
 // isPps) — always pushes, no comparison needed (comparing against
 // nodesRef.current HERE would wrongly see "unchanged", since the mutation
 // hasn't happened yet).
 function pushHistoryBeforeChange() {
 if (!wertstromUxV2Enabled) return
 appendHistoryEntry(captureHistorySnapshot())
 }

 // For call sites that captured a snapshot at some EARLIER point in time
 // (Drag-Start, Panel-Feld-Fokus) and only now — at Drag-Ende/Blur — learn
 // whether anything actually changed since. Referential comparison is
 // sufficient: setNodes/setConnections always produce a NEW array on a real
 // change, so an unchanged snapshot is still the exact same reference —
 // prevents an empty Undo-Eintrag for e.g. a Feld-Fokus ohne Edit.
 function commitHistorySnapshotIfChanged(preSnapshot: VsmHistorySnapshot | null) {
 if (!wertstromUxV2Enabled) return
 if (!preSnapshot) return
 if (preSnapshot.nodes === nodesRef.current && preSnapshot.connections === connectionsRef.current) return
 appendHistoryEntry(preSnapshot)
 }

 // Baustein 3: Ctrl+Z. Reads exclusively through historyPastRef/
 // historyFutureRef (see their own doc comment) — safe to call from
 // anywhere (keyboard effect, toolbar button) regardless of closure age.
 // Selektion nach Undo (Brief-Pflicht-Entscheidung): selectNode(null) — der
 // einfachste, immer korrekte Fall (kein Rätselraten, welcher Node bei einem
 // Mehrfach-Node-Delete-Undo oder einer Connections-Änderung "der
 // betroffene" gewesen wäre).
 function undo() {
 if (!wertstromUxV2Enabled) return
 const past = historyPastRef.current
 if (past.length === 0) return
 const entry = past[past.length - 1]
 const currentSnapshot = captureHistorySnapshot()
 setHistoryPast(past.slice(0, -1))
 setHistoryFuture(prev => {
 const next = [...prev, currentSnapshot]
 return next.length > HISTORY_LIMIT ? next.slice(next.length - HISTORY_LIMIT) : next
 })
 setNodes(entry.nodes)
 setConnections(entry.connections)
 // Brief-Pflicht-Klärung (a): Undo NACH einem Save ⇒ isDirty wieder true —
 // der nächste Save persistiert den (jetzt wiederhergestellten) alten
 // Stand. Korrekt und gewollt, kein Server-Revert-Anspruch.
 setIsDirty(true)
 selectNode(null)
 }

 // Baustein 3: Ctrl+Shift+Z / Ctrl+Y.
 function redo() {
 if (!wertstromUxV2Enabled) return
 const future = historyFutureRef.current
 if (future.length === 0) return
 const entry = future[future.length - 1]
 const currentSnapshot = captureHistorySnapshot()
 setHistoryFuture(future.slice(0, -1))
 setHistoryPast(prev => {
 const next = [...prev, currentSnapshot]
 return next.length > HISTORY_LIMIT ? next.slice(next.length - HISTORY_LIMIT) : next
 })
 setNodes(entry.nodes)
 setConnections(entry.connections)
 setIsDirty(true)
 selectNode(null)
 }

 // Baustein 3 (Undo/Redo, P8.2b): Feld-Commit im Properties-Panel — siehe
 // den Panel-Root-Containers eigenen Kommentar für die volle Begründung.
 // Ref statt State: kein Re-Render nötig für einen reinen Zwischenspeicher.
 const panelFieldSnapshotRef = useRef<VsmHistorySnapshot | null>(null)
 // P8.2b-Review-Fix K2b (KAR-987): DOM-Ref auf denselben gemeinsamen
 // Panel-Root-Container, den handlePanelFocusCapture/-BlurCapture oben schon
 // instrumentieren (Attribut unten am Container-Div) — der Focus-Jump-Effect
 // bei handleNodeKeyDown sucht darin das erste fokussierbare Element.
 const panelContainerRef = useRef<HTMLDivElement>(null)
 // Ref statt State (kein Re-Render nötig) — von handleNodeKeyDown VOR
 // selectNode() gesetzt, vom Focus-Jump-Effect direkt danach gelesen+
 // zurückgesetzt. Siehe des Effects eigene Doc-Kommentar für die volle
 // Begründung (nur Tastatur-Pfad, Maus-Klick setzt es nie).
 const focusPanelOnSelectRef = useRef(false)
 function handlePanelFocusCapture() {
 if (panelFieldSnapshotRef.current === null) {
 panelFieldSnapshotRef.current = captureHistorySnapshot()
 }
 }
 function handlePanelBlurCapture(e: React.FocusEvent<HTMLDivElement>) {
 // Fokus wechselt nur INNERHALB des Panels (z. B. Tab zwischen zwei
 // Feldern) — Pending-Snapshot bleibt bestehen, bündelt beide Änderungen
 // zu einem Undo-Schritt, noch nicht committen.
 if (e.currentTarget.contains(e.relatedTarget as Node | null)) return
 commitHistorySnapshotIfChanged(panelFieldSnapshotRef.current)
 panelFieldSnapshotRef.current = null
 }

 // Baustein 4 (Copy/Paste, KAR-878/KAR-986/KAR-987, P8.2b): interner
 // Clipboard-State — Brief: "KEIN navigator.clipboard-Zwang" (vermeidet
 // jeden Permission-Prompt; ein Ref reicht, kein Re-Render nötig für einen
 // reinen Zwischenspeicher). Hinter wertstromUxV2Enabled (Judgment-Call,
 // Brief nennt Baustein 4 nicht explizit "hinter Flag" wie 3/5, aber Paste
 // ist laut Brief selbst eine Undo-fähige Operation, s. u. — konsistent mit
 // Baustein 3s Flag-Gate, siehe Abschlussbericht).
 const clipboardNodeRef = useRef<VsmNode | null>(null)

 // Liest über nodesRef (nicht `nodes` direkt) — copySelectedNode/pasteNode
 // werden vom globalen Keyboard-Effect aufgerufen, dessen Deps nur
 // [selectedNodeId, wertstromUxV2Enabled] sind; ein reiner nodes-Wechsel
 // (z. B. Drag, ohne Selektionsänderung) würde eine `nodes`-Closure sonst
 // stale lassen.
 function copySelectedNode() {
 if (!wertstromUxV2Enabled) return
 if (!selectedNodeId) return
 const node = nodesRef.current.find(n => n.id === selectedNodeId)
 if (!node) return
 clipboardNodeRef.current = node
 }

 function pasteNode() {
 if (!wertstromUxV2Enabled) return
 const source = clipboardNodeRef.current
 if (!source) return
 pushHistoryBeforeChange()
 const newNode: VsmNode = {
 ...source,
 id: crypto.randomUUID(),
 x: source.x + 24,
 y: source.y + 24,
 name: `${source.name} ${t('wertstrom.editor.node.copySuffix', '(Kopie)')}`,
 // Brief: measureRefs NICHT mitkopiert (Maßnahmen-Verknüpfung gehört zum
 // Original) — explizit auf undefined überschrieben (nicht per
 // Destrukturierung weggelassen), damit die Absicht im Code sichtbar
 // bleibt. isPps NICHT mitkopiert (nur EINE Produktionssteuerung kann
 // sinnvoll existieren — Judgment-Call, s. Abschlussbericht). Kanten
 // werden nicht mitkopiert (Einzel-Node-Scope, Brief) — automatisch
 // erfüllt: pasteNode fasst `connections` nie an.
 measureRefs: undefined,
 isPps: undefined,
 }
 setNodes(prev => [...prev, newNode])
 // P8.2b-Review-Fix K5 (KAR-987, CP-1 major): clipboardNodeRef zeigt jetzt
 // auf die FRISCH eingefügte Kopie (nicht mehr unverändert auf das
 // Original) — wiederholtes Ctrl+V ohne erneutes Ctrl+C versetzt dadurch
 // JEDE weitere Kopie kumulativ von der zuletzt eingefügten (+24/+24 ab
 // dort), statt alle Kopien deckungsgleich auf demselben Punkt zu stapeln
 // (vorher: source blieb immer das Original, jede Kopie landete exakt bei
 // source.x+24/y+24). Namens-Folge ("X (Kopie) (Kopie)") ist dabei
 // akzeptiert (Brief) — source.name trägt ab der zweiten Kopie bereits den
 // Suffix.
 clipboardNodeRef.current = newNode
 selectNode(newNode.id)
 setIsDirty(true)
 }

 const addNode = useCallback((type: NodeType, x: number, y: number) => {
 pushHistoryBeforeChange()
 const newNode: VsmNode = {
 id: crypto.randomUUID(),
 type,
 x,
 y,
 name: t(NODE_TYPE_LABEL_KEYS[type], NODE_CONFIG[type].label),
 isValueAdded: type ==='process',
 }
 setNodes(prev => [...prev, newNode])
 // K2-Fix (P8.2a-Review Fix-Runde): selectNode also clears any stale
 // selectedConnectionId (this used to be a raw setSelectedNodeId call that
 // left a previously-selected connection's highlight/panel live).
 selectNode(newNode.id)
 setIsDirty(true)
 }, [selectNode, t])

 function deleteNode(id: string) {
 pushHistoryBeforeChange()
 setNodes(prev => prev.filter(n => n.id !== id))
 setConnections(prev => prev.filter(c => c.fromNodeId !== id && c.toNodeId !== id))
 // K2-Fix (P8.2a-Review Fix-Runde): selectNode(null) clears BOTH fields —
 // this used to only clear selectedNodeId, so a selectedConnectionId set
 // via the Zeitlinie/CT-Diagramm before this delete could resurface the
 // Verbindungs-Panel for an unrelated, still-existing connection.
 selectNode(null)
 setIsDirty(true)
 }

 // Baustein 2 (Canvas-Tastatur + A11y, KAR-878/KAR-986/KAR-987, P8.2b):
 // the arrow-key move step. Same core update as the pointermove drag branch
 // above (`setNodes` + `setIsDirty`), just applied as one fixed delta per
 // keypress instead of a continuous screen-delta — no drag.current/start-
 // position bookkeeping needed for a discrete step.
 const NODE_KEYBOARD_MOVE_STEP = 10
 const NODE_KEYBOARD_MOVE_STEP_FAST = 100
 function moveNodeBy(id: string, dx: number, dy: number, pushHistory = true) {
 // Baustein 3 (Undo/Redo, P8.2b): jeder Tastendruck ist bereits eine
 // abgeschlossene, diskrete Bewegung (kein kontinuierliches Tippen wie ein
 // Textfeld) — sofortiger Push, kein Debounce/Blur-Warten nötig. No-op
 // hinter dem Flag (pushHistoryBeforeChange selbst), also unabhängig vom
 // Flag sicher aufrufbar (Baustein 2 selbst ist unconditional).
 // P8.2b-Review-Fix K4 (KAR-987, ur-5 major): `pushHistory` (Default true,
 // von handleNodeKeyDown mit `!e.repeat` aufgerufen) — eine GEHALTENE
 // Pfeiltaste feuert per Browser-Auto-Repeat 40-60+ keydown-Events in ~2s,
 // jeder davon rief bisher pushHistoryBeforeChange() auf und flutete den
 // HISTORY_LIMIT=50-Stack, wodurch echte, ältere Undo-Schritte (Adds/
 // Deletes/Edits) stillschweigend eviktiert wurden. Die Bewegung SELBST
 // bleibt bei jedem Tick erlaubt (nur der Push wird übersprungen) — eine
 // gehaltene Taste bleibt dadurch EIN Undo-Eintrag für die ganze
 // Druck-Session (der ERSTE, nicht-repeat Tick pusht den Vorher-Zustand).
 if (pushHistory) pushHistoryBeforeChange()
 setNodes(prev => prev.map(n => n.id === id ? { ...n, x: n.x + dx, y: n.y + dy } : n))
 setIsDirty(true)
 }

 // Baustein 2 (Canvas-Tastatur + A11y): per-node onKeyDown, wired directly on
 // the node's own <div> (not the global document keydown effect above) — this
 // is deliberate: arrow-key move must act on whichever node currently HOLDS
 // BROWSER FOCUS (Brief: "bewegt den fokussierten/selektierten Node"), which
 // may be reached by Tab alone, before any Enter/Space selection. Reading
 // `node` from this closure (the node.map() callback) gives that for free,
 // with no separate focused-node-id state to keep in sync.
 // Escape/Delete/Backspace are deliberately NOT handled here (no
 // stopPropagation on those keys) — they keep bubbling to the existing
 // global document keydown handler below, which already implements them
 // against `selectedNodeId` ("EXAKT der bestehenden Lösch-Absicherung",
 // Brief) — duplicating that logic here would risk a second, diverging
 // deletion path.
 function handleNodeKeyDown(e: React.KeyboardEvent<HTMLDivElement>, node: VsmNode) {
 if (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
 e.preventDefault()
 const step = e.shiftKey ? NODE_KEYBOARD_MOVE_STEP_FAST : NODE_KEYBOARD_MOVE_STEP
 const dx = e.key === 'ArrowLeft' ? -step : e.key === 'ArrowRight' ? step : 0
 const dy = e.key === 'ArrowUp' ? -step : e.key === 'ArrowDown' ? step : 0
 // P8.2b-Review-Fix K4 (KAR-987, ur-5 major): `!e.repeat` — nur der ERSTE
 // (nicht-auto-repeat) Tick einer gehaltenen Pfeiltaste pusht einen
 // History-Eintrag, s. moveNodeBy's eigene Doc.
 moveNodeBy(node.id, dx, dy, !e.repeat)
 return
 }
 if (e.key === 'Enter' || e.key === ' ') {
 // Brief: "Enter/Space selektiert (öffnet Panel wie Klick)" — same
 // selectNode call handleNodeMouseDown makes on a real click, minus the
 // drag-start bookkeeping (a keyboard activation never starts a drag).
 e.preventDefault()
 // P8.2b-Review-Fix K2b (KAR-987, tastatur-a11y-2 Substanz-Teil): markiert
 // diese Selektion als TASTATUR-ausgelöst — der Focus-Jump-Effect (s.
 // focusPanelOnSelectRef eigene Doc) springt NUR bei gesetztem Ref ins
 // Panel. handleNodeMouseDown (Maus-Klick) setzt dieses Ref nie, bleibt
 // also unverändert.
 focusPanelOnSelectRef.current = true
 selectNode(node.id)
 }
 }

 // P8.2b-Review-Fix K2b (KAR-987, tastatur-a11y-2 Substanz-Teil, Brief-Pflicht
 // "kein Fokus-Verlust ins Nichts"): nach einer PER TASTATUR ausgelösten
 // Node-Selektion (Enter/Space, s. o.) springt der Fokus auf das erste
 // fokussierbare Element des gerade geöffneten Properties-Panels — ohne
 // dies bleibt der DOM-Fokus auf dem (jetzt möglicherweise nicht mehr
 // sichtbar hervorgehobenen) Node-Div stehen und ein Tastatur-Nutzer müsste
 // durch alle übrigen Nodes tabben, um das Feld überhaupt zu erreichen.
 // Best-effort-Selektor deckt beide Panel-Varianten + das Verbindungs-Panel
 // ab (derselbe gemeinsame Container wie handlePanelFocusCapture/
 // -BlurCapture); ohne Treffer (z. B. ein künftiger reiner Anzeige-Zustand)
 // fällt es auf den Container selbst zurück, damit der Fokus nie ins Leere
 // fällt. Maus-Klick-Verhalten bleibt UNVERÄNDERT (focusPanelOnSelectRef wird
 // ausschließlich von handleNodeKeyDown gesetzt, nie von
 // handleNodeMouseDown) — kein neuer Fokus-Diebstahl für Maus-Nutzer.
 // TODO (P8.2d): der verbleibende Tab-Wall (jeder Node ein eigener Tab-Stop;
 // reine Tab-Navigation ohne Enter/Space muss noch durch ALLE übrigen Nodes)
 // ist für diesen PR akzeptiert (Brief) — braucht roving-tabindex/ein
 // Composite-Widget-Pattern, hier bewusst nicht gebaut.
 useEffect(() => {
 if (!focusPanelOnSelectRef.current) return
 focusPanelOnSelectRef.current = false
 if (!wertstromUxV2Enabled) return
 if (!selectedNodeId) return
 const panel = panelContainerRef.current
 if (!panel) return
 const target = panel.querySelector<HTMLElement>('input, textarea, select, button, [href], [tabindex]:not([tabindex="-1"])')
 ;(target ?? panel).focus()
 }, [selectedNodeId, wertstromUxV2Enabled])

 function updateNode(id: string, patch: Partial<VsmNode>) {
 // Baustein 3 (Undo/Redo, P8.2b): isPps ist ein diskreter Checkbox-Zustand
 // (kein "Tippen") — Brief nennt "flowControl/isPps-Änderung" EXPLIZIT als
 // eigenen, SOFORTIGEN Push-Punkt, getrennt von "Feld-Commit im Panel"
 // (Text-/Zahlenfelder, die stattdessen über den Fokus-Verlassen-Mechanismus
 // weiter unten laufen — s. dessen eigenen Kommentar). Push VOR der
 // Änderung, damit der Snapshot den Vorher-Zustand trägt.
 if (patch.isPps !== undefined) pushHistoryBeforeChange()
 setNodes(prev => prev.map(n => {
 if (n.id !== id) return n
 // QVS-P3 (KAR-972): a patch touching an imported node's editable fields
 // flips that field's status to 'modified' (fieldStatus aus qafSource-
 // Lineage, ux-flow.md §8) — undefined on a manually created node (no
 // fieldStatus to begin with), so the spread below adds nothing for it.
 // Review-Fix 1 (PR #337): a key only counts as "changed" when its VALUE
 // actually differs (hasFieldValueChanged) — Object.keys(patch) alone
 // fired on every onChange event, including a net-zero resubmit of the
 // same value, which used to flip fieldStatus to 'modified' permanently.
 const changedKeys = ((Object.keys(patch) as Array<keyof VsmNode>).filter(isEditableFieldKey) as VsmNodeEditableFieldKey[])
 .filter(key => hasFieldValueChanged(key, n[key], patch[key]))
 const fieldStatus = deriveFieldStatusOnEdit(n, changedKeys)
 return { ...n, ...patch, ...(fieldStatus !== undefined ? { fieldStatus } : {}) }
 }))
 setIsDirty(true)
 }

 /** P8.2a (Baustein 3, "Push/Pull/Kanban-Kantensemantik", KAR-878/KAR-986):
 * the FIRST post-creation edit path for any VsmConnection field ever built
 * in this editor — before this Baustein, a connection's fields (kind/
 * frequency, and even the much older transportTimeSec/batchSize) were only
 * ever set AT connect-time, never editable afterwards through the UI at
 * all. Deliberately minimal (mirrors updateNode's plain merge-patch shape,
 * no fieldStatus concept — that is a QAF-import-only mechanism for NODES,
 * VsmConnection has no such lineage). */
 function updateConnection(id: string, patch: Partial<VsmConnection>) {
 // Baustein 3 (Undo/Redo, P8.2b): flowControl ist eine Select-Auswahl (ein
 // Klick = eine abgeschlossene Entscheidung, kein "Tippen") — sofortiger
 // Push, gleiche Begründung wie updateNode's isPps-Zweig oben.
 if (patch.flowControl !== undefined) pushHistoryBeforeChange()
 setConnections(prev => prev.map(c => (c.id === id ? { ...c, ...patch } : c)))
 setIsDirty(true)
 }

 /** vaClass 4-way selector onChange (ux-flow.md §8) — sets vaClass AND
 * derives isValueAdded from it in the SAME patch (architecture.md §3.1
 * "isValueAdded bleibt abgeleitet = vaClass==='va'"), so the canvas badge/
 * timeline metrics (vsm-metrics.ts, still isValueAdded-only) stay in sync
 * without their own vaClass-awareness. */
 function updateVaClass(id: string, vaClass: VaClass) {
 updateNode(id, { vaClass, isValueAdded: deriveIsValueAddedFromVaClass(vaClass) })
 }

 // Reset inline stopwatch when selected node changes
 useEffect(() => {
 setSwRunning(false)
 setSwElapsed(0)
 setSwShowPanel(false)
 if (swIntervalRef.current) { clearInterval(swIntervalRef.current); swIntervalRef.current = null }
 }, [selectedNodeId])

 function swStart() {
 swStartRef.current = Date.now() - swElapsed * 1000
 setSwRunning(true)
 swIntervalRef.current = setInterval(() => {
 setSwElapsed((Date.now() - swStartRef.current) / 1000)
 }, 100)
 }
 function swStop() {
 setSwRunning(false)
 if (swIntervalRef.current) { clearInterval(swIntervalRef.current); swIntervalRef.current = null }
 }
 function swReset() {
 swStop()
 setSwElapsed(0)
 }

 async function handleLscImport() {
 if (!lscProjectId) return
 setLscImporting(true)
 try {
 const supabase = createClient()
 const { data: steps } = await supabase
 .from('process_steps')
 .select('id, station_name, planned_cycle_time_sec, sort_order')
 .eq('project_id', lscProjectId)
 .order('sort_order')

 if (!steps || steps.length === 0) { setLscImporting(false); return }

 // Get avg measured CT for each step
 const stepIds = steps.map(s => s.id)
 const { data: measurements } = await supabase
 .from('cycle_measurements')
 .select('process_step_id, cycle_time_sec')
 .in('process_step_id', stepIds)
 .eq('is_outlier', false)

 const avgByStep = new Map<string, number>()
 if (measurements) {
 const grouped = new Map<string, number[]>()
 for (const m of measurements) {
 const arr = grouped.get(m.process_step_id) ?? []
 arr.push(m.cycle_time_sec)
 grouped.set(m.process_step_id, arr)
 }
 grouped.forEach((times, id) => {
 avgByStep.set(id, times.reduce((s, t) => s + t, 0) / times.length)
 })
 }

 // Place nodes in a horizontal row (offset from existing nodes)
 const maxX = nodes.reduce((m, n) => Math.max(m, n.x + 160), 100)
 const startX = maxX + 80
 const startY = 200

 const newNodes: VsmNode[] = steps.map((step, i) => ({
 id: crypto.randomUUID(),
 type:'process'as NodeType,
 x: startX + i * 220,
 y: startY,
 name: step.station_name,
 cycleTimeSec: avgByStep.has(step.id)
 ? Math.round((avgByStep.get(step.id)!) * 100) / 100
 : step.planned_cycle_time_sec ?? undefined,
 // Wertstrom P2 (A12, Capability-Matrix Section C "LSC/Stoppuhr-Übernahme
 // — plus Provenance-Kennzeichnung 'gemessen'"): a real Ø stopwatch
 // measurement is 'measured'; falling back to the planned_cycle_time_sec
 // field (no measurement yet for this step) is 'planned', not 'measured'.
 // undefined (neither available) leaves provenance unset, same as today.
 // Review-Fix F7: no downgrade-guard needed here (unlike the inline
 // stopwatch/Picker below) — every node in this batch is BRAND NEW
 // (crypto.randomUUID() above), never an overwrite of an existing node's
 // provenance, so there is nothing to silently downgrade.
 provenance: avgByStep.has(step.id) ? 'measured' : step.planned_cycle_time_sec != null ? 'planned' : undefined,
 isValueAdded: true,
 }))

 const newConnections: VsmConnection[] = newNodes.slice(0, -1).map((node, i) => ({
 id: crypto.randomUUID(),
 fromNodeId: node.id,
 toNodeId: newNodes[i + 1].id,
 }))

 // P8.2b-Review-Fix K1 (ur-4, KAR-987): ONE push for the whole bulk import
 // (one Undo-Schritt entfernt alle importierten Nodes+Kanten) — same reasoning
 // as applyExcelImport below.
 pushHistoryBeforeChange()
 setNodes(prev => [...prev, ...newNodes])
 setConnections(prev => [...prev, ...newConnections])
 setIsDirty(true)
 setShowLscImport(false)
 setLscProjectId('')
 } finally {
 setLscImporting(false)
 }
 }

 function handleOutputPortClick(e: React.MouseEvent, nodeId: string) {
 e.stopPropagation()
 setConnectingFrom(nodeId)
 // Wertstrom P2 (A7): always start a new connect gesture at the default
 // kind — see connectingKind's own state comment for why this resets here
 // rather than persisting the previous connection's choice.
 setConnectingKind('materialFlow')
 setConnectingFrequency('')
 // P8.2a (Baustein 3): same reset-per-gesture discipline as kind/frequency.
 setConnectingFlowControl(undefined)
 }

 function handleInputPortClick(e: React.MouseEvent, nodeId: string) {
 e.stopPropagation()
 if (connectingFrom && connectingFrom !== nodeId) {
 if (!connections.some(c => c.fromNodeId === connectingFrom && c.toNodeId === nodeId)) {
 // P8.2b-Review-Fix K1 (ur-1, KAR-987): the connect-gesture creates a new
 // VsmConnection but was the only Add-type mutation with no history push —
 // Redo (an unconditional full-array overwrite, see redo()'s own doc
 // comment) would silently discard a connection drawn after an Undo. Push
 // BEFORE the mutation, same "known imminent change" pattern as
 // addNode/deleteNode/pasteNode above.
 pushHistoryBeforeChange()
 setConnections(prev => [...prev, {
 id: crypto.randomUUID(),
 fromNodeId: connectingFrom,
 toNodeId: nodeId,
 // Wertstrom P2 (A7): `kind`/`frequency` omitted entirely for the
 // default (materialFlow) — matches "fehlendes Feld = heutige Semantik",
 // never an explicit but redundant `kind: 'materialFlow'` literal.
 ...(connectingKind === 'information' ? { kind: connectingKind } : {}),
 ...(connectingKind === 'information' && connectingFrequency.trim() ? { frequency: connectingFrequency.trim() } : {}),
 // P8.2a (Baustein 3): flowControl only ever sent for a materialFlow
 // gesture (the picker itself only renders in that case) — K3-Muster
 // sparse write, same shape as the two lines above.
 ...(connectingKind !== 'information' && connectingFlowControl ? { flowControl: connectingFlowControl } : {}),
 }])
 setIsDirty(true)
 }
 setConnectingFrom(null)
 }
 }

 // Baustein 1 (Pointer-Events-Refactor, KAR-878/KAR-986/KAR-987, P8.2b):
 // React.MouseEvent -> React.PointerEvent (mechanical, function name kept —
 // see handleTimelineOffsetHandleMouseDown's doc comment for the naming
 // Judgment-Call). `e.button`/`e.clientX`/`e.clientY` are unchanged
 // MouseEvent-inherited fields, so the pre-existing left-click-only /
 // screen-position logic is byte-identical for mouse. `isPrimary` guard is
 // NEW (Brief: multi-touch safety); `setPointerCapture` is NEW (touch
 // reliability — guarded with `?.` since jsdom 29 implements the
 // PointerEvent constructor but NOT the capture methods on Element, see
 // Abschlussbericht jsdom-Lückenliste; a real browser always has them).
 function handleCanvasMouseDown(e: React.PointerEvent) {
 if (!e.isPrimary) return
 if (e.button !== 0) return
 if (connectingFrom) {
 setConnectingFrom(null)
 return
 }
 // P8.2a (Baustein 3): clicking empty canvas clears a selected connection
 // too — same "clicking away deselects" convention as the node above.
 // K2-Fix: selectNode(null) clears BOTH selectedNodeId/selectedConnectionId.
 selectNode(null)
 e.currentTarget.setPointerCapture?.(e.pointerId)
 activePointerIdRef.current = e.pointerId
 drag.current = { type:'pan', startScreenX: e.clientX, startScreenY: e.clientY, startPan: pan }
 }

 function handleNodeMouseDown(e: React.PointerEvent, node: VsmNode) {
 if (!e.isPrimary) return
 if (connectingFrom) return
 e.stopPropagation()
 // P8.2a (Baustein 3): selecting a node deselects any selected connection —
 // the Properties-Panel shows either a node OR a connection, never both.
 selectNode(node.id)
 e.currentTarget.setPointerCapture?.(e.pointerId)
 activePointerIdRef.current = e.pointerId
 // Baustein 3 (Undo/Redo, P8.2b): pre-drag snapshot — see
 // dragHistorySnapshotRef's own doc comment. Taken here even when
 // wertstromUxV2Enabled is off (cheap ref write, no state/render) —
 // commitHistorySnapshotIfChanged itself no-ops behind the flag.
 dragHistorySnapshotRef.current = captureHistorySnapshot()
 drag.current = { type:'node', nodeId: node.id, startScreenX: e.clientX, startScreenY: e.clientY, startNodeX: node.x, startNodeY: node.y }
 }

 /** P8.2a (Baustein 3): the connection-selection counterpart to
 * handleNodeMouseDown above — only ever wired to a materialFlow connection
 * (kind !== 'information') behind wertstromUxV2Enabled, see the render call
 * site's own comment. stopPropagation mirrors handleNodeMouseDown/the K5-Fix
 * timeline-segment precedent (vsm-canvas-timeline.tsx) — without it, this
 * mousedown would bubble to handleCanvasMouseDown and immediately clear the
 * selection it just set via a canvas pan-drag start.
 * Baustein 1 (Pointer-Events-Refactor): React.PointerEvent + isPrimary guard
 * — no drag/capture here, a connection click only selects, never drags. */
 function handleConnectionMouseDown(e: React.PointerEvent, connectionId: string) {
 if (!e.isPrimary) return
 if (connectingFrom) return
 e.stopPropagation()
 selectConnection(connectionId)
 }

 // Review-Fix F5 (adversarial review, PR #353): returns whether the save
 // actually succeeded — handleCreateFutureState below needs this to decide
 // whether it is safe to proceed with duplicating (a caller that only wants
 // the old fire-and-forget behavior, the Speichern button, simply ignores
 // the return value).
 async function handleSave(): Promise<boolean> {
 setSaving(true)
 const res = await fetch(`/api/wertstrom/${vsm.id}`, {
 method:'PUT',
 // Wertstrom P0 (KAR-878): layout.viewport persistiert die aktuelle
 // Pan/Zoom-Ansicht. Review-Fix F6: Optimistic Locking läuft über den
 // repo-weiten If-Match-Header (lib/api/concurrency.ts), nicht mehr über
 // ein Body-Feld — Live-State, kein Stale-Closure-Risiko hier
 // (Klick-Handler, kein Timer).
 headers: {'Content-Type':'application/json', 'If-Match': updatedAt},
 // Wertstrom P4 (B2): shiftModel travels alongside viewport in the same
 // layout.jsonb merge (see route.ts) — always resent in full, same
 // "Live-State, kein Stale-Closure-Risiko" reasoning as pan/zoom above.
 // Review-Fix F3+F5: shiftModelWireValue (vsm-geometry.ts) turns the bare
 // local state into the three-way wire value the route now understands
 // (absent/null/object) — see shiftModelEverCompleteRef's own comment.
 // Baustein 2 (KAR-878): timelineOffsetY travels alongside viewport —
 // same "always resent in full, Live-State" reasoning as pan/zoom.
 // K3-Fix (Referenzwertstrom-Fixrunde 2, KAR-878): gated on
 // wertstromUxV2Enabled — the rendered band itself is ALREADY flag-gated
 // (`{wertstromUxV2Enabled && <VsmCanvasTimelineBand .../>}` below), but
 // this write was not, so a flag-off save injected a brand-new
 // `layout.timelineOffsetY` key into rows that never had one before this
 // PR, breaking the "Flag AUS ⇒ byte-gleich" invariant this file's own
 // doc comment (see below) claims for Baustein 2 (major, R1 des
 // Multi-Verifier-Reviews).
 body: JSON.stringify({
 nodes,
 connections,
 layout: { viewport: { x: pan.x, y: pan.y, zoom }, shiftModel: shiftModelWireValue(shiftModel, shiftModelEverCompleteRef.current), ...(wertstromUxV2Enabled ? { timelineOffsetY } : {}) },
 }),
 })
 setSaving(false)
 if (res.ok) {
 const updated = await res.json() as { updated_at?: string }
 if (updated.updated_at) setUpdatedAt(updated.updated_at)
 conflictRef.current = false
 setIsDirty(false)
 setSaveStatus('saved')
 setTimeout(() => setSaveStatus('idle'), 2000)
 return true
 } else if (res.status === 409) {
 // Kein stilles Überschreiben: lokale Änderungen bleiben im Editor
 // (isDirty unverändert) — nur die Autosave pausiert bis zum Reload.
 conflictRef.current = true
 setSaveStatus('conflict')
 return false
 } else {
 setSaveStatus('error')
 return false
 }
 }

 // Wertstrom P2 (A9, KAR-878/KAR-986): "Soll-Zustand erstellen" — duplicates
 // THIS Wertstrom (server-side, POST /api/wertstrom/[id]/duplicate) into a
 // new row with parent_value_stream_id + scenario_kind='future', then opens
 // it. The source row (this one) is never modified. KPI-delta computation
 // and a side-by-side comparison view are explicitly P5 (execution-prompt
 // §9.4 "Reopen/Detail-Vergleich") — this phase only creates + lists +
 // labels scenarios (P2-Brief Leitplanken).
 //
 // Review-Fix F5 (adversarial review, PR #353): the duplicate route reads
 // nodes/connections/layout straight from the last PERSISTED row — if the
 // editor is dirty (unsaved canvas edits), save FIRST so the new Soll-Zustand
 // is derived from what the user is actually looking at, not a stale server
 // copy. A failed save (409 conflict or any other error) ABORTS — the
 // duplicate is never fired trotzdem, and the existing save-status UI
 // (conflict/error banner) already tells the user why.
 async function handleCreateFutureState() {
 setCreatingFutureState(true)
 setFutureStateError(null)
 if (isDirty) {
 const saved = await handleSave()
 if (!saved) {
 setCreatingFutureState(false)
 setFutureStateError(t('wertstrom.editor.toolbar.createFutureStateSaveFailed', 'Speichern fehlgeschlagen — Soll-Zustand wurde nicht erstellt. Bitte erneut speichern.'))
 return
 }
 }
 const res = await fetch(`/api/wertstrom/${vsm.id}/duplicate`, { method: 'POST' })
 if (res.ok) {
 const created = await res.json() as { id: string }
 router.push(`/wertstrom/${created.id}`)
 // No setCreatingFutureState(false) on the success path — navigation is
 // in flight, keeping the button disabled until the route change lands
 // avoids a double-click firing a second duplicate.
 } else {
 setCreatingFutureState(false)
 setFutureStateError(t('wertstrom.editor.toolbar.createFutureStateFailed', 'Soll-Zustand konnte nicht erstellt werden.'))
 }
 }

 /** Review-Fix F7 (adversarial review, PR #353): guards the two USER-CHOSEN
  * provenance-writes (inline stopwatch "Als Zykluszeit übernehmen",
  * Stoppuhr-Picker) against silently downgrading an already
  * higher-confidence value (confidence order measured > calculated =
  * imported > planned > assumed — see PROVENANCE_CONFIDENCE_RANK/
  * isProvenanceDowngrade in vsm-config.ts). handleLscImport's provenance
  * write is NOT routed through this: it only ever sets provenance on
  * brand-new nodes it is creating in the same call, never an overwrite of
  * an existing node's value (see its own comment below) — there is nothing
  * to downgrade. Returns false (node left untouched) when the user cancels
  * the confirmation. */
 function applyProvenanceGuardedCycleTime(node: VsmNode, cycleTimeSec: number, provenance: ValueProvenance): boolean {
 if (isProvenanceDowngrade(node.provenance, provenance)) {
 const curLabel = t(PROVENANCE_LABEL_KEYS[node.provenance!], PROVENANCE_LABELS[node.provenance!])
 const nextLabel = t(PROVENANCE_LABEL_KEYS[provenance], PROVENANCE_LABELS[provenance])
 const curVal = node.cycleTimeSec != null ? `${node.cycleTimeSec}s` : t('wertstrom.editor.common.unknown', 'unbekannt')
 const confirmTemplate = t('wertstrom.editor.confirm.provenanceDowngrade', 'Aktueller Wert ({curVal}, Herkunft: {curLabel}) wird durch einen Wert mit geringerer Konfidenz ersetzt ({nextVal}s, Herkunft: {nextLabel}) — fortfahren?')
 const confirmed = window.confirm(
 interpolate(confirmTemplate, { curVal, curLabel, nextVal: String(cycleTimeSec), nextLabel })
 )
 if (!confirmed) return false
 }
 updateNode(node.id, { cycleTimeSec, provenance })
 return true
 }

 // Auto-save: inline to avoid stale closure issues
 useEffect(() => {
 if (!isDirty) return
 if (conflictRef.current) return // Optimistic Locking: pausiert bis Reload
 const currentNodes = nodes
 const currentConnections = connections
 // Wertstrom P4 (B2): unlike pan/zoom (ref-read, does NOT reset the 30s
 // countdown — see below), shiftModel is real business data like
 // nodes/connections, so it IS in this effect's dependency array
 // (restarts the countdown on edit) and is captured the same plain way.
 const currentShiftModel = shiftModel
 // Review-Fix F3+F5: captured alongside currentShiftModel, same "value at
 // the moment THIS effect (re-)ran" timing — any change to the ref only
 // ever happens in the same onShiftModelChange call that also updates
 // `shiftModel` state, so the two can never drift apart between here and
 // the timer firing.
 const currentShiftModelEverComplete = shiftModelEverCompleteRef.current
 const timer = setTimeout(async () => {
 if (conflictRef.current) return
 // panRef/zoomRef/updatedAtRef (not pan/zoom/updatedAt directly): the
 // CURRENT viewport/lock-token at the moment the timer fires, not
 // whatever it was when this effect last (re-)ran — panning/zooming
 // alone does not reset the 30s countdown.
 const res = await fetch(`/api/wertstrom/${vsm.id}`, {
 method:'PUT',
 // Review-Fix F6: If-Match header (updatedAtRef.current — same
 // CURRENT-value-at-fire-time reasoning as panRef/zoomRef above), not a
 // body field.
 headers: {'Content-Type':'application/json', 'If-Match': updatedAtRef.current},
 body: JSON.stringify({
 nodes: currentNodes,
 connections: currentConnections,
 layout: {
 viewport: { x: panRef.current.x, y: panRef.current.y, zoom: zoomRef.current },
 shiftModel: shiftModelWireValue(currentShiftModel, currentShiftModelEverComplete),
 // Baustein 2 (KAR-878): timelineOffsetYRef — same "current value at
 // fire-time, not at effect-schedule-time" reasoning as panRef/
 // zoomRef above; a timeline-offset-only drag never itself restarts
 // this 30s countdown (mirrors pan/zoom's own "along for the ride"
 // behavior, see timelineOffsetYRef's own doc comment).
 // K3-Fix (Referenzwertstrom-Fixrunde 2, KAR-878): gated on
 // wertstromUxV2Enabled, same reasoning as handleSave's identical fix
 // above — the 30s autosave must stay byte-identical to handleSave's
 // own payload shape for the same flag state.
 ...(wertstromUxV2Enabled ? { timelineOffsetY: timelineOffsetYRef.current } : {}),
 },
 }),
 })
 if (res.ok) {
 const updated = await res.json() as { updated_at?: string }
 if (updated.updated_at) setUpdatedAt(updated.updated_at)
 setIsDirty(false)
 setSaveStatus('saved')
 setTimeout(() => setSaveStatus('idle'), 2000)
 } else if (res.status === 409) {
 // Autosave darf bei einem Konflikt NICHT weiter versuchen — sonst
 // würde sie denselben stale expectedUpdatedAt endlos wiederholen.
 conflictRef.current = true
 setSaveStatus('conflict')
 }
 }, 30000)
 return () => clearTimeout(timer)
 }, [isDirty, nodes, connections, vsm.id, shiftModel, wertstromUxV2Enabled])

 // Wertstrom P8.1 fix (K1, Orphan-Maßnahme — PR #363 adversarial review,
 // CONFIRMED major, 5-fach-Cluster): browser-standard "verlassen?"-Warnung
 // while isDirty — repo-weit geprüft (kein bestehendes beforeunload-Muster
 // in components/wertstrom oder sonstwo im Editor-Baum, 0 Treffer), also
 // neu eingeführt statt einer vorhandenen Konvention zu folgen. Narrows the
 // "Tab schließen/Crash mitten in einer ungesicherten Aktion" window this
 // editor never guarded against at all (not just for the measure-link case
 // below — isDirty covers every unsaved change). MDN-Standardmuster:
 // preventDefault() für spec-konforme Browser, returnValue für ältere.
 useEffect(() => {
 if (!isDirty) return
 function onBeforeUnload(e: BeforeUnloadEvent) {
 e.preventDefault()
 e.returnValue = ''
 }
 window.addEventListener('beforeunload', onBeforeUnload)
 return () => window.removeEventListener('beforeunload', onBeforeUnload)
 }, [isDirty])

 // Wertstrom P8.1 fix (K1 Orphan-Maßnahme + C3 Closure-Race — PR #363
 // adversarial review, CONFIRMED major both): fires right after a
 // workshop_actions insert succeeds (already committed, real DB row) — see
 // VsmMeasureDialog's handleSubmit, which awaits this before letting the
 // dialog count as done (Ladezustand bis dahin).
 //
 // C3 (Closure-Race): the measureRefs patch is computed from `nodesRef.current`
 // — the CURRENT-value-at-fire-time ref (see its own doc comment above),
 // NOT a `nodes.find(...)` read off the RENDER closure (the previous
 // version's bug: a plain closure variable can be stale by the time an
 // async callback fires) and NOT a `setNodes(prev => …)` side-channel
 // readback (verified broken in this exact call context — see nodesRef's
 // doc comment). So it can't race a concurrent deletion of the target node
 // (Delete/Backspace bubbles past Radix' FocusScope to the global document
 // keydown handler even while this dialog is open, see the finding's
 // evidence; NOT additionally guarded here — the Brief scoped this fix to
 // hardening the patch itself, not to making the keydown handler
 // dialog-aware). Returns 'node-gone' instead of silently patching nothing
 // when the target id no longer exists.
 //
 // K1 (Orphan-Maßnahme): persists the patch immediately via a small,
 // targeted PUT — `nodes` only (connections/layout untouched, see
 // UpdateValueStreamMapBody's independent-optional-fields schema) — instead
 // of queueing it into the generic isDirty/Speichern/30s-Autosave pipeline,
 // which used to leave an already-committed workshop_actions row invisibly
 // orphaned for up to 30s or indefinitely (superseded TODO.md risk note,
 // corrected in CHANGELOG.md's Fix-Runde entry). Deliberately does NOT also
 // send `connections`/`layout` (Brief: "NUR mit dem measureRefs-Patch dieses
 // Nodes") and does NOT force `isDirty` back to false on success — other
 // pending edits (connections/layout, or a DIFFERENT node's fields, all of
 // which ride along in `patchedNodes` since the API can only replace the
 // whole `nodes` column, never a single field) may still be genuinely
 // unsaved; conservatively leaving `isDirty` as-is (still true) never hides
 // a real pending change, matching this module's honesty doctrine.
 async function linkMeasureToNode(nodeId: string, measureId: string): Promise<LinkMeasureOutcome> {
 const current = nodesRef.current
 const targetNode = current.find(n => n.id === nodeId)
 if (!targetNode) return 'node-gone'
 // P8.2a (Baustein 4, "Bestehende Maßnahme verknüpfen", KAR-878/KAR-986,
 // "Duplikat-Verknüpfung verhindert"): this function is now called from TWO
 // flows (create+link via VsmMeasureDialog, AND link-existing via
 // VsmLinkMeasureDialog) — an already-linked measureId is a no-op success,
 // never a second array entry. isMeasureAlreadyLinked (vsm-measures.ts) is
 // the SAME guard the link-existing picker's own option-filtering uses
 // (defense in depth, one definition of "duplicate" for both).
 if (isMeasureAlreadyLinked(targetNode.measureRefs, measureId)) return 'ok'
 // P8.2b-Review-Fix K1 (Undo-Vollständigkeit erweitert über die 4 benannten
 // ur-1..ur-4-Stellen hinaus, KAR-987): eigener Grep-Audit aller setNodes/
 // setConnections-Call-Sites fand diese als einzige weitere GENUINE Lücke
 // (rein additiv, kein Server-Load/Init, keine Undo/Redo-Eigenmechanik) —
 // push VOR der optimistischen Mutation. Der Fehler-Rollback weiter unten
 // (setNodes bei !res.ok) bleibt bewusst UNGEPUSHT: das ist eine automatische
 // Kompensation eines fehlgeschlagenen Requests, keine eigene Nutzer-Geste.
 pushHistoryBeforeChange()
 const patchedNodes = current.map(n => (n.id !== nodeId ? n : { ...n, measureRefs: [...(n.measureRefs ?? []), measureId] }))
 setNodes(patchedNodes)
 setIsDirty(true)

 const res = await fetch(`/api/wertstrom/${vsm.id}`, {
 method: 'PUT',
 headers: { 'Content-Type': 'application/json', 'If-Match': updatedAtRef.current },
 body: JSON.stringify({ nodes: patchedNodes }),
 })
 if (res.ok) {
 const updated = await res.json() as { updated_at?: string }
 if (updated.updated_at) setUpdatedAt(updated.updated_at)
 return 'ok'
 }
 // K5-Fix (P8.2a-Review Fix-Runde, KAR-878/KAR-986): roll back the
 // optimistic patch above on any non-2xx — without this, the rejected
 // measureId stayed in client-side `nodes` state forever (a Ghost-Link):
 // VsmLinkMeasureDialog's `alreadyLinkedIds` (targetNode.measureRefs) and
 // the Node-Panel's "Verknüpfte Maßnahmen" list both read directly off
 // this SAME state, so a 409/error made the dialog's own picker
 // immediately re-render as "already linked" — hiding the error branch
 // that was about to show `error` (the dialog's own `<form>`/message
 // never mounted) and leaving a link the server never persisted. Filters
 // the CURRENT nodes state (a fresh functional update), not the `current`/
 // `patchedNodes` snapshot captured before the `await` — a concurrent,
 // unrelated edit made while this PUT was in flight is not clobbered.
 // isDirty is deliberately left AS-IS (see this function's own doc
 // comment above) — the same conservative "never hide a real pending
 // change" doctrine applies whether the PUT succeeded or failed.
 setNodes(prev => prev.map(n => (n.id !== nodeId ? n : { ...n, measureRefs: (n.measureRefs ?? []).filter(id => id !== measureId) })))
 if (res.status === 409) return 'conflict'
 return 'link-error'
 }

 const selectedNode = useMemo(() => nodes.find(n => n.id === selectedNodeId) ?? null, [nodes, selectedNodeId])
 // P8.2a (Baustein 3, "Push/Pull/Kanban-Kantensemantik", KAR-878/KAR-986):
 // mirrors selectedNode above — mutually exclusive (see handleNodeMouseDown/
 // handleConnectionMouseDown), only ever set behind wertstromUxV2Enabled.
 const selectedConnection = useMemo(() => connections.find(c => c.id === selectedConnectionId) ?? null, [connections, selectedConnectionId])
 // Wertstrom P8.1 (A18, §15.5, P7-Anschluss, Baustein 4): the §18 "offene
 // Maßnahmen" data source for VsmManagementAnalysisView — see
 // vsm-measures.ts's own doc comment for the `null`-vs-`[]` distinction.
 // K2-Fix: `deriveOpenMeasureInputs` now also reports WHY `items` is
 // undefined (`issue`) — threaded through to VsmManagementAnalysisView so it
 // can render "nicht einsehbar"/"konnte nicht geladen werden" instead of the
 // generic "keine Datenquelle" text.
 const openMeasuresDerivation = useMemo(() => deriveOpenMeasureInputs(nodes, measures), [nodes, measures])
 const timelineData = useMemo(() => computeTimeline(nodes), [nodes])
 // Wertstrom P4 (B2): demand RATE [Stk/Tag] for the Timeline-Leiter's
 // inventory-coverage calc, summed from customer-node `demand` fields
 // (already the "Stk/Tag" field per the Properties-Panel label below) —
 // deliberately NOT a new input (single source of truth, no re-entry of a
 // number the user already provided elsewhere). `undefined` (not 0) when no
 // customer node has a demand set — never a fabricated rate.
 const demandUnitsPerDay = useMemo(() => computeDemandUnitsPerDay(nodes), [nodes])
 // Referenzwertstrom-Falle (Kais-Auftrag, KAR-878, §4/§13): Kais requires the
 // Taktzeit to be COMPUTED (Arbeitszeit/Pausen/Schichten/Kundenbedarf), never
 // a static value, and to update when demand/shift inputs change. The
 // pre-existing `taktTimeSecFromProject` prop satisfies this for every
 // Wertstrom with a linked project (unchanged, still computed once by
 // vsm-wizard-logic.ts's `computeTakt` call or entered on the Projekt itself
 // — this fallback NEVER overrides it, preserving byte-identical behaviour
 // for every pre-existing Wertstrom). Only a Wertstrom with NO linked project
 // (`taktTimeSecFromProject` is `null`) falls back to a takt derived fresh,
 // on every render, from THIS Wertstrom's own `shiftModel` + live
 // `demandUnitsPerDay` — same `computeTakt` function, called here instead of
 // only once at creation time, so editing either input live-updates the
 // takt used by the bottleneck/capacity/timeline calculations below (one
 // value, `taktTimeSec`, never two diverging numbers on screen).
 //
 // C12-Fix (Referenzwertstrom-Fixrunde, KAR-878): this fallback is gated
 // behind `wertstromUxV2Enabled` — it is a NEW UX-V2-era capability, not a
 // pre-existing one, and every OTHER new UX-V2 UI block in this component is
 // gated the same way. Without the gate, ANY pre-existing project-less
 // Wertstrom that already had a `shiftModel` + customer demand entered
 // (a supported, tested combination since P4) would silently start
 // computing a live takt the instant this PR shipped, changing its
 // Engpass-Erkennung/Warnbadges even with the flag OFF — the "Flag AUS ⇒
 // exakt altes Verhalten, byte-gleich" doctrine every other P3+ feature in
 // this file upholds. `app/wertstrom/[id]/page.tsx` applies the SAME
 // `wertstromUxV2Enabled` gate to the comparison-target's OWN takt fallback
 // (C6-Fix) — both sides of every Ist/Soll-Vergleich derive their takt via
 // the identical rule, never one side gated differently from the other.
 const liveTaktFallback = useMemo(
   () => computeTakt(shiftModel && demandUnitsPerDay ? { shiftModel, workingDays: 1, demandQuantity: demandUnitsPerDay } : undefined),
   [shiftModel, demandUnitsPerDay],
 )
 const taktTimeSec = taktTimeSecFromProject ?? (wertstromUxV2Enabled ? liveTaktFallback.taktTimeSec : null)
 // Wertstrom P7 (KAR-878/KAR-986): shared `lib/vsm-engine` capacity-override
 // context for the NEW Management-Analyse/Präsentationsmodus/Export-Familie
 // — same derivation vsm-scenario-compare.ts's `computeScenarioKpiSnapshot`
 // already uses (only `node.availabilityPct`, the sole per-node override the
 // editor actually collects today — see EffectiveCapacityDisplay above),
 // duplicated here (not imported) to keep that P5 module independent of a
 // components-layer helper import.
 const capacityOverridesByNodeId = useMemo(() => {
 const map: Record<string, { availabilityPct?: number }> = {}
 for (const n of nodes) {
 if ((n.type === 'process' || n.type ==='machine') && n.availabilityPct != null) map[n.id] = { availabilityPct: n.availabilityPct }
 }
 return map
 }, [nodes])
 // Wertstrom P7 (KAR-878/KAR-986) Grundsatz-Entscheidung "v2 überall, wo
 // 'Engpass' draufsteht": the Canvas-Node-Badge used to run the legacy
 // `findBottleneckId` heuristic (type==='process' only, raw cycleTimeSec,
 // ignores machine-Nodes/Takt/OEE/Parallelität) while the Management-
 // Analyse-Panel directly below it (same viewport, always visible together
 // once expanded) already used `computeBottleneckV2` — two contradictory
 // "wo ist der Engpass"-Aussagen on screen at once, nowhere disclosed as
 // two different models. Same memoized, non-hot-path call the Management-
 // Analyse-Panel/Präsentationsmodus already make with these exact inputs.
 const bottleneckId = useMemo(() => computeBottleneckV2(nodes, taktTimeSec, capacityOverridesByNodeId).primary?.nodeId ?? null, [nodes, taktTimeSec, capacityOverridesByNodeId])
 // C22-Fix (Wertstrom P7 fix-round): `projects` (full array, id/project_code/
 // supplier_name) is already a Prop — `supplier_name` is a REQUIRED project
 // field (lib/api/schemas.ts), unlike the optional `project_code` that
 // `projectLabel` (page.tsx) already collapses onto. Derived locally so the
 // PDF export can show it as its OWN "Lieferant" row instead of only via
 // `projectLabel`'s nullish-coalescing fallback, which hides supplier_name
 // whenever project_code is also set.
 const supplierName = useMemo(() => projects.find(p => p.id === vsm.project_id)?.supplier_name ?? null, [projects, vsm.project_id])
 // Wertstrom P7: the SAME {current, target} bundle VsmScenarioCompareView
 // receives (see its call site below) — reused verbatim by
 // VsmManagementAnalysisView/VsmPresentationMode/VsmExportDialog so "Ist/
 // Soll-Effekte" never depends on which viewMode tab happens to be open.
 const scenarioCompareBundle = useMemo(() => {
 if (!wertstromScenariosEnabled || !compareTarget) return null
 return {
 current: {
 id: vsm.id,
 title: vsm.title,
 scenario_kind: vsm.scenario_kind ??'current' as const,
 updated_at: updatedAt,
 nodes,
 context: { taktTimeSec, shiftModel, demandUnitsPerDay, projectLabel },
 // Baustein 5 (Preferred Future State, KAR-878/KAR-986/KAR-987, P8.2b):
 // nur bedeutungsvoll, wenn DIESE Seite selbst der Parent ist
 // (scenario_kind==='current') — parsePreferredScenarioIdFromLayout
 // liefert sonst ohnehin undefined (das Feld wird nur am Parent-layout
 // geschrieben, s. Schema-Doc).
 preferredScenarioId: parsePreferredScenarioIdFromLayout(vsm.layout),
 },
 target: compareTarget,
 }
 }, [wertstromScenariosEnabled, compareTarget, vsm.id, vsm.title, vsm.scenario_kind, vsm.layout, updatedAt, nodes, taktTimeSec, shiftModel, demandUnitsPerDay, projectLabel])
 const [presentationModeOpen, setPresentationModeOpen] = useState(false)

 // QVS-P3 (KAR-972): Stoppuhr-Soll/Ist-Vergleich — QAF-Originalwert (nie
 // überschrieben, s. qafSource.fields) gegen den gemessenen Ø desselben
 // Prozessnamens (bestehender LSC-/Stoppuhr-Weg). Rein anzeigend.
 const qafVsMeasured = useMemo(() => {
 if (!selectedNode?.qafSource) return null
 const qafSec = selectedNode.qafSource.fields?.zykluszeit?.original
 if (typeof qafSec !=='number') return null
 const measuredSteps: MeasuredStep[] = processSteps.map(s => ({
 station_name: s.station_name,
 measuredAvgSec: s.measuredAvgSec ?? null,
 measuredCount: s.measuredCount ?? 0,
 }))
 const match = findMeasuredMatch(selectedNode.name, measuredSteps)
 if (!match) return null
 return { qafSec, measuredSec: match.avgSec, deltaPercent: computeQafVsMeasuredDeltaPercent(qafSec, match.avgSec) }
 }, [selectedNode, processSteps])

 // Wertstrom P4 (B3, Capability-Matrix, KAR-878/KAR-986): the Expert-tier
 // "→ berechnete Verfügbarkeit" hint — derived from lib/oee's
 // calculateAvailabilityFromMtbfMttr, ONLY ever applied to availabilityPct
 // via the explicit "Übernehmen" button below (never silently), same "kein
 // stilles Überschreiben" doctrine as applyProvenanceGuardedCycleTime. One
 // decimal place — availabilityPct is a plain percent input, not a
 // provenance-tracked field, so no further rounding contract applies.
 const derivedAvailabilityPct = useMemo(() => {
 if (selectedNode?.mtbfMin == null || selectedNode?.mttrMin == null) return null
 const fraction = calculateAvailabilityFromMtbfMttr(selectedNode.mtbfMin, selectedNode.mttrMin)
 return fraction != null ? Math.round(fraction * 1000) / 10 : null
 }, [selectedNode?.mtbfMin, selectedNode?.mttrMin])

 async function handleExcelFile(e: React.ChangeEvent<HTMLInputElement>) {
 const file = e.target.files?.[0]
 if (!file) return
 const ExcelJS = (await import('exceljs')).default
 const buf = await file.arrayBuffer()
 const ejWb = new ExcelJS.Workbook()
 await ejWb.xlsx.load(buf)
 const ws = ejWb.worksheets[0]

 // Build header row and convert to objects
 const headerRow = ws.getRow(1)
 const headers: string[] = []
 headerRow.eachCell({ includeEmpty: true }, (cell, colNum) => {
 headers[colNum - 1] = String(cell.value ??'')
 })
 const rows: Record<string, unknown>[] = []
 ws.eachRow({ includeEmpty: false }, (row, rowNum) => {
 if (rowNum === 1) return
 const obj: Record<string, unknown> = {}
 headers.forEach((h, i) => {
 const cell = row.getCell(i + 1)
 obj[h] = cell.value ?? null
 })
 rows.push(obj)
 })

 setExcelPreview(mapExcelRowsToPreview(rows))
 }

 function applyExcelImport() {
 if (!excelPreview) return
 const newNodes: VsmNode[] = excelPreview.map((row, i) => ({
 id: crypto.randomUUID(),
 type:'process'as NodeType,
 x: 40 + i * (NODE_W + 40),
 y: 200,
 name: row.name,
 ...importRowToNodeTimeFields(row),
 isValueAdded: true,
 }))
 // Wertstrom P0 (KAR-878): Excel-Import erzeugte bisher nur Nodes, keine
 // Kanten — im Unterschied zum LSC-Import (handleLscImport oben) und zum
 // QAF-Mapper (lib/qaf-value-stream/internal/mapper.ts), die beide dieselbe
 // lineare i→i+1-Verkettung bauen. Nur die neu importierte Kette wird intern
 // verbunden — ein Anschluss an bereits bestehende Nodes ist bewusst NICHT
 // automatisch (kein Rätselraten, an welchen Vorgänger-Knoten die Excel-Zeilen
 // gehören).
 const newConnections: VsmConnection[] = buildLinearConnectionPairs(newNodes.map(n => n.id)).map(pair => ({
 id: crypto.randomUUID(),
 ...pair,
 }))
 // P8.2b-Review-Fix K1 (ur-4, KAR-987): ONE push for the whole bulk import —
 // same reasoning as handleLscImport above (one Undo-Schritt entfernt alle
 // importierten Nodes+Kanten, statt den ganzen Import komplett history-frei
 // zu lassen).
 pushHistoryBeforeChange()
 setNodes(prev => [...prev, ...newNodes])
 setConnections(prev => [...prev, ...newConnections])
 setIsDirty(true)
 setExcelPreview(null)
 setShowExcelImport(false)
 }

 // Wertstrom P3 (A2, KAR-878/KAR-986): "Automatisch anordnen" — re-runs the
 // SAME computeAutoLayout the Quick-Start-Wizard uses to build a fresh
 // Wertstrom (vsm-wizard-logic.ts), but against the LIVE canvas. Always
 // confirms first (never silent) because it overwrites every node's manual
 // x/y — manual dragging itself stays fully available afterwards, this is a
 // one-shot repositioning action, not a persistent constraint.
 //
 // Review-Fix F5: also re-fits the viewport onto the NEW layout — without
 // this, computeAutoLayout always places nodes at a FIXED origin
 // (START_X/Y_MAIN) regardless of the prior layout, so confirming could
 // leave the canvas showing an empty viewport (nodes correctly repositioned
 // in state, but outside the pan/zoom the user was left at) —
 // indistinguishable from a failure. Deliberately computes fit-view against
 // `updatedNodes` directly instead of calling handleFitView() (which reads
 // `nodes` from its own closure): setNodes() does not update that closure
 // synchronously, so calling handleFitView() right here would still fit
 // against the STALE pre-layout positions until the next render.
 function handleAutoLayout() {
 const confirmed = window.confirm(t('wertstrom.editor.confirm.autoLayout', 'Alle Knoten automatisch anordnen? Manuell gesetzte Positionen werden dabei überschrieben.'))
 if (!confirmed) return
 // P8.2b-Review-Fix K1 (ur-3, KAR-987): biggest blast-radius mutation in the
 // whole editor (overwrites EVERY node's x/y at once) had no history push at
 // all — the native confirm() only warns, it is not a revert mechanism.
 // Push AFTER confirm (never for a cancelled dialog) and BEFORE the
 // overwrite, so Ctrl+Z can restore the manual layout.
 pushHistoryBeforeChange()
 const positions = computeAutoLayout(nodes, connections)
 const updatedNodes = nodes.map(n => ({ ...n, ...(positions[n.id] ?? {}) }))
 setNodes(updatedNodes)
 setIsDirty(true)
 const rect = canvasRef.current?.getBoundingClientRect()
 const fitted = computeFitView(updatedNodes, { width: rect?.width ?? 0, height: rect?.height ?? 0 })
 setPan({ x: fitted.x, y: fitted.y })
 setZoom(fitted.zoom)
 }

 const gridStyle = {
 backgroundColor:'var(--vsm-grid-bg)',
 backgroundImage:'radial-gradient(circle, var(--vsm-grid-dot) 1.5px, transparent 1.5px)',
 backgroundSize: `${20 * zoom}px ${20 * zoom}px`,
 backgroundPosition: `${pan.x % (20 * zoom)}px ${pan.y % (20 * zoom)}px`,
 }

 return (
 <div className="h-dvh flex flex-col overflow-hidden">
 {/* Top toolbar */}
 <div className="h-12 bg-card border-b border-border flex items-center gap-3 px-4 shrink-0">
 <Link href="/wertstrom"className="text-xs text-muted-foreground hover:text-primary flex items-center gap-1 transition-colors">
 <ChevronLeft size={14} />
 {t('wertstrom.editor.toolbar.overview', 'Übersicht')}
 </Link>
 <span className="text-xs text-border">|</span>
 <span className="text-sm font-semibold text-foreground truncate max-w-xs">{vsm.title}</span>
 {/* Wertstrom P2 (A9, "Kennzeichnung"): no badge for scenario_kind ===
 'current' — an Ist-Wertstrom is the default/majority case, a badge on
 every single row would be noise the existing qafSource-marker/
 FieldStatusTag "absence is quiet" convention deliberately avoids.
 Review-Fix F2: also gated on wertstromScenariosEnabled — the whole A9
 create-UI (this badge included) is P5-scoped, flag off in every profile
 today. */}
 {wertstromScenariosEnabled && vsm.scenario_kind !== 'current' && (
 <span className="text-[9px] px-1.5 py-0.5 rounded-full bg-secondary text-primary-dark shrink-0">
 {t(SCENARIO_KIND_LABEL_KEYS[vsm.scenario_kind], SCENARIO_KIND_LABELS[vsm.scenario_kind])}
 </span>
 )}
 {/* Review-Fix F2: flag-gated (P5-scoped, see Props doc comment). F3:
 additionally only rendered on an Ist-Wertstrom (isCurrentScenario) —
 duplicating an already-derived scenario chained parent_value_stream_id
 off the wrong generation and stacked the "(Soll)" title suffix; the
 route rejects it server-side too (400), this just keeps the button from
 offering an action that would fail. */}
 {wertstromScenariosEnabled && isCurrentScenario && (
 <>
 <button
 onClick={() => void handleCreateFutureState()}
 disabled={creatingFutureState}
 className="ml-2 flex items-center gap-1.5 h-7 px-2.5 rounded-lg border border-border text-[10px] text-muted-foreground hover:text-primary hover:border-primary hover:bg-secondary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
 title={t('wertstrom.editor.toolbar.createFutureStateTitle', 'Legt eine Kopie dieses Wertstroms als Soll-Zustand an')}
 >
 <GitBranch size={12} />
 {creatingFutureState ? t('wertstrom.editor.toolbar.creatingEllipsis', 'Erstelle…') : t('wertstrom.editor.toolbar.createFutureState', 'Soll-Zustand erstellen')}
 </button>
 {futureStateError && <span className="text-xs text-destructive">{futureStateError}</span>}
 </>
 )}
 {/* Wertstrom P5 (A9, KAR-878/KAR-986, Kern-Anforderung 1 "Szenarien-
 Navigation"): Zugehörigkeit + Wechsel (Ist <-> seine Szenarien) — plain
 navigation, separate from the Vergleich-Einstiegspunkte below (canvas/
 table/compare toggle). Renders nothing when there is nothing to show
 (no scenarios on an Ist map, or a plain non-scenario map). */}
 {wertstromScenariosEnabled && (
 <VsmScenarioNav
 isCurrentScenario={isCurrentScenario}
 currentId={vsm.id}
 scenarioParent={scenarioParent}
 parentUnresolved={!isCurrentScenario && scenarioParent == null}
 scenarioChildren={scenarioChildren}
 onCompareWithParent={() => setViewMode('compare')}
 />
 )}
 <button
 onClick={() => setShowLscImport(true)}
 className="flex items-center gap-1.5 h-7 px-2.5 rounded-lg border border-border text-[10px] text-muted-foreground hover:text-primary hover:border-primary hover:bg-secondary transition-colors"
 title={t('wertstrom.editor.toolbar.importLscTitle', 'Aus LSC Workshop importieren')}
 >
 <Download size={12} />
 {t('wertstrom.editor.toolbar.importLsc', 'LSC importieren')}
 </button>
 {/* QVS-P5 (KAR-974): nur sichtbar, wenn mindestens ein Node aus einem
 QAF-Import stammt (qafSource) — ein rein manuell/Excel/LSC angelegter
 Wertstrom bekommt diesen Button gar nicht angeboten (Leitplanke). Die
 eigentliche Durchsetzung des Flags passiert serverseitig in
 app/wertstrom/qaf-actions.ts (requireFlagEnabled), exakt wie bei jedem
 anderen QVS-Server-Action-Aufruf — kein zusätzliches Flag-Prop hier
 nötig, selbes Muster wie die vaClass-4-Wege-Auswahl/"Quelle anzeigen". */}
 {hasQafImportedNodes && (
 <button
 onClick={() => setShowReimport(true)}
 className="flex items-center gap-1.5 h-7 px-2.5 rounded-lg border border-border text-[10px] text-muted-foreground hover:text-primary hover:border-primary hover:bg-secondary transition-colors"
 title={t('wertstrom.editor.toolbar.syncQafTitle', 'Mit QAF-Quelle synchronisieren')}
 >
 <RefreshCw size={12} />
 {t('wertstrom.editor.toolbar.sync', 'Synchronisieren')}
 </button>
 )}
 {copilotExportsEnabled && (
 <CopilotExportButton
 id={vsm.id}
 formats={[
   { format: 'docx', action: exportWertstromCopilotDocx, label: t('wertstrom.editor.toolbar.copilotWordLabel', 'Word (DOCX)') },
   { format: 'md', action: exportWertstromCopilotMd, label: t('wertstrom.editor.toolbar.copilotMarkdownLabel', 'Markdown (KI-Chat)') },
 ]}
 label={t('wertstrom.editor.toolbar.copilotExportLabel', 'Copilot-Export')}
 busyLabel={t('wertstrom.editor.toolbar.copilotExportBusy', 'Erzeuge…')}
 className="flex items-center gap-1.5 h-7 px-2.5 rounded-lg border border-border text-[10px] text-muted-foreground hover:text-primary hover:border-primary hover:bg-secondary transition-colors"
 />
 )}
 {/* Wertstrom P7 (A17, KAR-878/KAR-986, execution-prompt §11.6/§19): eine
 primäre Export-Aktion (Auswahl-Dialog, §19.1 "not many equal download
 buttons") + Präsentationsmodus-Button — beide hinter wertstromUxV2, wie
 der Rest von P3+ (die alte, direkte CopilotExportButton oben bleibt
 zusätzlich bestehen, hinter ihrem eigenen copilotExportsEnabled-Flag,
 das in jedem Profil `false` ist — kein sichtbarer Doppel-Button heute,
 siehe PR-Beschreibung "Judgment Call"). */}
 {wertstromUxV2Enabled && (
 <>
 <button
 onClick={() => setPresentationModeOpen(true)}
 className="flex items-center gap-1.5 h-7 px-2.5 rounded-lg border border-border text-[10px] text-muted-foreground hover:text-primary hover:border-primary hover:bg-secondary transition-colors"
 title={t('wertstrom.editor.toolbar.presentationModeTitle', 'Präsentationsmodus (Vollbild)')}
 >
 {t('wertstrom.editor.toolbar.presentationMode', 'Präsentationsmodus')}
 </button>
 <VsmExportDialog
 vsm={vsm}
 nodes={nodes}
 connections={connections}
 taktTimeSec={taktTimeSec}
 demandUnitsPerDay={demandUnitsPerDay}
 shiftModel={shiftModel}
 projectLabel={projectLabel}
 supplierName={supplierName}
 capacityOverridesByNodeId={capacityOverridesByNodeId}
 scenarioCompare={scenarioCompareBundle}
 copilotExportsEnabled={copilotExportsEnabled}
 />
 </>
 )}
 {/* Wertstrom P4 (A14, KAR-878/KAR-986): Canvas <-> Tabelle — only
 rendered behind the flag (Nicht-Scope "bestehende Editor-Funktionen
 laufen bei Flag AUS byte-gleich weiter"), same gating convention as
 "Automatisch anordnen" (A2, P3). `role="group"` + `aria-pressed` make
 this a real toggle-group for assistive tech, not just plain buttons.
 Wertstrom P5 (A9): a third "Vergleich" entry joins this SAME group —
 gated independently on `wertstromScenariosEnabled` (its own flag, not
 `wertstromUxV2Enabled`) so the Vergleichs-Ansicht is reachable even in a
 deployment that has P3's UX flag off. The group itself now renders when
 EITHER flag would show at least one non-Canvas button — Canvas alone
 would be a pointless single-button "group". */}
 {(wertstromUxV2Enabled || canShowCompareToggle) && (
 <div role="group" aria-label={t('wertstrom.editor.toolbar.viewSelectAriaLabel', 'Ansicht wählen')} className="flex items-center rounded-lg border border-border overflow-hidden shrink-0">
 <button
 type="button"
 onClick={() => setViewMode('canvas')}
 aria-pressed={viewMode ==='canvas'}
 className={`flex items-center gap-1 h-7 px-2.5 text-[10px] transition-colors ${viewMode ==='canvas'?'bg-primary text-white':'text-muted-foreground hover:bg-secondary'}`}
 >
 <LayoutGrid size={12} />
 {t('wertstrom.editor.toolbar.viewCanvas', 'Canvas')}
 </button>
 {wertstromUxV2Enabled && (
 <button
 type="button"
 onClick={() => setViewMode('table')}
 aria-pressed={viewMode ==='table'}
 className={`flex items-center gap-1 h-7 px-2.5 text-[10px] transition-colors border-l border-border ${viewMode ==='table'?'bg-primary text-white':'text-muted-foreground hover:bg-secondary'}`}
 >
 <Table2 size={12} />
 {t('wertstrom.editor.toolbar.viewTable', 'Tabelle')}
 </button>
 )}
 {canShowCompareToggle && (
 <button
 type="button"
 onClick={() => setViewMode('compare')}
 aria-pressed={viewMode ==='compare'}
 className={`flex items-center gap-1 h-7 px-2.5 text-[10px] transition-colors border-l border-border ${viewMode ==='compare'?'bg-primary text-white':'text-muted-foreground hover:bg-secondary'}`}
 >
 <GitCompare size={12} />
 {t('wertstrom.editor.toolbar.viewCompare', 'Vergleich')}
 </button>
 )}
 </div>
 )}
 {/* A11y: dynamic-message live region for the view switch (P4-Brief
 "aria-live für dynamische Meldungen beim View-Umschalten"). Visually
 hidden — the toggle buttons above already show state visually via
 aria-pressed/color. Wertstrom P5: no longer gated on wertstromUxV2Enabled
 alone (same reasoning as the group above) — announces whenever the group
 itself can render. */}
 {(wertstromUxV2Enabled || canShowCompareToggle) && (
 <span className="sr-only" aria-live="polite">
 {viewMode ==='table'? t('wertstrom.editor.toolbar.viewAnnounceTable', 'Ansicht: Tabelle') : viewMode ==='compare'? t('wertstrom.editor.toolbar.viewAnnounceCompare', 'Ansicht: Vergleich') : t('wertstrom.editor.toolbar.viewAnnounceCanvas', 'Ansicht: Canvas')}
 </span>
 )}
 {/* Baustein 3 (Undo/Redo, KAR-878/KAR-986/KAR-987, P8.2b): Toolbar-Buttons,
 hinter wertstromUxV2Enabled (Brief). disabled-Pattern identisch zum
 bestehenden Speichern-Button (border/muted statt eines eigenen neuen
 Stils). historyPast/historyFuture (State, nicht die *Ref-Varianten) hier
 bewusst gelesen — der Re-Render bei jeder Push/Undo/Redo-Operation ist
 genau das, was den disabled-Zustand live aktuell hält. */}
 {wertstromUxV2Enabled && (
 <div className="flex items-center gap-1">
 <button
 type="button"
 onClick={() => undo()}
 disabled={historyPast.length === 0}
 className="flex items-center justify-center h-7 w-7 rounded-lg border border-border text-muted-foreground hover:text-primary hover:border-primary hover:bg-secondary transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:text-muted-foreground disabled:hover:border-border disabled:hover:bg-transparent"
 title={t('wertstrom.editor.toolbar.undoTitle', 'Rückgängig (Strg+Z)')}
 aria-label={t('wertstrom.editor.toolbar.undoAriaLabel', 'Rückgängig')}
 >
 <Undo2 size={14} />
 </button>
 <button
 type="button"
 onClick={() => redo()}
 disabled={historyFuture.length === 0}
 className="flex items-center justify-center h-7 w-7 rounded-lg border border-border text-muted-foreground hover:text-primary hover:border-primary hover:bg-secondary transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:text-muted-foreground disabled:hover:border-border disabled:hover:bg-transparent"
 title={t('wertstrom.editor.toolbar.redoTitle', 'Wiederholen (Strg+Umschalt+Z)')}
 aria-label={t('wertstrom.editor.toolbar.redoAriaLabel', 'Wiederholen')}
 >
 <Redo2 size={14} />
 </button>
 </div>
 )}
 {/* Optimistic Locking (Wertstrom P0, KAR-878): Konflikt-Hinweis hat Vorrang
 vor den übrigen Speicherstatus-Anzeigen — die Zeile wurde in einem anderen
 Fenster geändert, "Ungespeicherte Änderungen" wäre hier irreführend. */}
 {saveStatus ==='conflict'&& <span className="text-xs text-destructive ml-auto">{t('wertstrom.editor.toolbar.saveConflict', 'In anderem Fenster geändert — Seite neu laden')}</span>}
 {saveStatus !=='conflict'&& isDirty && <span className="text-xs text-status-yellow ml-auto">{t('wertstrom.editor.toolbar.saveUnsaved', 'Ungespeicherte Änderungen')}</span>}
 {saveStatus !=='conflict'&& !isDirty && saveStatus ==='saved'&& <span className="text-xs text-success ml-auto">{t('wertstrom.editor.toolbar.saveSaved', 'Gespeichert')}</span>}
 {saveStatus ==='error'&& <span className="text-xs text-destructive ml-auto">{t('wertstrom.editor.toolbar.saveError', 'Fehler beim Speichern')}</span>}
 <button
 onClick={() => void handleSave()}
 disabled={saving || !isDirty}
 className={`${isDirty ?'ml-auto':''} h-8 px-4 rounded-lg text-xs font-semibold transition-colors ${
 isDirty ?'bg-primary text-white hover:bg-primary-dark':'bg-border text-muted-foreground'
 }`}
 >
 {saving ? t('wertstrom.editor.toolbar.saving', 'Speichert…') : t('wertstrom.editor.toolbar.save', 'Speichern')}
 </button>
 </div>

 {/* Main area */}
 <div className="flex flex-1 overflow-hidden">
 {/* Wertstrom P4 (A14, KAR-878/KAR-986): Tabellen-View replaces palette+
 canvas ONLY while both the flag is on AND viewMode==='table' — the
 Right properties panel (below, outside this branch) stays mounted
 either way, so selecting a row here still opens the same node in the
 same panel used by the Canvas (gleiche Datenbasis, ein Panel). When
 the flag is off, viewMode can never leave its 'canvas' default (the
 toggle that would change it is itself flag-gated), so this branch is
 unreachable and the old palette+canvas markup below is byte-identical.
 Wertstrom P5 (A9): 'compare' replaces palette+canvas AND the properties
 panel (both — see the panel's own wrapping condition below), since a
 wide Ist/Soll comparison table needs the full width the way the
 Tabellen-View does not need to claim it (that one keeps the panel for
 per-node editing). */}
 {viewMode ==='compare'&& wertstromScenariosEnabled && compareTarget ? (
 <VsmScenarioCompareView
 current={{
 id: vsm.id,
 title: vsm.title,
 scenario_kind: vsm.scenario_kind ??'current',
 updated_at: updatedAt,
 nodes,
 context: { taktTimeSec, shiftModel, demandUnitsPerDay, projectLabel },
 }}
 target={compareTarget}
 headingRef={compareHeadingRef}
 // P8.2b-Review-Fix K6 (KAR-987, F1/preferred-sparse major): das
 // "Bevorzugt"-Badge braucht zusätzlich diesen Flag, nicht nur
 // wertstromScenariosEnabled (das nur die Compare-Ansicht selbst
 // freischaltet, s. Bedingung oben).
 wertstromUxV2Enabled={wertstromUxV2Enabled}
 />
 ) : viewMode ==='compare'&& wertstromScenariosEnabled && compareTargetRequestedButMissing ? (
 <div className="flex-1 flex flex-col items-center justify-center p-4 text-center">
 <h2 ref={compareHeadingRef} tabIndex={-1} className="text-sm font-semibold text-foreground outline-none mb-1">
 {t('wertstrom.editor.compare.notPossibleTitle', 'Vergleich nicht möglich')}
 </h2>
 <p className="text-xs text-muted-foreground max-w-sm">{t('wertstrom.editor.compare.notPossibleBody', 'Das ausgewählte Szenario ist nicht mehr verfügbar (gelöscht oder kein direktes Szenario dieser Karte).')}</p>
 </div>
 ) : viewMode ==='table'&& wertstromUxV2Enabled ? (
 <VsmTableView
 nodes={nodes}
 selectedNodeId={selectedNodeId}
 taktTimeSec={taktTimeSec}
 bottleneckId={bottleneckId}
 updateNode={updateNode}
 // K2-Fix (P8.2a-Review Fix-Runde): was a raw setSelectedNodeId passthrough
 // — bypassed the mutual-exclusivity clearing entirely, leaving a stale
 // selectedConnectionId (and its Verbindungs-Panel/edge highlight) live
 // after picking a node's row in the Tabellen-Ansicht.
 onSelectNode={selectNode}
 onAddNodes={(newNodes, newConnections) => {
 // P8.2b-Review-Fix K1 (Undo-Vollständigkeit erweitert, KAR-987): eigener
 // Grep-Audit fand diesen Bulk-Add-Callback (Tabellen-Ansicht) als
 // zweite zusätzliche Lücke neben linkMeasureToNode — gleiche
 // Fix-Mechanik wie handleLscImport/applyExcelImport (EIN Push für den
 // ganzen Bulk).
 pushHistoryBeforeChange()
 setNodes(prev => [...prev, ...newNodes])
 setConnections(prev => [...prev, ...newConnections])
 setIsDirty(true)
 }}
 headingRef={tableHeadingRef}
 />
 ) : (
 <>
 {/* Left palette */}
 <div className="w-48 bg-card border-r border-border flex flex-col shrink-0">
 <div className="px-4 py-3 border-b border-border">
 <h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t('wertstrom.editor.palette.title', 'Elemente')}</h3>
 <p className="text-[10px] text-muted-foreground mt-0.5">{t('wertstrom.editor.palette.dragHint', 'Auf Canvas ziehen')}</p>
 </div>
 <div className="p-2 space-y-1 overflow-y-auto flex-1">
 {(Object.entries(NODE_CONFIG) as Array<[NodeType, typeof NODE_CONFIG[NodeType]]>).map(([type, cfg]) => (
 <div
 key={type}
 className="flex items-center gap-2 px-2 py-2 rounded-lg border border-border bg-background text-xs cursor-grab hover:border-secondary hover:bg-secondary select-none transition-colors"
 // Baustein 1 (Pointer-Events-Refactor): onMouseDown -> onPointerDown +
 // isPrimary-Guard, same reasoning as the other drag-starting handlers.
 // Palette-Drag-per-Touch bleibt bewusst AUSSER Scope (Brief zählt für
 // Touch nur Node-Drag/Pan/Band-Drag/Kanten-Tap/Connect-Gesture auf, keine
 // Palette-Drag-and-Drop) — kein `touch-action` hier gesetzt (siehe
 // Abschlussbericht), dieser Handler feuert für Touch zwar (onPointerDown
 // ist geräteneutral), ein echter Drag würde aber ohne `touch-action: none`
 // auf diesem Element von der Seiten-Scroll-Geste des Browsers
 // typischerweise abgefangen.
 onPointerDown={e => {
 if (!e.isPrimary) return
 e.preventDefault()
 setPaletteDragType(type)
 setPaletteDragPos({ x: e.clientX, y: e.clientY })
 e.currentTarget.setPointerCapture?.(e.pointerId)
 activePointerIdRef.current = e.pointerId
 drag.current = { type:'palette', nodeType: type, startScreenX: e.clientX, startScreenY: e.clientY }
 }}
 >
 {/* K7-Fix (Referenzwertstrom-Fixrunde 2, KAR-878): title+aria-label auf
 dem umschließenden, NICHT aria-hidden <span> — die SVG selbst bleibt
 aria-hidden (vsm-symbols.tsx), title/aria-label müssen daher auf einem
 Element sitzen, das tatsächlich im Accessibility-Tree ankommt. Kais
 §10.5 "per Tooltip erklärbar" war an dieser Stelle KOMPLETT unerfüllt. */}
 <span title={t(VSM_SYMBOL_LABEL_KEYS[cfg.icon], VSM_SYMBOL_LABELS[cfg.icon])} aria-label={t(VSM_SYMBOL_LABEL_KEYS[cfg.icon], VSM_SYMBOL_LABELS[cfg.icon])} className="shrink-0 inline-flex">
 <VsmSymbolByKey symbolKey={cfg.icon} size={16} style={{ color: cfg.color }} />
 </span>
 <span className="text-foreground font-medium text-[10px]">{t(NODE_TYPE_LABEL_KEYS[type], cfg.label)}</span>
 <div className="ml-auto w-2 h-5 rounded"style={{ backgroundColor: cfg.color, opacity: 0.4 }} />
 </div>
 ))}
 </div>
 {/* Baustein 3 (VA-Sichtbarkeit, KAR-878): "+ Legende dezent" (Brief) für
 den grünen/grauen Kastenfuß-Balken — unflagged wie die Fußleiste selbst
 (s. showVaIndicator im Node-Rendering). Baustein 2's Zeitlinien-
 Kategorien (Warte-/Bestands-/Transportzeit) hängen hier direkt darunter
 an derselben Legende an, NUR hinter wertstromUxV2Enabled (nur dann
 existiert das Band überhaupt) — EINE Legende statt mehrerer verstreuter
 Farb-Erklärungen (Kais §11 "nicht überladen"). */}
 <div className="px-3 py-2 border-t border-border space-y-1">
 {/* K11-Fix (Referenzwertstrom-Fixrunde 2, KAR-878): 9px -> text-xs (12px,
 design-lint T11-Untergrenze) auf allen drei Zeilen dieser Legende; die
 Überschrift wechselt von der breitesten Letter-Spacing-Stufe (design-lint
 T10 — verschärft Lesbarkeit auf ohnehin schon kleinem Text) auf die
 moderate Stufe (0.025em) statt sie ganz zu entfernen — All-Caps-Text ganz
 ohne jede Extra-Laufweite wäre seinerseits eine "Buchstaben-Wand"
 (design-lint T6). Beide Regeln gleichzeitig sauber, nicht nur eine gegen
 die andere getauscht. NUR diese eine Fläche + die Zeitlinien-
 Steuerleiste unten (Brief: "kein File-weiter Font-Sweep") — die 100+
 vorbestehenden text-[9/10px]-Stellen in dieser Datei (Node-interne
 Texte, größenbeschränkt durch die kompakte Node-Box) bleiben
 unangetastet. */}
 <p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">{t('wertstrom.editor.palette.legendVaHeading', 'Wertschöpfung')}</p>
 <div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground">
 {/* "Wertschöpfend (VA)"/"Nicht wertschöpfend (NVA)" statt der bloßen
 Kastenfuß-Badge-Wörter ("✓ Wertschöpfend"/"NVA") — bewusst ANDERER
 Text als die Badges selbst, sonst kollidiert diese Legende mit jedem
 `getByText("Wertschöpfend"/"NVA")` im ganzen Bestandscode (u. a. dem
 alten isValueAdded-Checkbox-Label), erklärt die Abkürzung gleich
 mit. */}
 <span className="flex items-center gap-1"><span className="inline-block w-2 h-2 rounded-full shrink-0"style={{ backgroundColor:'var(--success)'}} />{t('wertstrom.editor.palette.legendVa', 'Wertschöpfend (VA)')}</span>
 <span className="flex items-center gap-1"><span className="inline-block w-2 h-2 rounded-full shrink-0"style={{ backgroundColor:'var(--status-grey)'}} />{t('wertstrom.editor.palette.legendNva', 'Nicht wertschöpfend (NVA)')}</span>
 </div>
 {wertstromUxV2Enabled && (
 <div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground pt-0.5">
 <span className="flex items-center gap-1"><span className="inline-block w-2 h-2 rounded-full shrink-0"style={{ backgroundColor:'var(--status-yellow)'}} />{t('wertstrom.editor.palette.legendWaitTime', 'Wartezeit')}</span>
 <span className="flex items-center gap-1"><span className="inline-block w-2 h-2 rounded-full shrink-0"style={{ backgroundColor:'var(--vsm-node-inventory)'}} />{t('wertstrom.editor.palette.legendInventory', 'Bestand')}</span>
 <span className="flex items-center gap-1"><span className="inline-block w-2 h-2 rounded-full shrink-0"style={{ backgroundColor:'var(--vsm-node-machine)'}} />{t('wertstrom.editor.palette.legendTransport', 'Transport')}</span>
 </div>
 )}
 </div>
 {/* Toolbar: zoom + import */}
 <div className="p-3 border-t border-border space-y-2">
 <div className="flex items-center gap-1">
 <button onClick={() => setZoom(z => Math.min(ZOOM_MAX, z * 1.2))} className="flex-1 h-7 rounded border border-border text-sm font-bold text-muted-foreground hover:bg-background">+</button>
 <span className="text-[10px] text-muted-foreground w-10 text-center">{Math.round(zoom * 100)}%</span>
 <button onClick={() => setZoom(z => Math.max(ZOOM_MIN, z * 0.8))} className="flex-1 h-7 rounded border border-border text-sm font-bold text-muted-foreground hover:bg-background">−</button>
 </div>
 <button onClick={handleFitView} className="w-full h-7 rounded border border-border text-[10px] text-muted-foreground hover:bg-background">{t('wertstrom.editor.palette.fitView', 'Einpassen')}</button>
 {/* Wertstrom P0 (KAR-878): ruft jetzt dieselbe Fit-View-Berechnung auf statt
 fixer pan/zoom-Werte — bei realen Wertströmen war "immer zurück auf
 (300,150)/100%" selten hilfreich, sobald Nodes woanders liegen. */}
 <button onClick={handleFitView} className="w-full h-7 rounded border border-border text-[10px] text-muted-foreground hover:bg-background">{t('wertstrom.editor.palette.reset', 'Zurücksetzen')}</button>
 {/* Wertstrom P3 (A2): flag-gated — Nicht-Scope "bestehende Editor-
 Funktionen laufen bei Flag AUS byte-gleich weiter"; the underlying
 computeAutoLayout lib stays additive/unflagged, only this entry point
 is gated. */}
 {wertstromUxV2Enabled && (
 <button onClick={handleAutoLayout} className="w-full h-7 rounded border border-border text-[10px] text-muted-foreground hover:bg-background">{t('wertstrom.editor.palette.autoLayout', 'Automatisch anordnen')}</button>
 )}
 <button onClick={() => setShowExcelImport(true)} className="w-full h-7 rounded border border-primary text-[10px] text-primary hover:bg-secondary">{t('wertstrom.editor.palette.importExcel', 'Excel importieren')}</button>
 </div>
 </div>

 {/* Canvas */}
 <div
 ref={canvasRef}
 // P8.2b-Review-Fix K2b (KAR-987): programmatisch fokussierbar (NICHT per
 // Tab erreichbar — tabIndex={-1}, kein neuer Tab-Stop), damit ein
 // Tastatur-Delete eines fokussierten Nodes den Fokus hierher zurückgeben
 // kann statt ihn ins Leere fallen zu lassen (s. globaler keydown-Handler).
 tabIndex={-1}
 // P8.2b-Review-Fix K3 (KAR-987, KAR-662-Lehre, pointer-touch-action-
 // inheritance major): `vsm-canvas-surface` aktiviert die Wildcard-CSS-Regel
 // in globals.css (`.vsm-canvas-surface, .vsm-canvas-surface * { touch-action:
 // none }`) — das Inline-`touchAction:'none'` unten bleibt zusätzlich
 // bestehen (harmlos redundant, kein Widerspruch).
 className="vsm-canvas-surface relative flex-1 overflow-hidden cursor-grab active:cursor-grabbing focus:outline-none"
 // Baustein 1 (Pointer-Events-Refactor, KAR-878/KAR-986/KAR-987, P8.2b):
 // `touch-action: none` GEZIELT auf dem Canvas (Brief: "NICHT global" — die
 // restliche Seite, inkl. der Palette links, bleibt normal scrollbar).
 // P8.2b-Review-Fix K3 (KAR-987, KAR-662-Lehre): KORRIGIERT — die vorherige
 // Annahme hier ("touch-action wirkt transitiv auf DOM-Nachfahren mit") ist
 // exakt die Annahme, die dieses Repo bereits in note-canvas.tsx empirisch
 // widerlegt hat: Safari vererbt touch-action NICHT zuverlässig an
 // Nachfahren (app/globals.css, `.note-draw-surface`-Kommentar, KAR-662).
 // Nodes/Connections-SVG/Timeline-Band liegen zwar als DOM-Nachfahren
 // INNERHALB dieses Divs, brauchen auf Safari aber zusätzlich die
 // Wildcard-CSS-Regel (`vsm-canvas-surface`-Klasse oben) UND einen
 // non-passive touchmove-Fallback (Effect weiter unten) — beide jetzt
 // ergänzt, analog zum note-canvas.tsx-Fix. Ohne dies würde ein Finger-Drag
 // auf dem Canvas vom Browser ggf. als Seiten-Scroll interpretiert, nie als
 // pointermove an dieses Element geliefert.
 style={{ height:'100%', touchAction:'none', ...gridStyle }}
 onPointerDown={handleCanvasMouseDown}
 >
 {/* World div */}
 <div
 className="absolute"
 style={{
 width: 8000,
 height: 4000,
 transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
 transformOrigin:'0 0',
 }}
 >
 {/* SVG for connections */}
 <svg
 style={{ position:'absolute', left: 0, top: 0, width: 8000, height: 4000, overflow:'visible', pointerEvents:'none'}}
 >
 <defs>
 {/* Baustein 1 (VSM-Standard-Visualisierung, KAR-878): "Kanten: materialFlow
 als Push-Pfeil-Stil, information als Blitz-Stil" — zwei eigene
 Pfeilspitzen statt der bisherigen einen (ersetzt das bisherige
 "arrowhead"; das nie referenzierte "arrowhead-red" entfällt ersatzlos,
 tote Markup, s. Abschlussbericht). Breit/stumpf (Push) vs. schlank/
 offen (elektronisch) — visuell unterscheidbar ohne neue Farbe. */}
 <marker id="vsm-arrow-material"markerWidth="11"markerHeight="9"refX="10"refY="4.5"orient="auto">
 <polygon points="0 0, 11 4.5, 0 9"style={{ fill:'var(--vsm-edge)'}} />
 </marker>
 <marker id="vsm-arrow-info"markerWidth="9"markerHeight="9"refX="8"refY="4.5"orient="auto">
 <path d="M0,0.5 L7,4.5 L0,8.5"fill="none"style={{ stroke:'var(--vsm-edge)'}} strokeWidth={1.4} strokeLinecap="round"strokeLinejoin="round" />
 </marker>
 </defs>
 {/* Referenzwertstrom-Fixrunde (KAR-878, K-ZONEN): Firmengrenzen (Kais §11)
     — dezente vertikale Trennlinien + Zonen-Labels, NUR wenn layout.zones
     definiert ist (Bestandskarten ohne zones bleiben unverändert). Vor den
     Verbindungen/Nodes gerendert, damit sie als dezenter Hintergrund
     wirken (DCT-Industrial-Flat-Stil, nicht überladen — Kais §11). */}
 {zoneBounds.length > 0 && (
 <>
 {zoneDividerXs.map((x, i) => (
 <line
 key={`zone-divider-${i}`}
 data-vsm-zone-divider="true"
 x1={x}
 y1={zoneDividerSpan.minY - 40}
 x2={x}
 y2={zoneDividerSpan.minY + zoneDividerSpan.height + 20}
 style={{ stroke:'var(--border)'}}
 strokeWidth={1}
 strokeDasharray="4 4"
 />
 ))}
 {zoneBounds.map(z => (
 <text
 key={`zone-label-${z.id}`}
 x={(z.minX + z.maxX) / 2}
 y={z.minY - 28}
 textAnchor="middle"
 fontSize={11}
 fontWeight={600}
 style={{ fill:'var(--muted-foreground)'}}
 >
 {z.label}
 </text>
 ))}
 </>
 )}
 {/* Baustein 4 (Informationsfluss entwirren, KAR-878): NUR die
 materialFlow-Kanten laufen noch über buildPath/den alten Bezier-Pfad
 ("materialFlow-Kanten unangetastet", Brief) — information-Kanten
 rendern jetzt separat weiter unten über computeInformationEdgeRoutes. */}
 {connections.filter(conn => conn.kind !=='information').map(conn => {
 const fromNode = nodes.find(n => n.id === conn.fromNodeId)
 const toNode = nodes.find(n => n.id === conn.toNodeId)
 if (!fromNode || !toNode) return null
 const from = getOutputPortPos(fromNode)
 const to = getInputPortPos(toNode)
 const isSelected = wertstromUxV2Enabled && conn.id === selectedConnectionId
 const midX = (from.x + to.x) / 2
 const midY = (from.y + to.y) / 2
 return (
 // P8.2a (Baustein 3, "Push/Pull/Kanban-Kantensemantik", KAR-878/KAR-986):
 // onPointerDown (Baustein 1, P8.2b: was onMouseDown — see
 // handleConnectionMouseDown's own doc) — and, K3-Fix below, the hit-path's
 // pointerEvents — only wired behind wertstromUxV2Enabled — a flag-off
 // session keeps this <g> exactly as inert as before this Baustein
 // (pointerdown falls through to the canvas pan-drag, unchanged). See the
 // flowControl OVERLAY MARKER further below for its own, separate K4-Fix
 // (rendering now gated behind the flag too).
 // Baustein 2 (Canvas-Tastatur + A11y, P8.2b): tabIndex/role/aria-label/
 // onKeyDown mirror the SAME wertstromUxV2Enabled gate as onPointerDown
 // above ("Kanten: fokussierbar analog (nur hinter Flag, wie die
 // Klick-Auswahl aus P8.2a)", Brief) — a flag-off session gets `undefined`
 // for every one of these props, so the <g> renders with zero extra DOM
 // attributes, unchanged from before this Baustein.
 <g
 key={conn.id}
 data-vsm-connection={conn.id}
 onPointerDown={wertstromUxV2Enabled ? e => handleConnectionMouseDown(e, conn.id) : undefined}
 onKeyDown={wertstromUxV2Enabled ? e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); selectConnection(conn.id) } } : undefined}
 tabIndex={wertstromUxV2Enabled ? 0 : undefined}
 role={wertstromUxV2Enabled ? 'button' : undefined}
 aria-label={wertstromUxV2Enabled ? `${t('wertstrom.editor.canvas.connectionAriaLabel', 'Verbindung')} ${fromNode.name} → ${toNode.name}` : undefined}
 className={wertstromUxV2Enabled ? 'focus:outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2' : undefined}
 >
 <path
 d={buildPath(from.x, from.y, to.x, to.y)}
 fill="none"
 style={{ stroke: isSelected ?'var(--primary)':'var(--vsm-edge)', pointerEvents:'none'}}
 strokeWidth={isSelected ? 3 : 2}
 markerEnd="url(#vsm-arrow-material)"
 />
 {/* K3-Fix (P8.2a-Review Fix-Runde, KAR-878/KAR-986): the visible stroke
 above (2-3px in SVG user-space) is the ENTIRE previous hit-area — at
 the reference seed's persisted zoom (0.32, wertstrom-seed.ts), 2px ×
 0.32 ≈ 0.64 SCREEN px, practically unclickable, and there was no
 affordance (no cursor:pointer) hinting a user should zoom in first.
 This invisible sibling path shares the exact same `d` geometry but
 uses `stroke="transparent"` (SVG hit-tests a transparent stroke same
 as a visible one — `pointer-events` default `visiblePainted` only
 requires a non-`none` stroke, not a non-transparent one) at a
 strokeWidth counter-scaled against the LIVE zoom (same 1/zoom
 counter-scale technique vsm-canvas-timeline.tsx's band text/drag
 handle already use against the World-div's own `scale(zoom)`
 transform, see its COUNTER_SCALE doc) so the EFFECTIVE on-screen hit
 width stays a constant ~12px regardless of zoom level, never shrinking
 below usable at any ZOOM_MIN..ZOOM_MAX (vsm-geometry.ts) step.
 pointerEvents is set ONLY here (the visible path above now explicitly
 opts out via 'none', the <g> itself carries no pointerEvents style at
 all) — one unambiguous hit target, not two overlapping ones. Z-order:
 this element sits inside the SAME <g>, inside the SAME connections
 svg element, which closes BEFORE the node divs render (nodes.map
 further down in this same JSX tree) — nodes are separate, later-
 painted DOM elements, so a wide hit-path that happens to extend under
 a node's own area never steals that node's click (the node div is
 hit-tested first, being on top). */}
 {wertstromUxV2Enabled && (
 <path
 d={buildPath(from.x, from.y, to.x, to.y)}
 fill="none"
 stroke="transparent"
 strokeWidth={12 / zoom}
 style={{ pointerEvents:'auto', cursor:'pointer'}}
 data-vsm-connection-hit={conn.id}
 />
 )}
 {(conn.label || conn.frequency) && (
 <text x={midX} y={midY - 8} textAnchor="middle"fontSize={10} style={{ fill:'var(--vsm-edge)'}}>{conn.label ?? conn.frequency}</text>
 )}
 {/* Baustein 3: additive overlay icon at the edge midpoint — same
 "icon on top of an unchanged line" technique Baustein 4 (letzte
 Runde) already established for information edges
 (VsmSymbolInfoElectronic below). Placement Judgment-Call (Brief:
 "Mitte oder Zielport — entscheide"): Mitte, konsistent mit diesem
 bereits etablierten Präzedenzfall, kein Sonderfall für Push vs.
 Kanban. Pull/Kanban teilen bewusst DASSELBE Symbol
 (VsmSymbolKanbanPull, §10.11/§10.12 "zusammengefasst", s.
 vsm-symbols.tsx).
 K4-Fix (P8.2a-Review Fix-Runde, KAR-878/KAR-986): this OVERLAY MARKER
 used to be deliberately UNFLAGGED ("renders whenever a real,
 already-persisted field value is present, regardless of the editing
 flag") — that reasoning covered the DATA (flowControl absent vs.
 present) but missed that the profile-independent reference seed
 (lib/demo/seeds/wertstrom-seed.ts) sets flowControl='kanban'
 unconditionally on two edges. A wertstromUxV2:false profile (e.g. a
 fresh customer profile cloned from config/profiles/_template.ts, whose
 own file comment names it exactly for that purpose) that loads the
 reference/demo seed would see a NEW icon with the flag off — the same
 "existing/loaded data ⇒ ungated new rendering" pattern C12
 (CHANGELOG.md) already named "major" and fixed by gating the
 RENDERING, not the data. DATA stays additive/ungated (a sparse
 DB/seed field is fine, still no migration) — only the VISUAL element
 now moves behind the flag, same split C12 established. */}
 {wertstromUxV2Enabled && conn.flowControl ==='push'&& (
 <VsmSymbolPushArrow x={midX - 8} y={midY - 8} size={16} style={{ color:'var(--vsm-edge)'}} />
 )}
 {wertstromUxV2Enabled && (conn.flowControl ==='pull'|| conn.flowControl ==='kanban') && (
 <VsmSymbolKanbanPull x={midX - 8} y={midY - 8} size={16} style={{ color:'var(--vsm-edge)'}} />
 )}
 </g>
 )
 })}
 {/* Baustein 4 (Informationsfluss entwirren, Kais-Live-Feedback 23.07.,
 KAR-878): "Viel zu unübersichtlich. Icons usw würden hier helfen." —
 orthogonales Bündel-Routing (computeInformationEdgeRoutes,
 vsm-geometry.ts) statt frei kreuzender Bezier-"Spinnen", PLUS der
 elektronische Info-Blitz (Baustein 1, VsmSymbolInfoElectronic) TRÄGT
 jetzt die visuelle Last statt eines immer sichtbaren, teils
 sehr langen Text-Labels (Kais-Seed: einzelne Labels >80 Zeichen) — der
 volle Text bleibt über die native `<title>`-Tooltip erreichbar (dasselbe
 Muster, das diese Datei bereits für die Zeitlinien-Segmente nutzt), statt
 auf dem Canvas selbst Unübersichtlichkeit zu reproduzieren, die dieser
 Baustein gerade beheben soll.
 K1-Fix (Referenzwertstrom-Fixrunde 2, KAR-878): style={{pointerEvents:
 'auto'}} auf dem <g> war HIER (anders als VsmCanvasTimelineBand's
 eigenes Wurzel-<g>, vsm-canvas-timeline.tsx:159, das denselben
 geerbten pointer-events:none vom Connections-<svg> — Zeile weiter
 unten — bereits korrekt zurücksetzt) vergessen worden — die
 <title>-Tooltip konnte dadurch in echten Browsern NIE feuern
 (pointer-events wird an Kind-Elemente vererbt), das VOR diesem PR
 permanente Text-Label war ersatzlos unerreichbar. Entscheidung
 (Brief: "prüfe ehrlich, ob Hover-only genügt"): Hover-only-Tooltip
 BLEIBT die Lösung — Kais' eigene §12-Doktrin ("progressive
 Offenlegung", Detail hinter Interaktion) deckt genau dieses Muster,
 und ein zusätzliches permanentes Kompakt-Element würde am PPS-Hub
 (bis zu 7 konvergierende Kanten, s. K9-Fix) exakt die
 Bezier-Spinnen-Unübersichtlichkeit wieder herstellen, die Baustein 4
 beheben sollte — nur mit geraden statt gebogenen Linien. Mit dem
 pointer-events-Fix funktioniert der Hover jetzt tatsächlich, das war
 vorher nicht der Fall. */}
 {informationEdgeRoutes.map(route => {
 const conn = connections.find(c => c.id === route.connectionId)
 const detail = [conn?.label, conn?.frequency].filter(Boolean).join(' · ')
 return (
 <g key={route.connectionId} data-vsm-information-edge={route.connectionId} style={{ pointerEvents:'auto'}}>
 <path
 d={route.path}
 fill="none"
 style={{ stroke:'var(--vsm-edge)'}}
 strokeWidth={1.5}
 strokeDasharray="5 3"
 markerEnd="url(#vsm-arrow-info)"
 >
 {detail && <title>{detail}</title>}
 </path>
 <VsmSymbolInfoElectronic x={route.labelX - 6} y={route.labelY - 6} size={12} style={{ color:'var(--vsm-edge)'}} />
 </g>
 )
 })}
 {/* Connection preview line */}
 {connectingFrom && (() => {
 const fromNode = nodes.find(n => n.id === connectingFrom)
 if (!fromNode) return null
 const from = getOutputPortPos(fromNode)
 return (
 <path
 d={buildPath(from.x, from.y, mouseWorldPos.x, mouseWorldPos.y)}
 fill="none"
 style={{ stroke:'var(--vsm-connect-preview)'}}
 strokeWidth={2}
 strokeDasharray="6 4"
 />
 )
 })()}
 {/* Baustein 2 (Canvas-integrierte Standard-Zeitlinie, KAR-878): lebt
 IM SELBEN SVG wie Connections/Zonen (nicht in einem eigenen Bereich) —
 teilt sich dadurch automatisch denselben pan/zoom-Transform des
 umschließenden World-Divs ("zoomt mit", Brief). Ersetzt die bisherige
 separate <details>-Sektion vollständig (s. u., entfernt); Flag wie
 bisher wertstromUxV2Enabled. */}
 {wertstromUxV2Enabled && (
 <VsmCanvasTimelineBand
 nodes={nodes}
 connections={connections}
 demandUnitsPerDay={demandUnitsPerDay}
 shiftModel={shiftModel}
 selectedNodeId={selectedNodeId}
 onSelectNode={selectAndRevealNode}
 offsetY={timelineOffsetY}
 onOffsetHandlePointerDown={handleTimelineOffsetHandleMouseDown}
 onOffsetStep={handleTimelineOffsetStep}
 // K2-Fix (Referenzwertstrom-Fixrunde 2, KAR-878): the band's own
 // text/drag-handle counter-scale by 1/zoom against THIS live value —
 // see VsmCanvasTimelineBand's own zoom prop doc.
 zoom={zoom}
 />
 )}
 </svg>

 {/* Nodes */}
 {nodes.map(node => {
 const isSelected = node.id === selectedNodeId
 const isBottleneck = node.id === bottleneckId
 const config = NODE_CONFIG[node.type]
 // Baustein 1 (VSM-Standard-Visualisierung, KAR-878): per-instance symbol —
 // refines the type-level config.icon using this node's REAL structural
 // data (Lager-Art, Versand-Kante, PPS-Signatur), see resolveNodeSymbolKey's
 // own doc comment.
 const symbolKey = resolveNodeSymbolKey(node, nodes, connections)
 const h = getNodeH(node.type)
 const showCtWarning = hasCtWarning(isBottleneck, node.cycleTimeSec, taktTimeSec)
 // Baustein 3 (VA-Sichtbarkeit, KAR-878): VA classification only applies to
 // the node types lib/vsm-engine's own timeline-ladder.ts/timeline-sequence.ts
 // actually classify as VA/NVA (process/machine/timevalue) — inventory/
 // transport/customer/supplier have no such concept and must not show a
 // spurious badge (mirrors those modules' own isExcludedFromProcessCategories
 // boundary).
 const showVaIndicator = node.type ==='process'|| node.type ==='machine'|| node.type ==='timevalue'
 // K8-Fix (Referenzwertstrom-Fixrunde 2, KAR-878): tri-state — see
 // resolveVaBadgeState's own doc comment (vsm-config.ts) for why the
 // old binary resolveIsValueAdded collapsed "Ungeklärt" and "nie
 // klassifiziert" onto the same "NVA" text as a genuine nva/nnva
 // classification.
 const vaBadgeState = resolveVaBadgeState(node)

 // Baustein 2 (Canvas-Tastatur + A11y, KAR-878/KAR-986/KAR-987, P8.2b):
 // Name + Symbol-Label (VSM_SYMBOL_LABELS, via resolveNodeSymbolLabel —
 // same resolved string the header's own title/aria-label above already
 // uses) + Typ (NODE_CONFIG[type].label), exactly die drei vom Brief
 // genannten Teile.
 // P8.2b-Review-Fix K2 (PARENT-ENTSCHEID, KAR-987, tastatur-a11y-1 critical):
 // REVIDIERT gegenüber der ursprünglichen Baustein-2-Deklaration ("nicht
 // flag-gated") — Review bewies zwei Flag-AUS-Verhaltensbrüche: (1) jeder
 // Node trug tabIndex unconditional, handleNodeMouseDown ruft nirgends
 // e.preventDefault() auf, also hinterlässt ein GEWÖHNLICHER Maus-Klick
 // (der normale, seit main bestehende Weg, einen Node auszuwählen) den
 // DOM-Fokus auf dem Node-Div — jede folgende Pfeiltaste bewegt ihn dann
 // lautlos (handleNodeKeyDown prüft nur DOM-Fokus, nicht isSelected), auch
 // bei Flag AUS, ohne sichtbaren Fokus-Ring (:focus-visible zeigt bei
 // Zeiger-Fokus keinen Ring) und ohne Undo-Möglichkeit (pushHistoryBeforeChange
 // ist zwar No-op, aber setNodes/setIsDirty in moveNodeBy sind es NICHT).
 // (2) dieselbe Tab-Erreichbarkeit vergrößerte die Tab-Kette zum Panel um
 // einen Stop pro Node, ebenfalls unconditional. Die gesamte Node-
 // Interaktions-A11y (tabIndex, role, aria-label, Fokus-Ring, onKeyDown)
 // kommt daher jetzt hinter wertstromUxV2Enabled — bei Flag AUS sind diese
 // Attribute ABWESEND (nicht tabIndex={-1}), damit ein Node-Div
 // attribut-identisch zu main bleibt. Der K7-Fix-Präzedenzfall (Symbol-
 // title/aria-label im Header, ein Satz weiter oben referenziert) bleibt
 // davon unberührt — das ist rein informativ, nie verhaltensändernd
 // (kein tabIndex/onKeyDown), anders als die hier gegatete Interaktions-A11y.
 const nodeAriaLabel = `${node.name}, ${resolveNodeSymbolLabel(t, symbolKey, node, wertstromUxV2Enabled)}, ${t(NODE_TYPE_LABEL_KEYS[node.type], config.label)}`
 return (
 <div
 key={node.id}
 className={`absolute select-none rounded-lg bg-card border-2 transition-shadow ${
 wertstromUxV2Enabled ?'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1':''
 } ${
 isSelected ?'shadow-lg border-primary':'border-border'
 }`}
 style={{
 left: node.x,
 top: node.y,
 width: NODE_W,
 height: h,
 cursor:'move',
 borderLeft: `4px solid ${config.color}`,
 }}
 onPointerDown={e => handleNodeMouseDown(e, node)}
 onKeyDown={wertstromUxV2Enabled ? e => handleNodeKeyDown(e, node) : undefined}
 tabIndex={wertstromUxV2Enabled ? 0 : undefined}
 role={wertstromUxV2Enabled ?'button': undefined}
 aria-label={wertstromUxV2Enabled ? nodeAriaLabel : undefined}
 >
 {/* Node header */}
 <div className="px-2 py-1.5 flex items-center justify-between gap-1 border-b border-border">
 <div className="flex items-center gap-1 min-w-0">
 {/* K7-Fix (Referenzwertstrom-Fixrunde 2, KAR-878): title+aria-label —
 the resolved symbolKey can be a REFINEMENT (Supermarkt/FIFO/Versand/
 PPS) beyond the generic NODE_CONFIG[type].label the panel/header
 already show elsewhere; without this, a user had no way to learn a
 given icon specifically means e.g. "Versand" rather than a generic
 Transport.
 K4-Fix (P8.2a-Review Fix-Runde, KAR-878/KAR-986): this header renders
 UNCONDITIONALLY (flag on AND off) — passing wertstromUxV2Enabled as
 the confidentLabelEnabled arg keeps the confident "Produktionssteuerung
 (PPS)" wording behind the flag, same as the flowControl overlay icon
 above. */}
 <span title={resolveNodeSymbolLabel(t, symbolKey, node, wertstromUxV2Enabled)} aria-label={resolveNodeSymbolLabel(t, symbolKey, node, wertstromUxV2Enabled)} className="shrink-0 inline-flex">
 <VsmSymbolByKey symbolKey={symbolKey} size={12} style={{ color: config.color }} />
 </span>
 <span className="text-[10px] font-semibold text-foreground truncate">{node.name}</span>
 {/* QVS-P3 (KAR-972): dezente Herkunfts-Kennzeichnung — KEIN Farbrauschen,
 nur ein gedämpftes, wortloses Symbol. Kein VSM-Standard-Symbol (Kais
 §10) — QVS-Import-Herkunft ist ein anderes Konzept, bleibt bewusst
 unangetastet (siehe Abschlussbericht). */}
 {node.qafSource && (
 <span className="text-[9px] text-muted-foreground shrink-0" title={t('wertstrom.editor.node.importedFromQaf', 'Importiert aus QAF')}>⤵</span>
 )}
 {/* Wertstrom P8.1 (A18, Baustein 1) — dezenter Blitz-Badge, NUR hinter
 wertstromUxV2 (kaizenNote ist nur über den neuen Panel-Tier setzbar;
 "Flag-aus byte-gleich" gilt auch für einen Altwert bei einem späteren
 Flag-Flip). amber-700/amber-400: AA-sicheres Warn-Muster aus PR #362.
 VSM-Standard-Visualisierung Baustein 1 (KAR-878): ⚡-Emoji ->
 VsmSymbolKaizen ("Kaizen-Blitz (P8.1-Badge ersetzen)"). */}
 {wertstromUxV2Enabled && node.kaizenNote != null && (
 <span
 className="text-amber-700 dark:text-amber-400 shrink-0 inline-flex"
 title={node.kaizenNote.trim() || t('wertstrom.editor.node.kaizenMarked', 'Kaizen-Chance markiert')}
 aria-label={`${t('wertstrom.editor.node.kaizenAriaPrefix', 'Kaizen-Chance')}: ${node.kaizenNote.trim() || t('wertstrom.editor.node.kaizenAriaFallback', 'markiert')}`}
 >
 <VsmSymbolKaizen size={9} />
 </span>
 )}
 </div>
 {isBottleneck && (
 <span className="text-[9px] font-bold px-1 py-0.5 rounded bg-destructive text-white shrink-0 inline-flex items-center gap-0.5">
 <VsmSymbolBottleneck size={9} />
 {t('wertstrom.editor.node.bottleneck', 'Engpass')}
 </span>
 )}
 </div>
 {/* Node content */}
 <div className="px-2 py-1 text-[10px] text-muted-foreground space-y-0.5">
 {node.type ==='process'&& <>
 {node.cycleTimeSec != null && <div className={`font-medium ${showCtWarning ?'text-destructive':'text-foreground'}`}>{t('wertstrom.editor.node.ctPrefix', 'CT')}: {node.cycleTimeSec}s</div>}
 {showCtWarning && <div className="text-destructive text-[9px]">↑ {t('wertstrom.editor.node.taktPrefix', 'Takt')}: {taktTimeSec}s</div>}
 </>}
 {node.type ==='machine'&& <>
 {node.oee != null && <div>{t('wertstrom.editor.node.oeePrefix', 'OEE')}: {node.oee}%</div>}
 {node.capacityPerHour != null && <div>{node.capacityPerHour} {t('wertstrom.editor.node.unitsPerHour', 'Stk/h')}</div>}
 </>}
 {node.type ==='inventory'&& <>
 {/* Wertstrom P2 (A8): sub-type label + FIFO capacity — only rendered
 when set, so a generic (undifferentiated) inventory node looks
 byte-identical to before this feature. */}
 {node.inventoryKind && <div className="font-medium text-foreground">{t(INVENTORY_KIND_LABEL_KEYS[node.inventoryKind], INVENTORY_KIND_LABELS[node.inventoryKind])}</div>}
 {/* Review-Fix F10 (adversarial review, PR #353): inventoryMaxQuantity is
 only meaningful for inventoryKind==='fifo' (Capability-Matrix A8, same
 gate the Properties-Panel field below uses) — gated here too so a stale
 capacity value left over from a since-changed Lager-Art (see the
 select's onChange above) can never render even in the one case that
 onChange doesn't already prevent (a pre-existing node saved before this
 review-fix). */}
 {node.quantity != null && <div>{node.quantity}{node.inventoryKind ==='fifo'&& node.inventoryMaxQuantity != null ? ` / ${node.inventoryMaxQuantity}`:''} {t('wertstrom.editor.node.unitsShort', 'Stk.')}</div>}
 {node.waitTimeSec != null && <div>{t('wertstrom.editor.node.waitPrefix', 'Warte')}: {node.waitTimeSec}s</div>}
 </>}
 {node.type ==='transport'&& <>
 {node.distance != null && <div>{node.distance} m</div>}
 {node.transportTimeSec != null && <div>{node.transportTimeSec}s</div>}
 </>}
 {(node.type ==='customer'|| node.type ==='supplier') && node.demand != null && <div>{node.demand} {t('wertstrom.editor.node.unitsPerDay', 'Stk/Tag')}</div>}
 {node.type ==='timevalue'&& node.cycleTimeSec != null && <div className="font-medium text-foreground">{node.cycleTimeSec}s</div>}
 {/* Baustein 3 (VA-Sichtbarkeit, KAR-878): Kais "Wertschöpfend soll besser
 sichtbar sein" — bisher NUR ein "✓ Wertschöpfend"-Hinweis, NICHTS im
 false/undefined-Fall (keine Möglichkeit, "NVA" von "nicht gesetzt" zu
 unterscheiden). Jetzt immer EINE von drei Zeilen für jeden VA-fähigen
 Node-Typ (isExcludedFromProcessCategories-Gegenstück, s.o.) — AA-konform
 über TEXT ("Wertschöpfend"/"NVA"/"Ungeklärt"), nicht nur über die
 Fußleisten-Farbe unten (nicht Farbe-only, Brief).
 K8-Fix (Referenzwertstrom-Fixrunde 2, KAR-878): dritter, neutraler
 Zustand ergänzt ("Ungeklärt", wortgleich mit VA_CLASS_LABELS.unknown im
 4-Wege-Selector) — vorher zeigte sowohl eine echte nva-Klassifikation
 als auch "nie klassifiziert"/"vaClass explizit Ungeklärt gewählt"
 identisch "NVA" an, eine Verfälschung einer expliziten Nutzereingabe. */}
 {showVaIndicator && (
 <div className={vaBadgeState === 'va' ? 'text-success-text font-medium' : 'text-muted-foreground'}>
 {vaBadgeState === 'va' ? t('wertstrom.editor.node.vaBadgeVa', '✓ Wertschöpfend') : vaBadgeState === 'nva' ? t('wertstrom.editor.node.vaBadgeNva', 'NVA') : t(VA_CLASS_LABEL_KEYS.unknown, VA_CLASS_LABELS.unknown)}
 </div>
 )}
 </div>
 {/* Baustein 3 (VA-Sichtbarkeit, KAR-878): grüner Balken am Kastenfuß bei
 Wertschöpfend, grau sonst — "Bestehende --vsm-Vars/Statusfarben nutzen"
 (Brief): --success (bereits im Editor für ✓-Wertschöpfend-Text
 verwendet) / --status-grey (DCT Status-Color-System, bereits für
 neutrale Risikostufen genutzt). Rein dekorativ (aria-hidden) — der
 Text/Symbol-Hinweis oben ist die AA-Quelle, dieser Balken ist die
 zusätzliche, nicht farbausschließliche Verstärkung.
 K8-Fix: bei 'unknown' bewusst KEIN Balken (statt eine dritte,
 kaum von --status-grey unterscheidbare Akzentfarbe zu erfinden) — der
 Balken ist rein dekorativ/aria-hidden, die textliche Aussage oben bleibt
 in jedem Fall die AA-Quelle; "kein Balken" signalisiert hier ehrlich
 "keine Aussage", statt eine schwache Farbunterscheidung vorzutäuschen. */}
 {showVaIndicator && vaBadgeState !== 'unknown' && (
 <div
 className="absolute bottom-0 left-0 right-0 h-[3px] rounded-b-lg"
 style={{ backgroundColor: vaBadgeState === 'va' ? 'var(--success)' : 'var(--status-grey)' }}
 aria-hidden="true"
 />
 )}
 {/* Input port (left) */}
 <div
 className={`absolute -left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 rounded-full border-2 border-white shadow cursor-crosshair z-10 ${
 connectingFrom ?'bg-primary hover:scale-125':'bg-border hover:bg-secondary'
 }`}
 onPointerDown={e => e.stopPropagation()}
 onClick={e => handleInputPortClick(e, node.id)}
 />
 {/* Output port (right) */}
 <div
 className={`absolute -right-2.5 top-1/2 -translate-y-1/2 w-4 h-4 rounded-full border-2 border-white shadow cursor-crosshair z-10 ${
 connectingFrom === node.id ?'bg-primary':'bg-secondary hover:bg-primary'
 }`}
 onPointerDown={e => e.stopPropagation()}
 onClick={e => handleOutputPortClick(e, node.id)}
 />
 </div>
 )
 })}
 </div>

 {/* Connecting mode hint */}
 {connectingFrom && (
 <div className="absolute top-3 left-1/2 -translate-x-1/2 flex flex-col items-center gap-1.5 z-20">
 <div className="bg-primary text-white text-xs px-3 py-1.5 rounded-full shadow-lg pointer-events-none">
 {t('wertstrom.editor.canvas.connectHint', 'Klicken Sie auf einen Eingangs-Port (●) um die Verbindung herzustellen · Esc zum Abbrechen')}
 </div>
 {/* Wertstrom P2 (A7, "Editor: Kantentyp wählen") — the kind for the
 connection currently being drawn. Deliberately NOT pointer-events-none
 (unlike the instruction pill above) — this row needs clicks. */}
 <div className="flex items-center gap-1 bg-card border border-border rounded-full shadow-lg p-1">
 {(Object.entries(CONNECTION_KIND_LABELS) as Array<[ConnectionKind, string]>).map(([kind, label]) => (
 <button
 key={kind}
 onClick={() => setConnectingKind(kind)}
 className={`text-[10px] px-2 py-1 rounded-full transition-colors ${
 connectingKind === kind ?'bg-primary text-white':'text-muted-foreground hover:bg-secondary'
 }`}
 >
 {t(CONNECTION_KIND_LABEL_KEYS[kind], label)}
 </button>
 ))}
 </div>
 {connectingKind ==='information'&& (
 <input
 type="text"
 value={connectingFrequency}
 onChange={e => setConnectingFrequency(e.target.value)}
 placeholder={t('wertstrom.editor.canvas.frequencyPlaceholder', 'Frequenz (optional)')}
 className="h-6 w-36 px-2 rounded-full border border-border text-[10px] text-foreground bg-card shadow-lg text-center focus:outline-none focus:border-primary"
 />
 )}
 {/* P8.2a (Baustein 3, "Push/Pull/Kanban-Kantensemantik", KAR-878/KAR-986):
 optionale Steuerungsart beim Zeichnen einer NEUEN materialFlow-Kante —
 Experte/Flag, wie das Panel-Äquivalent unten. Kein Eintrag ausgewählt
 (Default) heißt "kein Feld", nicht "push" — derselbe "erste Option ist
 kein stiller Default"-Vorbehalt wie bei jedem anderen sparse Feld hier.
 K6-Fix (P8.2a-Review Fix-Runde, KAR-878/KAR-986): 10px, nicht 12px
 (text-xs) — die connectingKind-Toggle-Zeile direkt darüber und das
 Frequenz-Input (beide im selben schwebenden Toolbar-Cluster) verwenden
 durchgängig dieselbe 10px-Klasse; text-xs war ein unbeabsichtigter
 Schriftgrößen-Sprung innerhalb eines einzigen, eng gestapelten
 UI-Clusters, kein bewusster neuer Stil. */}
 {wertstromUxV2Enabled && connectingKind !=='information'&& (
 <div className="flex items-center gap-1 bg-card border border-border rounded-full shadow-lg p-1">
 <button
 onClick={() => setConnectingFlowControl(undefined)}
 className={`text-[10px] px-2 py-1 rounded-full transition-colors ${
 connectingFlowControl === undefined ?'bg-primary text-white':'text-muted-foreground hover:bg-secondary'
 }`}
 >
 {t('wertstrom.editor.canvas.flowControlNone', '(kein)')}
 </button>
 {(Object.entries(FLOW_CONTROL_LABELS) as Array<[FlowControl, string]>).map(([fc, label]) => (
 <button
 key={fc}
 onClick={() => setConnectingFlowControl(fc)}
 className={`text-[10px] px-2 py-1 rounded-full transition-colors ${
 connectingFlowControl === fc ?'bg-primary text-white':'text-muted-foreground hover:bg-secondary'
 }`}
 >
 {t(FLOW_CONTROL_LABEL_KEYS[fc], label)}
 </button>
 ))}
 </div>
 )}
 </div>
 )}
 {/* Baustein 2 (VSM-Standard-Visualisierung, Kais-Live-Feedback 23.07.,
 KAR-878): Zeitlinien-Steuerleiste — lebt AUSSERHALB des pan/zoom-
 Transforms (fixe Bildschirmposition, wie der "Connecting mode hint"
 oben), damit die +/−-Buttons IMMER gleich groß/klickbar bleiben,
 unabhängig vom Canvas-Zoom (Brief: "Drag am Band-Griff ODER
 +/−-Steuerung" — beide Wege sind verdrahtet). Der Durchlaufzeit-
 Hilfetext (Kais §12) wandert hierher ans Band (Brief) — die Zeitlinie
 selbst ist jetzt reines SVG im Canvas, ohne sinnvollen HTML-Tooltip-
 Anker. */}
 {wertstromUxV2Enabled && nodes.length > 0 && (
 <div className="absolute bottom-3 left-3 flex items-center gap-1.5 bg-card border border-border rounded-full shadow-lg px-2 py-1 z-20">
 {/* K11-Fix (Referenzwertstrom-Fixrunde 2, KAR-878): 10px -> text-xs
 (12px, design-lint T11) auf Label + beiden ±-Buttons; Klickfläche
 w-5 h-5 (20px) -> w-6 h-6 (24px, Brief: "±-Klickfläche ≥24px"). NUR
 diese Zeitlinien-Steuerleiste + die VA-Legende oben — kein
 File-weiter Font-Sweep. */}
 <VsmSymbolTimelineMarker size={12} className="text-muted-foreground shrink-0" />
 <span className="text-xs text-muted-foreground flex items-center gap-0.5">
 {t('wertstrom.editor.canvas.timelineLabel', 'Zeitlinie')}
 <FieldHint hint={durchlaufzeitHint(t)} />
 </span>
 <button
 type="button"
 onClick={() => handleTimelineOffsetStep(-10)}
 aria-label={t('wertstrom.editor.canvas.timelineCloserAriaLabel', 'Zeitlinie näher an die Elemente')}
 className="w-6 h-6 rounded-full border border-border text-xs font-bold text-muted-foreground hover:bg-secondary hover:text-foreground"
 >
 −
 </button>
 <button
 type="button"
 onClick={() => handleTimelineOffsetStep(10)}
 aria-label={t('wertstrom.editor.canvas.timelineFartherAriaLabel', 'Zeitlinie weiter von den Elementen weg')}
 className="w-6 h-6 rounded-full border border-border text-xs font-bold text-muted-foreground hover:bg-secondary hover:text-foreground"
 >
 +
 </button>
 {/* K4-Fix (Referenzwertstrom-Fixrunde 2, KAR-878): dedicated reset —
 with the ± stepper fixed at 10px/click and no upper clamp before this
 fix, recovering from one large accidental drag/click-run could take
 hundreds of clicks. Direct setTimelineOffsetY(0) (not
 handleTimelineOffsetStep, which only ever applies a DELTA) — same
 "live UI preference, never itself a dirty-triggering change" posture
 as the ± stepper/drag handle (no setIsDirty here either; the value
 still travels along on the next save/autosave regardless, per
 timelineOffsetYRef's own doc comment). Only shown once the band has
 actually been nudged away from its default resting position — a
 no-op reset button sitting there unconditionally would be
 Kais-§11-"überladen" clutter for the common case.
 */}
 {timelineOffsetY !== 0 && (
 <button
 type="button"
 onClick={() => setTimelineOffsetY(0)}
 aria-label={t('wertstrom.editor.canvas.timelineResetAriaLabel', 'Zeitlinie zurücksetzen')}
 title={t('wertstrom.editor.canvas.timelineResetAriaLabel', 'Zeitlinie zurücksetzen')}
 className="h-6 px-1.5 rounded-full border border-border text-xs font-medium text-muted-foreground hover:bg-secondary hover:text-foreground"
 >
 ↺
 </button>
 )}
 </div>
 )}
 </div>
 </>
 )}

 {/* Right properties panel — Wertstrom P5 (A9): suppressed while
 viewMode==='compare' (the Vergleichs-Ansicht claims the full width
 instead, same reasoning as the palette+canvas branch above); mounted in
 both other modes exactly as before P5 (byte-identical). */}
 {viewMode !=='compare'&& (
 <>
 {/* Right properties panel */}
 {/* Baustein 3 (Undo/Redo, KAR-878/KAR-986/KAR-987, P8.2b): Feld-Commit
 im Panel — EIN Handler-Paar auf diesem gemeinsamen äußeren Container
 deckt BEIDE Panel-Varianten (NEW/wertstromUxV2Enabled unten UND das
 alte flache Panel) sowie das Verbindungs-Panel automatisch mit ab,
 ohne jedes der 60+ Felder einzeln anzufassen. onFocusCapture merkt den
 Vorher-Zustand beim ERSTEN Fokus-Eintritt; onBlurCapture committet ihn
 NUR wenn der Fokus das PANEL wirklich VERLÄSST (e.relatedTarget liegt
 außerhalb) — ein Tab-Wechsel zwischen zwei Feldern IM SELBEN Panel
 bündelt beide Änderungen bewusst zu EINEM Undo-Schritt (Brief: "Feld-
 Commit = blur/Enter", hier realisiert als "Panel verlassen", nicht als
 Bindestrich-genaues Feld-für-Feld-Blur — deklariert im
 Abschlussbericht). Unconditional gesetzt (nicht flag-gated): beide
 Handler-Funktionen selbst no-open hinter wertstromUxV2Enabled
 (commitHistorySnapshotIfChanged), reine Funktionsreferenzen ändern das
 gerenderte HTML nicht. */}
 <div
 ref={panelContainerRef}
 className="w-64 bg-card border-l border-border flex flex-col shrink-0 overflow-y-auto"
 onFocusCapture={handlePanelFocusCapture}
 onBlurCapture={handlePanelBlurCapture}
 >
 {selectedNode ? wertstromUxV2Enabled ? (
 // Wertstrom P4 (A13, Capability-Matrix, KAR-878/KAR-986): Standard/
 // Expert-Trennung + Progressive Disclosure (§10.2/§7.1/§7.2). Essential
 // fields stay directly visible (Name, Zykluszeit, Anzahl Mitarbeiter,
 // Verfügbarkeit — the B3 Standard OEE field); Advanced (the 9
 // previously UI-less editable fields from lib/vsm-types plus the
 // pre-existing time-breakdown/Prozessart fields) and Expert (MTBF/MTTR,
 // B3) sit behind PanelSection's native <details> (closed by default).
 // Provenance-/FieldStatus-Anzeigen (P2) travel with each field exactly
 // as before, including the 9 newly-UI'd ones (they were always part of
 // VsmNodeEditableFieldKey — updateNode's fieldStatus derivation already
 // covers them with zero changes here). This whole branch renders ONLY
 // while the flag is on; the ORIGINAL flat panel (untouched, below) is
 // what a flag-off session still sees, byte-identical.
 <div className="p-4 space-y-4">
 <div className="flex items-center gap-2 pb-2 border-b border-border">
 {/* K7-Fix (Referenzwertstrom-Fixrunde 2, KAR-878): title+aria-label —
 same reasoning as the Canvas-Node-Header site: the resolved symbol
 can be a more specific refinement than the generic
 NODE_CONFIG[type].label shown right next to it (h3 below). */}
 {(() => {
 const panelSymbolKey = resolveNodeSymbolKey(selectedNode, nodes, connections)
 return (
 // K4-Fix (P8.2a-Review Fix-Runde): this branch only renders while
 // wertstromUxV2Enabled is true anyway (see the ternary above) —
 // passing it explicitly is a no-op today, kept for consistency with
 // the Canvas-Node-Header/flat-panel call sites (all three now state
 // the same invariant explicitly instead of two of three relying on
 // it implicitly).
 <span title={resolveNodeSymbolLabel(t, panelSymbolKey, selectedNode, wertstromUxV2Enabled)} aria-label={resolveNodeSymbolLabel(t, panelSymbolKey, selectedNode, wertstromUxV2Enabled)} className="shrink-0 inline-flex">
 <VsmSymbolByKey symbolKey={panelSymbolKey} size={16} style={{ color: NODE_CONFIG[selectedNode.type].color }} />
 </span>
 )
 })()}
 <h3 className="text-xs font-semibold text-foreground">{t(NODE_TYPE_LABEL_KEYS[selectedNode.type], NODE_CONFIG[selectedNode.type].label)}</h3>
 </div>

 {/* Review-Fix F13 (adversarial review, PR #355): role="heading"/aria-level
 gives this the same screen-reader Heading-List presence a real <h4> would
 (NVDA/JAWS Insert+F7, VoiceOver Rotor) — not a real heading element itself
 to avoid touching the existing visual/flow semantics, same technique
 PanelSection's own summary uses below for Erweitert/Experte. */}
 <div role="heading" aria-level={4} className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">{t('wertstrom.editor.panel.basicSection', 'Basis')}</div>

 <div>
 {/* Review-Fix F11 (adversarial review, PR #355): htmlFor/id — every field
 in this new panel gets this pairing (see each subsequent field below);
 nested input-in-label is the OTHER acceptable variant this repo already
 uses (the Wertschöpfend checkbox further down), but htmlFor/id is the
 majority pattern here and in vsm-quick-start-wizard.tsx, so it's the one
 consistently applied throughout this panel. */}
 <label htmlFor="vsm-panel-name" className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">
 {t('wertstrom.editor.panel.nameLabel', 'Name')}
 <FieldStatusTag status={selectedNode.fieldStatus?.name} />
 </label>
 <input
 id="vsm-panel-name"
 type="text"
 value={selectedNode.name}
 onChange={e => updateNode(selectedNode.id, { name: e.target.value })}
 className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary"
 />
 </div>

 {selectedNode.qafSource && (
 <button
 onClick={() => setShowQafSource(true)}
 className="w-full h-8 rounded-lg border border-border text-[10px] text-muted-foreground hover:border-primary hover:text-primary hover:bg-secondary transition-colors"
 >
 {t('wertstrom.editor.panel.showSource', 'Quelle anzeigen')}
 </button>
 )}

 {selectedNode.type ==='process'&& (
 <>
 <div>
 <label htmlFor="vsm-panel-cycle-time-sec" className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.cycleTimeSecLabel', 'Zykluszeit (s)')}<FieldHint hint={hints['vsm.cycle_time']} /><FieldStatusTag status={selectedNode.fieldStatus?.cycleTimeSec} /><ProvenanceTag provenance={selectedNode.provenance} /></label>
 <input
 id="vsm-panel-cycle-time-sec"
 type="number"
 min={0}
 value={selectedNode.cycleTimeSec ??''}
 onChange={e => updateNode(selectedNode.id, {
 cycleTimeSec: e.target.value ? Number(e.target.value) : undefined,
 provenance: undefined,
 })}
 className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary"
 />
 {qafVsMeasured && (
 <p className="mt-1 text-[10px] text-muted-foreground">
 {t('wertstrom.editor.panel.qafPrefix', 'QAF')} {qafVsMeasured.qafSec.toLocaleString('de-DE')} s · {t('wertstrom.editor.panel.measuredAvgPrefix', 'Gemessen Ø')} {qafVsMeasured.measuredSec.toLocaleString('de-DE')} s
 {qafVsMeasured.deltaPercent != null && ` · ${qafVsMeasured.deltaPercent > 0 ? '+' : ''}${qafVsMeasured.deltaPercent.toFixed(1)} %`}
 </p>
 )}
 </div>
 <div>
 <label htmlFor="vsm-panel-num-workers" className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.numWorkersLabel', 'Anzahl Mitarbeiter')}<FieldHint hint={hints['vsm.num_workers']} /><FieldStatusTag status={selectedNode.fieldStatus?.numWorkers} /></label>
 <input
 id="vsm-panel-num-workers"
 type="number"
 min={0}
 value={selectedNode.numWorkers ??''}
 onChange={e => updateNode(selectedNode.id, { numWorkers: e.target.value ? Number(e.target.value) : undefined })}
 className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary"
 />
 </div>
 <div>
 <label htmlFor="vsm-panel-availability-pct" className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.availabilityPctLabel', 'Verfügbarkeit (%)')}<FieldHint hint={hints['vsm.availability_pct']} /></label>
 <input
 id="vsm-panel-availability-pct"
 type="number"
 min={0}
 max={100}
 value={selectedNode.availabilityPct ??''}
 onChange={e => updateNode(selectedNode.id, { availabilityPct: e.target.value ? Number(e.target.value) : undefined })}
 className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary"
 />
 </div>
 <EffectiveCapacityDisplay node={selectedNode} taktTimeSec={taktTimeSec} />

 {/* Inline stopwatch (5.3) — unverändert */}
 <div className="border-t border-border pt-3 space-y-2">
 <button
 onClick={() => setSwShowPanel(v => !v)}
 className="w-full h-8 rounded-lg border border-border text-[10px] text-muted-foreground hover:border-primary hover:text-primary hover:bg-secondary flex items-center justify-center gap-1.5 transition-colors"
 >
 <Timer size={12} />
 {swShowPanel ? t('wertstrom.editor.panel.stopwatchHide', 'Zeitmessung ausblenden') : t('wertstrom.editor.panel.stopwatchShow', 'Zeitmessung starten')}
 </button>
 {swShowPanel && (
 <div className="bg-background rounded-lg p-3 space-y-2">
 <div className="text-center font-mono text-lg font-bold text-primary-dark">
 {swElapsed.toFixed(1)}s
 </div>
 <div className="flex gap-1">
 {!swRunning ? (
 <button
 onClick={swStart}
 className="flex-1 h-7 rounded-md bg-primary text-white text-[10px] font-semibold hover:bg-primary-dark transition-colors"
 >
 {swElapsed > 0 ? t('wertstrom.editor.panel.stopwatchResume', 'Weiter') : t('wertstrom.editor.panel.stopwatchStart', 'Start')}
 </button>
 ) : (
 <button
 onClick={swStop}
 className="flex-1 h-7 rounded-md bg-destructive text-white text-[10px] font-semibold hover:bg-destructive transition-colors"
 >
 {t('wertstrom.editor.panel.stopwatchStop', 'Stop')}
 </button>
 )}
 <button
 onClick={swReset}
 className="h-7 px-2 rounded-md border border-border text-[10px] text-muted-foreground hover:bg-card transition-colors"
 >
 {t('wertstrom.editor.panel.stopwatchReset', 'Reset')}
 </button>
 </div>
 {!swRunning && swElapsed > 0 && (
 <button
 onClick={() => {
 if (applyProvenanceGuardedCycleTime(selectedNode, Math.round(swElapsed * 100) / 100, 'measured')) swReset()
 }}
 className="w-full h-7 rounded-md bg-success text-white text-[10px] font-semibold hover:bg-status-green transition-colors"
 >
 {interpolate(t('wertstrom.editor.panel.applyCycleTime', 'Als Zykluszeit übernehmen ({sec}s)'), { sec: swElapsed.toFixed(1) })}
 </button>
 )}
 </div>
 )}
 </div>

 {processSteps.length > 0 && (
 <button
 onClick={() => setShowStoppuhrPicker(true)}
 className="w-full h-8 rounded-lg border border-primary text-[10px] text-primary hover:bg-secondary transition-colors"
 >
 {t('wertstrom.editor.panel.importFromStopwatch', 'Aus Stoppuhr importieren')}
 </button>
 )}
 </>
 )}

 {selectedNode.type ==='machine'&& (
 <>
 <div>
 <label htmlFor="vsm-panel-oee" className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.oeeLabel', 'OEE (%)')}<FieldHint hint={hints['vsm.oee']} /></label>
 <input
 id="vsm-panel-oee"
 type="number"
 min={0}
 max={100}
 value={selectedNode.oee ??''}
 onChange={e => updateNode(selectedNode.id, { oee: e.target.value ? Number(e.target.value) : undefined })}
 className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary"
 />
 </div>
 <div>
 <label htmlFor="vsm-panel-machine-capacity-per-hour" className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.capacityPerHourLabel', 'Kapazität (Stk/h)')}<FieldHint hint={hints['vsm.capacity_per_hour']} /></label>
 <input
 id="vsm-panel-machine-capacity-per-hour"
 type="number"
 min={0}
 value={selectedNode.capacityPerHour ??''}
 onChange={e => updateNode(selectedNode.id, { capacityPerHour: e.target.value ? Number(e.target.value) : undefined })}
 className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary"
 />
 </div>
 </>
 )}

 {selectedNode.type ==='inventory'&& (
 <>
 <div>
 <label htmlFor="vsm-panel-inventory-kind" className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.inventoryKindLabel', 'Lager-Art')}</label>
 <select
 id="vsm-panel-inventory-kind"
 value={selectedNode.inventoryKind ??''}
 onChange={e => {
 const nextKind = (e.target.value as InventoryKind) || undefined
 updateNode(selectedNode.id, {
 inventoryKind: nextKind,
 ...(nextKind !=='fifo'? { inventoryMaxQuantity: undefined } : {}),
 })
 }}
 className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary bg-card"
 >
 <option value="">{t('wertstrom.editor.panel.genericOption', '— generisch —')}</option>
 {(Object.entries(INVENTORY_KIND_LABELS) as Array<[InventoryKind, string]>).map(([value, label]) => (
 <option key={value} value={value}>{t(INVENTORY_KIND_LABEL_KEYS[value as InventoryKind], label)}</option>
 ))}
 </select>
 </div>
 <div>
 <label htmlFor="vsm-panel-inventory-quantity" className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.quantityLabel', 'Menge (Stk)')}<FieldHint hint={hints['vsm.inventory_quantity']} /></label>
 <input
 id="vsm-panel-inventory-quantity"
 type="number"
 min={0}
 value={selectedNode.quantity ??''}
 onChange={e => updateNode(selectedNode.id, { quantity: e.target.value ? Number(e.target.value) : undefined })}
 className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary"
 />
 </div>
 </>
 )}

 {selectedNode.type ==='transport'&& (
 <>
 <div>
 <label htmlFor="vsm-panel-transport-time-sec" className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.transportTimeSecLabel', 'Transportzeit (s)')}<FieldHint hint={hints['vsm.transport_time']} /></label>
 <input
 id="vsm-panel-transport-time-sec"
 type="number"
 min={0}
 value={selectedNode.transportTimeSec ??''}
 onChange={e => updateNode(selectedNode.id, { transportTimeSec: e.target.value ? Number(e.target.value) : undefined })}
 className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary"
 />
 </div>
 <div>
 <label htmlFor="vsm-panel-transport-frequency" className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.frequencyLabel', 'Frequenz')}<FieldHint hint={hints['vsm.transport_frequency']} /></label>
 <input
 id="vsm-panel-transport-frequency"
 type="text"
 value={selectedNode.transportFrequency ??''}
 onChange={e => updateNode(selectedNode.id, { transportFrequency: e.target.value || undefined })}
 placeholder={t('wertstrom.editor.panel.frequencyPlaceholder', 'z. B. 3x täglich')}
 className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary"
 />
 </div>
 </>
 )}

 {(selectedNode.type ==='customer'|| selectedNode.type ==='supplier') && (
 <div>
 <label htmlFor="vsm-panel-demand" className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.node.unitsPerDay', 'Stk/Tag')}<FieldHint hint={hints['vsm.demand_per_day']} /></label>
 <input
 id="vsm-panel-demand"
 type="number"
 min={0}
 value={selectedNode.demand ??''}
 onChange={e => updateNode(selectedNode.id, { demand: e.target.value ? Number(e.target.value) : undefined })}
 className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary"
 />
 </div>
 )}

 {selectedNode.type ==='timevalue'&& (
 <div>
 <label htmlFor="vsm-panel-timevalue-cycle-time-sec" className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.timeValueSecLabel', 'Zeitwert (s)')}<FieldHint hint={hints['vsm.time_value']} /></label>
 <input
 id="vsm-panel-timevalue-cycle-time-sec"
 type="number"
 min={0}
 value={selectedNode.cycleTimeSec ??''}
 onChange={e => updateNode(selectedNode.id, { cycleTimeSec: e.target.value ? Number(e.target.value) : undefined, provenance: undefined })}
 className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary"
 />
 </div>
 )}

 {selectedNode.type ==='process'&& (
 <PanelSection title={t('wertstrom.editor.panel.advancedSection', 'Erweitert')}>
 <div>
 <label htmlFor="vsm-panel-machine-time-sec" className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.machineTimeSecLabel', 'Maschinenzeit (s)')}<FieldHint hint={hints['vsm.machine_time']} /></label>
 <input id="vsm-panel-machine-time-sec" type="number" min={0} value={selectedNode.machineTimeSec ??''} onChange={e => updateNode(selectedNode.id, { machineTimeSec: e.target.value ? Number(e.target.value) : undefined })} className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary" />
 </div>
 <div>
 <label htmlFor="vsm-panel-manual-time-sec" className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.manualTimeSecLabel', 'Manuelle Zeit (s)')}<FieldHint hint={hints['vsm.manual_time']} /></label>
 <input id="vsm-panel-manual-time-sec" type="number" min={0} value={selectedNode.manualTimeSec ??''} onChange={e => updateNode(selectedNode.id, { manualTimeSec: e.target.value ? Number(e.target.value) : undefined })} className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary" />
 </div>
 <div>
 <label htmlFor="vsm-panel-setup-time-sec" className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.setupTimeSecLabel', 'Rüstzeit (s)')}<FieldHint hint={hints['vsm.setup_time']} /></label>
 <input id="vsm-panel-setup-time-sec" type="number" min={0} value={selectedNode.setupTimeSec ??''} onChange={e => updateNode(selectedNode.id, { setupTimeSec: e.target.value ? Number(e.target.value) : undefined })} className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary" />
 </div>
 <div>
 <label htmlFor="vsm-panel-wait-time-sec" className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.waitTimeSecLabel', 'Wartezeit (s)')}<FieldHint hint={hints['vsm.wait_time']} /></label>
 <input id="vsm-panel-wait-time-sec" type="number" min={0} value={selectedNode.waitTimeSec ??''} onChange={e => updateNode(selectedNode.id, { waitTimeSec: e.target.value ? Number(e.target.value) : undefined })} className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary" />
 </div>
 <div>
 <label htmlFor="vsm-panel-process-capacity-per-hour" className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.capacityPerHourLabel', 'Kapazität (Stk/h)')}<FieldHint hint={hints['vsm.capacity_per_hour']} /></label>
 <input id="vsm-panel-process-capacity-per-hour" type="number" min={0} value={selectedNode.capacityPerHour ??''} onChange={e => updateNode(selectedNode.id, { capacityPerHour: e.target.value ? Number(e.target.value) : undefined })} className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary" />
 </div>
 <div>
 <label htmlFor="vsm-panel-process-type" className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.processTypeLabel', 'Prozessart')}<FieldHint hint={hints['vsm.process_type']} /></label>
 <select
 id="vsm-panel-process-type"
 value={selectedNode.processType ??''}
 onChange={e => updateNode(selectedNode.id, { processType: (e.target.value as ProcessType) || undefined })}
 className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary bg-card"
 >
 <option value="">{t('wertstrom.editor.panel.selectPlaceholder', '— wählen —')}</option>
 <option value="manual">{t('wertstrom.editor.panel.processTypeManual', 'Manuell')}</option>
 <option value="robot">{t('wertstrom.editor.panel.processTypeRobot', 'Roboter')}</option>
 <option value="human_robot">{t('wertstrom.editor.panel.processTypeHumanRobot', 'Mensch + Roboter')}</option>
 <option value="machine">{t(NODE_TYPE_LABEL_KEYS.machine, 'Maschine')}</option>
 </select>
 </div>
 {selectedNode.processType ==='machine'&& (
 <div>
 <label htmlFor="vsm-panel-machine-type" className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">
 {t('wertstrom.editor.panel.machineTypeLabel', 'Maschinentyp')}
 <FieldStatusTag status={selectedNode.fieldStatus?.machineType} />
 </label>
 <input
 id="vsm-panel-machine-type"
 type="text"
 value={selectedNode.machineType ??''}
 onChange={e => updateNode(selectedNode.id, { machineType: e.target.value || undefined })}
 placeholder={t('wertstrom.editor.panel.machineTypePlaceholder', 'z.B. Spritzgussmaschine')}
 className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary"
 />
 </div>
 )}

 <div>
 <label htmlFor="vsm-panel-parts-per-cycle" className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.partsPerCycleLabel', 'Teile pro Zyklus')}<FieldHint hint={hints['vsm.parts_per_cycle']} /><FieldStatusTag status={selectedNode.fieldStatus?.partsPerCycle} /></label>
 <input id="vsm-panel-parts-per-cycle" type="number" min={0} value={selectedNode.partsPerCycle ??''} onChange={e => updateNode(selectedNode.id, { partsPerCycle: e.target.value ? Number(e.target.value) : undefined })} className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary" />
 </div>
 <div>
 <label htmlFor="vsm-panel-location" className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.locationLabel', 'Standort')}<FieldHint hint={hints['vsm.location']} /><FieldStatusTag status={selectedNode.fieldStatus?.location} /></label>
 <input id="vsm-panel-location" type="text" value={selectedNode.location ??''} onChange={e => updateNode(selectedNode.id, { location: e.target.value || undefined })} className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary" />
 </div>
 <div>
 <label htmlFor="vsm-panel-currency" className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.currencyLabel', 'Währung')}<FieldHint hint={hints['vsm.currency']} /><FieldStatusTag status={selectedNode.fieldStatus?.currency} /></label>
 {/* Review-Fix F7 (adversarial review, PR #355): normalizeCurrency
 (trim + toUpperCase, re-exported from the qaf-differences module's
 PUBLIC barrel — @/lib/qaf-differences, not a deep import into its
 internal/ — see that barrel's own "no deep imports into internal/"
 doc comment) reused instead of writing the raw keystrokes straight
 through. Before this, 'eur'/'EUR'/'Eur ' typed here landed in
 DIFFERENT buckets in computeCostPerUnitRollup (vsm-metrics.ts, which
 only .trim()s, no case-fold) — this field used to be QAF-import-only
 (always already normalized upstream), so a free-text keyboard path
 for it is new in this PR. */}
 <input
 id="vsm-panel-currency"
 type="text"
 value={selectedNode.currency ??''}
 onChange={e => updateNode(selectedNode.id, { currency: normalizeCurrency(e.target.value) ?? undefined })}
 placeholder={t('wertstrom.editor.panel.currencyPlaceholder', 'z. B. EUR')}
 className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary"
 />
 </div>
 <div>
 <label htmlFor="vsm-panel-scrap-rate" className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.scrapRateLabel', 'Ausschussrate (%)')}<FieldHint hint={hints['vsm.scrap_rate']} /><FieldStatusTag status={selectedNode.fieldStatus?.scrapRate} /></label>
 <input id="vsm-panel-scrap-rate" type="number" min={0} max={100} value={selectedNode.scrapRate ??''} onChange={e => updateNode(selectedNode.id, { scrapRate: e.target.value ? Number(e.target.value) : undefined })} className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary" />
 </div>
 <div>
 <label htmlFor="vsm-panel-scrap-cost-per-unit" className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.scrapCostPerUnitLabel', 'Ausschusskosten je Teil')}<FieldHint hint={hints['vsm.scrap_cost_per_unit']} /><FieldStatusTag status={selectedNode.fieldStatus?.scrapCostPerUnit} /></label>
 <input id="vsm-panel-scrap-cost-per-unit" type="number" min={0} value={selectedNode.scrapCostPerUnit ??''} onChange={e => updateNode(selectedNode.id, { scrapCostPerUnit: e.target.value ? Number(e.target.value) : undefined })} className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary" />
 </div>
 <div>
 <label htmlFor="vsm-panel-machine-hour-rate" className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.machineHourRateLabel', 'Maschinenstundensatz')}<FieldHint hint={hints['vsm.machine_hour_rate']} /><FieldStatusTag status={selectedNode.fieldStatus?.machineHourRate} /></label>
 <input id="vsm-panel-machine-hour-rate" type="number" min={0} value={selectedNode.machineHourRate ??''} onChange={e => updateNode(selectedNode.id, { machineHourRate: e.target.value ? Number(e.target.value) : undefined })} className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary" />
 </div>
 <div>
 <label htmlFor="vsm-panel-labor-hour-rate" className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.laborHourRateLabel', 'Lohnkostensatz (je Stunde)')}<FieldHint hint={hints['vsm.labor_hour_rate']} /><FieldStatusTag status={selectedNode.fieldStatus?.laborHourRate} /></label>
 <input id="vsm-panel-labor-hour-rate" type="number" min={0} value={selectedNode.laborHourRate ??''} onChange={e => updateNode(selectedNode.id, { laborHourRate: e.target.value ? Number(e.target.value) : undefined })} className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary" />
 </div>
 <div>
 <label htmlFor="vsm-panel-setup-cost-per-unit" className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.setupCostPerUnitLabel', 'Rüstkosten je Einheit')}<FieldHint hint={hints['vsm.setup_cost_per_unit']} /><FieldStatusTag status={selectedNode.fieldStatus?.setupCostPerUnit} /></label>
 <input id="vsm-panel-setup-cost-per-unit" type="number" min={0} value={selectedNode.setupCostPerUnit ??''} onChange={e => updateNode(selectedNode.id, { setupCostPerUnit: e.target.value ? Number(e.target.value) : undefined })} className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary" />
 </div>
 <div>
 <label htmlFor="vsm-panel-cost-per-unit" className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.costPerUnitLabel', 'Herstellkosten je Teil')}<FieldHint hint={hints['vsm.cost_per_unit']} /><FieldStatusTag status={selectedNode.fieldStatus?.costPerUnit} /></label>
 <input id="vsm-panel-cost-per-unit" type="number" min={0} value={selectedNode.costPerUnit ??''} onChange={e => updateNode(selectedNode.id, { costPerUnit: e.target.value ? Number(e.target.value) : undefined })} className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary" />
 </div>

 {selectedNode.qafSource ? (
 <div>
 <label htmlFor="vsm-panel-va-class" className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">
 {t('wertstrom.editor.panel.vaClassLabel', 'Wertschöpfung')}
 <FieldHint hint={hints['vsm.value_added']} />
 <FieldStatusTag status={selectedNode.fieldStatus?.vaClass} />
 </label>
 <select
 id="vsm-panel-va-class"
 value={deriveVaClassSelection(selectedNode)}
 onChange={e => updateVaClass(selectedNode.id, e.target.value as VaClass)}
 className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary bg-card"
 >
 {(Object.entries(VA_CLASS_LABELS) as Array<[VaClass, string]>).map(([value, label]) => (
 <option key={value} value={value}>{t(VA_CLASS_LABEL_KEYS[value as VaClass], label)}</option>
 ))}
 </select>
 {legacyValueAddedHint(selectedNode) !== null && (
 <p className="mt-1 text-[10px] text-muted-foreground">
 {interpolate(t('wertstrom.editor.panel.legacyVaHint', 'Noch nicht eingeordnet — Alt-Wert „Wertschöpfend”: {value}'), { value: legacyValueAddedHint(selectedNode) ? t('wertstrom.editor.common.yes', 'Ja') : t('wertstrom.editor.common.no', 'Nein') })}
 </p>
 )}
 </div>
 ) : (
 <label className="flex items-center gap-2 cursor-pointer">
 <input
 type="checkbox"
 checked={selectedNode.isValueAdded ?? false}
 onChange={e => updateNode(selectedNode.id, { isValueAdded: e.target.checked })}
 className="w-4 h-4 rounded border border-border accent-primary"
 />
 <span className="text-xs text-foreground">{t('wertstrom.editor.panel.valueAddedCheckbox', 'Wertschöpfend')}</span>
 <FieldHint hint={hints['vsm.value_added']} />
 </label>
 )}
 </PanelSection>
 )}

 {selectedNode.type ==='process'&& (
 <PanelSection title={t('wertstrom.editor.panel.expertSection', 'Experte')}>
 {/* P8.2a (Baustein 2, "PPS als echtes Datenfeld", KAR-878/KAR-986):
 explizite Markierung des zentralen Produktionssteuerung/PPS-Elements
 (Kais §5) — gewinnt gegenüber resolveNodeSymbolKeys strukturellem
 Heuristik-Fallback, sobald gesetzt (s. dessen Doku). Sparse Write: nur
 `{ isPps: true }` wird je gesendet; Abwählen setzt `undefined`
 (K3-Muster — entfällt beim Speichern ganz aus dem JSON, nie ein
 explizites `false`, s. lib/vsm-types.ts VsmNode.isPps). */}
 <label className="flex items-center gap-2 cursor-pointer">
 <input
 type="checkbox"
 checked={selectedNode.isPps ?? false}
 onChange={e => updateNode(selectedNode.id, { isPps: e.target.checked ? true : undefined })}
 className="w-4 h-4 rounded border border-border accent-primary"
 />
 <span className="text-xs text-foreground">{t('wertstrom.editor.panel.ppsCheckbox', 'Produktionssteuerung (PPS)')}</span>
 <FieldHint hint={hints['vsm.is_pps']} />
 </label>
 <div>
 <label htmlFor="vsm-panel-mtbf-min" className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.mtbfMinLabel', 'MTBF (Min.)')}<FieldHint hint={hints['vsm.mtbf_min']} /></label>
 <input id="vsm-panel-mtbf-min" type="number" min={0} value={selectedNode.mtbfMin ??''} onChange={e => updateNode(selectedNode.id, { mtbfMin: e.target.value ? Number(e.target.value) : undefined })} className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary" />
 </div>
 <div>
 <label htmlFor="vsm-panel-mttr-min" className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.mttrMinLabel', 'MTTR (Min.)')}<FieldHint hint={hints['vsm.mttr_min']} /></label>
 <input id="vsm-panel-mttr-min" type="number" min={0} value={selectedNode.mttrMin ??''} onChange={e => updateNode(selectedNode.id, { mttrMin: e.target.value ? Number(e.target.value) : undefined })} className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary" />
 </div>
 {derivedAvailabilityPct != null && (
 <p className="text-[10px] text-muted-foreground flex items-center gap-2">
 <span>→ {t('wertstrom.editor.panel.derivedAvailability', 'berechnete Verfügbarkeit')}: {derivedAvailabilityPct.toFixed(1)}%</span>
 <button
 type="button"
 onClick={() => updateNode(selectedNode.id, { availabilityPct: derivedAvailabilityPct })}
 className="text-primary hover:underline font-medium"
 >
 {t('wertstrom.editor.panel.apply', 'Übernehmen')}
 </button>
 </p>
 )}
 </PanelSection>
 )}

 {selectedNode.type ==='inventory'&& (
 <PanelSection title={t('wertstrom.editor.panel.advancedSection', 'Erweitert')}>
 {selectedNode.inventoryKind ==='fifo'&& (
 <div>
 <label htmlFor="vsm-panel-inventory-max-quantity" className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.maxCapacityLabel', 'Max. Kapazität (Stk)')}</label>
 <input id="vsm-panel-inventory-max-quantity" type="number" min={0} value={selectedNode.inventoryMaxQuantity ??''} onChange={e => updateNode(selectedNode.id, { inventoryMaxQuantity: e.target.value ? Number(e.target.value) : undefined })} className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary" />
 </div>
 )}
 <div>
 <label htmlFor="vsm-panel-inventory-wait-time-sec" className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.waitTimeSecLabel', 'Wartezeit (s)')}<FieldHint hint={hints['vsm.inventory_wait_time']} /></label>
 <input id="vsm-panel-inventory-wait-time-sec" type="number" min={0} value={selectedNode.waitTimeSec ??''} onChange={e => updateNode(selectedNode.id, { waitTimeSec: e.target.value ? Number(e.target.value) : undefined })} className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary" />
 </div>
 </PanelSection>
 )}

 {selectedNode.type ==='transport'&& (
 <PanelSection title={t('wertstrom.editor.panel.advancedSection', 'Erweitert')}>
 <div>
 <label htmlFor="vsm-panel-transport-distance" className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.distanceLabel', 'Distanz (m)')}<FieldHint hint={hints['vsm.distance']} /></label>
 <input id="vsm-panel-transport-distance" type="number" min={0} value={selectedNode.distance ??''} onChange={e => updateNode(selectedNode.id, { distance: e.target.value ? Number(e.target.value) : undefined })} className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary" />
 </div>
 </PanelSection>
 )}

 {/* Notizen — common, immer sichtbar: ein einzelnes Freitextfeld ohne
 Geschwister bringt hinter einem Klick keinen Nutzen (§10.2). */}
 <div>
 <label htmlFor="vsm-panel-notes" className="block text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.notesLabel', 'Notizen')}</label>
 <textarea
 id="vsm-panel-notes"
 value={selectedNode.notes ??''}
 onChange={e => updateNode(selectedNode.id, { notes: e.target.value || undefined })}
 rows={3}
 className="w-full px-2 py-1.5 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary resize-none"
 />
 </div>

 {/* Kaizen-Marker (Wertstrom P8.1, A18, KAR-878/KAR-986, execution-prompt
 §15.5, Baustein 1) — common, jeder Node-Typ kann als Kaizen-Chance
 markiert werden (Engpass/Risiko sind nicht auf 'process' beschränkt).
 Presence of kaizenNote (auch '') IST der Marker; "Entfernen" setzt ihn
 zurück auf undefined — "Text, entfernbar" (Brief-Wortlaut), niemals ein
 separates Boolean. amber-700/amber-400: AA-sicheres Warn-Muster aus
 PR #362 (text-warning ist im Light-Theme AA-fail), nicht --vsm-node-*
 (jener Var-Satz unterscheidet Node-TYPEN/Canvas-Chrome, keine Badges —
 siehe repo-root CLAUDE.md Farbsystem-Abschnitt). */}
 <div className="border-t border-border pt-3">
 <div className="flex items-center justify-between gap-2 mb-1">
 <label htmlFor="vsm-panel-kaizen-note" className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
 <span className="text-amber-700 dark:text-amber-400 inline-flex" aria-hidden="true">
 <VsmSymbolKaizen size={10} />
 </span>
 {t('wertstrom.editor.node.kaizenAriaPrefix', 'Kaizen-Chance')}
 </label>
 {selectedNode.kaizenNote != null && (
 <button
 type="button"
 onClick={() => updateNode(selectedNode.id, { kaizenNote: undefined })}
 aria-label={t('wertstrom.editor.panel.kaizenRemoveAriaLabel', 'Kaizen-Marker entfernen')}
 className="text-[10px] text-muted-foreground hover:text-destructive"
 >
 {t('wertstrom.common.remove', 'Entfernen')}
 </button>
 )}
 </div>
 {selectedNode.kaizenNote != null ? (
 <textarea
 id="vsm-panel-kaizen-note"
 value={selectedNode.kaizenNote}
 onChange={e => updateNode(selectedNode.id, { kaizenNote: e.target.value })}
 rows={2}
 placeholder={t('wertstrom.editor.panel.kaizenNotePlaceholder', 'Kurzbeschreibung der Kaizen-Chance')}
 className="w-full px-2 py-1.5 rounded-lg border border-amber-600/40 dark:border-amber-400/40 text-xs text-foreground focus:outline-none focus:border-primary resize-none"
 />
 ) : (
 <button
 type="button"
 onClick={() => updateNode(selectedNode.id, { kaizenNote: '' })}
 className="w-full h-8 rounded-lg border border-dashed border-border text-[10px] text-muted-foreground hover:border-amber-600 hover:text-amber-700 dark:hover:text-amber-400 transition-colors flex items-center justify-center gap-1.5"
 >
 <span aria-hidden="true" className="inline-flex">
 <VsmSymbolKaizen size={10} />
 </span>
 {t('wertstrom.editor.panel.kaizenMarkButton', 'Kaizen-Chance markieren')}
 </button>
 )}
 {selectedNode.kaizenNote != null && (
 <>
 {/* Baustein 2 — "Maßnahme erstellen" am Node-Panel. NUR bei
 projekt-verknüpfter Karte (workshop_actions ist projekt-gebunden,
 Brief) — sonst ausgegrauter Hinweis statt eines Fehlers erst beim
 Absenden. K2-Fix (RLS-Sichtbarkeits-Ehrlichkeit, PR #363 adversarial
 review): ebenso ausgegraut für einen Nicht-Owner des verknüpften
 Projekts — der Insert würde an workshop_actions_own's RLS scheitern
 (project.user_id === auth.uid()), vorher ehrlich abfangen statt den
 Fehler erst beim Absenden zu kassieren. */}
 <button
 type="button"
 onClick={() => {
 if (!vsm.project_id || !canManageMeasures) return
 setMeasureDialogContext({
 sourceNodeId: selectedNode.id,
 titleSeed: selectedNode.kaizenNote?.trim() || `${t('wertstrom.editor.node.kaizenAriaPrefix', 'Kaizen-Chance')}: ${selectedNode.name}`,
 originText: interpolate(t('wertstrom.editor.measures.originText', 'Aus Wertstrom „{vsmTitle}", Element „{nodeName}" ({nodeType}).'), {
 vsmTitle: vsm.title,
 nodeName: selectedNode.name,
 nodeType: t(NODE_TYPE_LABEL_KEYS[selectedNode.type], NODE_CONFIG[selectedNode.type].label),
 }),
 })
 }}
 disabled={!vsm.project_id || !canManageMeasures}
 title={!vsm.project_id ? t('wertstrom.editor.measures.assignProjectFirst', 'Karte zuerst einem Projekt zuordnen') : !canManageMeasures ? t('wertstrom.editor.measures.ownerOnlyCreate', 'Maßnahmen können nur vom Projekt-Eigentümer erstellt werden') : undefined}
 className="mt-2 w-full h-8 rounded-lg border border-primary text-primary text-[10px] font-medium hover:bg-secondary transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:border-border disabled:text-muted-foreground"
 >
 {t('wertstrom.editor.measures.createMeasure', 'Maßnahme erstellen')}
 </button>
 {/* P8.2a (Baustein 4, "Bestehende Maßnahme verknüpfen", KAR-878/KAR-986):
 der ZWEITE Pfad in measureRefs — P8.1 baute nur "erstellen". STRIKT im
 P8.1-Rahmen: dieselbe RLS-Grenze (canManageMeasures wiederverwendet, kein
 neuer Gate-Mechanismus — Nicht-Owner sehen diesen Button ebenfalls nicht),
 dieselbe project_id-Voraussetzung. */}
 <button
 type="button"
 onClick={() => {
 if (!vsm.project_id || !canManageMeasures) return
 setLinkMeasureDialogNodeId(selectedNode.id)
 }}
 disabled={!vsm.project_id || !canManageMeasures}
 title={!vsm.project_id ? t('wertstrom.editor.measures.assignProjectFirst', 'Karte zuerst einem Projekt zuordnen') : !canManageMeasures ? t('wertstrom.editor.measures.ownerOnlyLink', 'Maßnahmen können nur vom Projekt-Eigentümer verknüpft werden') : undefined}
 className="mt-1.5 w-full h-8 rounded-lg border border-border text-muted-foreground text-xs font-medium hover:bg-secondary hover:text-foreground transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
 >
 {t('wertstrom.editor.measures.linkExisting', 'Bestehende Maßnahme verknüpfen')}
 </button>
 {!vsm.project_id && (
 <p className="mt-1 text-xs text-muted-foreground">{t('wertstrom.editor.measures.assignProjectFirstHint', 'Karte zuerst einem Projekt zuordnen, um Maßnahmen zu erstellen oder zu verknüpfen.')}</p>
 )}
 {vsm.project_id && !canManageMeasures && (
 <p className="mt-1 text-xs text-muted-foreground">{t('wertstrom.editor.measures.ownerOnlyHint', 'Maßnahmen können nur vom Projekt-Eigentümer erstellt oder verknüpft werden.')}</p>
 )}
 </>
 )}
 </div>

 {/* Verknüpfte Maßnahmen (Baustein 3 — Anzeige). Tote Refs (gelöschte
 Maßnahme) werden ehrlich als "nicht mehr vorhanden" mit Entfernen-
 Option gezeigt, nie still gefiltert (Brief-Wortlaut).
 K2-Fix (RLS-Sichtbarkeits-Ehrlichkeit, PR #363 adversarial review):
 eine dritte, ehrliche Kategorie "nicht einsehbar" (kein Projekt-
 Zugriff) — NIE als "gelöscht/tote Referenz" behandelt, siehe
 resolveLinkedMeasures's eigene Doku für die Herleitung — sowie ein
 eigener Lade-Fehler-Zustand, wenn die Query selbst fehlschlug. */}
 {(() => {
 const { found, deadRefs, notVisibleRefs, loadError } = resolveLinkedMeasures(selectedNode.measureRefs, measures)
 if (!loadError && found.length === 0 && deadRefs.length === 0 && notVisibleRefs.length === 0) return null
 return (
 <div className="border-t border-border pt-3">
 <div role="heading" aria-level={4} className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
 {t('wertstrom.editor.measures.linkedHeading', 'Verknüpfte Maßnahmen')}
 </div>
 {loadError ? (
 <p className="text-[11px] text-destructive">{t('wertstrom.editor.measures.loadError', 'Maßnahmen konnten nicht geladen werden.')}</p>
 ) : (
 <ul className="space-y-1.5">
 {found.map(m => (
 <li key={m.id} className="text-[11px] border border-border rounded-md p-2 space-y-1">
 <div className="flex items-center justify-between gap-2">
 <span className="text-muted-foreground">{t(WORKSHOP_STATUS_LABEL_KEYS[m.status], WORKSHOP_STATUS_LABELS_DE[m.status])}</span>
 {vsm.project_id && (
 <Link href={`/project/${vsm.project_id}/workshop`} className="text-primary hover:underline shrink-0">
 {t('wertstrom.editor.measures.openInWorkshop', 'Im Workshop-Modul öffnen')}
 </Link>
 )}
 </div>
 <p className="text-foreground">{measureTitle(m.description)}</p>
 </li>
 ))}
 {notVisibleRefs.map(id => (
 <li key={id} className="text-[11px] border border-dashed border-border rounded-md p-2">
 <span className="text-muted-foreground italic">{t('wertstrom.editor.measures.notVisible', 'Nicht einsehbar (kein Projekt-Zugriff)')}</span>
 </li>
 ))}
 {deadRefs.map(id => (
 <li key={id} className="text-[11px] border border-dashed border-border rounded-md p-2 flex items-center justify-between gap-2">
 <span className="text-muted-foreground italic">{t('wertstrom.editor.measures.noLongerExists', 'Maßnahme nicht mehr vorhanden')}</span>
 <button
 type="button"
 onClick={() => updateNode(selectedNode.id, { measureRefs: (selectedNode.measureRefs ?? []).filter(r => r !== id) })}
 aria-label={t('wertstrom.editor.measures.removeDeadRefAriaLabel', 'Tote Maßnahmen-Referenz entfernen')}
 className="text-destructive hover:underline shrink-0"
 >
 {t('wertstrom.common.remove', 'Entfernen')}
 </button>
 </li>
 ))}
 </ul>
 )}
 </div>
 )
 })()}

 <button
 onClick={() => {
 // P8.2b-Review-Fix K1 (ur-2, KAR-987): this destructive, confirm-less
 // action had no history push — inconsistent with the "Knoten löschen"
 // button right below it (deleteNode itself pushes). Push BEFORE the
 // mutation, same pattern as everywhere else in this file.
 pushHistoryBeforeChange()
 setConnections(prev => prev.filter(c => c.fromNodeId !== selectedNode.id && c.toNodeId !== selectedNode.id))
 setIsDirty(true)
 }}
 className="w-full h-8 rounded-lg border border-border text-[10px] text-muted-foreground hover:bg-background transition-colors"
 >
 {t('wertstrom.editor.node.deleteConnections', 'Verbindungen löschen')}
 </button>

 <button
 onClick={() => deleteNode(selectedNode.id)}
 className="w-full h-8 rounded-lg bg-destructive text-white text-[10px] font-semibold hover:bg-red-800 transition-colors"
 >
 {t('wertstrom.editor.node.deleteNode', 'Knoten löschen')}
 </button>
 </div>
 ) : (
 <div className="p-4 space-y-4">
 <div className="flex items-center gap-2 pb-2 border-b border-border">
 {/* K7-Fix (Referenzwertstrom-Fixrunde 2, KAR-878): title+aria-label —
 same reasoning as the Canvas-Node-Header site: the resolved symbol
 can be a more specific refinement than the generic
 NODE_CONFIG[type].label shown right next to it (h3 below).
 K4-Fix (P8.2a-Review Fix-Runde, KAR-878/KAR-986): this IS the flat,
 flag-OFF panel (see the ternary above — this branch only renders
 while wertstromUxV2Enabled is false) — passing it explicitly keeps
 the confident "Produktionssteuerung (PPS)" wording behind the flag,
 restoring the "byte-identical to main" guarantee this panel's own
 header comment claims two screens above. */}
 {(() => {
 const panelSymbolKey = resolveNodeSymbolKey(selectedNode, nodes, connections)
 return (
 <span title={resolveNodeSymbolLabel(t, panelSymbolKey, selectedNode, wertstromUxV2Enabled)} aria-label={resolveNodeSymbolLabel(t, panelSymbolKey, selectedNode, wertstromUxV2Enabled)} className="shrink-0 inline-flex">
 <VsmSymbolByKey symbolKey={panelSymbolKey} size={16} style={{ color: NODE_CONFIG[selectedNode.type].color }} />
 </span>
 )
 })()}
 <h3 className="text-xs font-semibold text-foreground">{t(NODE_TYPE_LABEL_KEYS[selectedNode.type], NODE_CONFIG[selectedNode.type].label)}</h3>
 </div>

 {/* Name */}
 <div>
 <label className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">
 {t('wertstrom.editor.panel.nameLabel', 'Name')}
 <FieldStatusTag status={selectedNode.fieldStatus?.name} />
 </label>
 <input
 type="text"
 value={selectedNode.name}
 onChange={e => updateNode(selectedNode.id, { name: e.target.value })}
 className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary"
 />
 </div>

 {/* "Quelle anzeigen" (KAR-972, ux-flow.md §8) — nur für importierte Nodes */}
 {selectedNode.qafSource && (
 <button
 onClick={() => setShowQafSource(true)}
 className="w-full h-8 rounded-lg border border-border text-[10px] text-muted-foreground hover:border-primary hover:text-primary hover:bg-secondary transition-colors"
 >
 {t('wertstrom.editor.panel.showSource', 'Quelle anzeigen')}
 </button>
 )}

 {/* Type-specific fields */}
 {selectedNode.type ==='process'&& (
 <>
 <div>
 <label className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.cycleTimeSecLabel', 'Zykluszeit (s)')}<FieldHint hint={hints['vsm.cycle_time']} /><FieldStatusTag status={selectedNode.fieldStatus?.cycleTimeSec} /><ProvenanceTag provenance={selectedNode.provenance} /></label>
 <input
 type="number"
 min={0}
 value={selectedNode.cycleTimeSec ??''}
 onChange={e => updateNode(selectedNode.id, {
 cycleTimeSec: e.target.value ? Number(e.target.value) : undefined,
 // Review-Fix F6 (adversarial review, PR #353): a hand-edit via this
 // plain field is NOT one of the 3 recognized provenance-setting paths
 // (LSC-Import/Inline-Stoppuhr/Picker) — whatever provenance the value
 // used to carry no longer describes what is now shown, so it resets to
 // unknown (absence-is-quiet doctrine) instead of leaving a stale claim
 // like "Gemessen" on a now hand-typed number.
 provenance: undefined,
 })}
 className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary"
 />
 {qafVsMeasured && (
 <p className="mt-1 text-[10px] text-muted-foreground">
 {t('wertstrom.editor.panel.qafPrefix', 'QAF')} {qafVsMeasured.qafSec.toLocaleString('de-DE')} s · {t('wertstrom.editor.panel.measuredAvgPrefix', 'Gemessen Ø')} {qafVsMeasured.measuredSec.toLocaleString('de-DE')} s
 {qafVsMeasured.deltaPercent != null && ` · ${qafVsMeasured.deltaPercent > 0 ? '+' : ''}${qafVsMeasured.deltaPercent.toFixed(1)} %`}
 </p>
 )}
 </div>
 <div>
 <label className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.machineTimeSecLabel', 'Maschinenzeit (s)')}<FieldHint hint={hints['vsm.machine_time']} /></label>
 <input
 type="number"
 min={0}
 value={selectedNode.machineTimeSec ??''}
 onChange={e => updateNode(selectedNode.id, { machineTimeSec: e.target.value ? Number(e.target.value) : undefined })}
 className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary"
 />
 </div>
 <div>
 <label className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.manualTimeSecLabel', 'Manuelle Zeit (s)')}<FieldHint hint={hints['vsm.manual_time']} /></label>
 <input
 type="number"
 min={0}
 value={selectedNode.manualTimeSec ??''}
 onChange={e => updateNode(selectedNode.id, { manualTimeSec: e.target.value ? Number(e.target.value) : undefined })}
 className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary"
 />
 </div>
 <div>
 <label className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.setupTimeSecLabel', 'Rüstzeit (s)')}<FieldHint hint={hints['vsm.setup_time']} /></label>
 <input
 type="number"
 min={0}
 value={selectedNode.setupTimeSec ??''}
 onChange={e => updateNode(selectedNode.id, { setupTimeSec: e.target.value ? Number(e.target.value) : undefined })}
 className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary"
 />
 </div>
 <div>
 <label className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.waitTimeSecLabel', 'Wartezeit (s)')}<FieldHint hint={hints['vsm.wait_time']} /></label>
 <input
 type="number"
 min={0}
 value={selectedNode.waitTimeSec ??''}
 onChange={e => updateNode(selectedNode.id, { waitTimeSec: e.target.value ? Number(e.target.value) : undefined })}
 className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary"
 />
 </div>
 <div>
 <label className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.capacityPerHourLabel', 'Kapazität (Stk/h)')}<FieldHint hint={hints['vsm.capacity_per_hour']} /></label>
 <input
 type="number"
 min={0}
 value={selectedNode.capacityPerHour ??''}
 onChange={e => updateNode(selectedNode.id, { capacityPerHour: e.target.value ? Number(e.target.value) : undefined })}
 className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary"
 />
 </div>
 {/* Anzahl Mitarbeiter */}
 <div>
 <label className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.numWorkersLabel', 'Anzahl Mitarbeiter')}<FieldHint hint={hints['vsm.num_workers']} /><FieldStatusTag status={selectedNode.fieldStatus?.numWorkers} /></label>
 <input
 type="number"min={0}
 value={selectedNode.numWorkers ??''}
 onChange={e => updateNode(selectedNode.id, { numWorkers: e.target.value ? Number(e.target.value) : undefined })}
 className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary"
 />
 </div>

 {/* Prozessart */}
 <div>
 <label className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.processTypeLabel', 'Prozessart')}<FieldHint hint={hints['vsm.process_type']} /></label>
 <select
 value={selectedNode.processType ??''}
 onChange={e => updateNode(selectedNode.id, { processType: (e.target.value as ProcessType) || undefined })}
 className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary bg-card"
 >
 <option value="">{t('wertstrom.editor.panel.selectPlaceholder', '— wählen —')}</option>
 <option value="manual">{t('wertstrom.editor.panel.processTypeManual', 'Manuell')}</option>
 <option value="robot">{t('wertstrom.editor.panel.processTypeRobot', 'Roboter')}</option>
 <option value="human_robot">{t('wertstrom.editor.panel.processTypeHumanRobot', 'Mensch + Roboter')}</option>
 <option value="machine">{t(NODE_TYPE_LABEL_KEYS.machine, 'Maschine')}</option>
 </select>
 </div>
 {selectedNode.processType ==='machine'&& (
 <div>
 <label className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">
 {t('wertstrom.editor.panel.machineTypeLabel', 'Maschinentyp')}
 <FieldStatusTag status={selectedNode.fieldStatus?.machineType} />
 </label>
 <input
 type="text"
 value={selectedNode.machineType ??''}
 onChange={e => updateNode(selectedNode.id, { machineType: e.target.value || undefined })}
 placeholder={t('wertstrom.editor.panel.machineTypePlaceholder', 'z.B. Spritzgussmaschine')}
 className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary"
 />
 </div>
 )}

 {/* Review-Fix 4/6 (PR #337): vaClass 4-Wege-Auswahl NUR für qafSource-Nodes
 — qafSource entsteht ausschließlich über den flag-gated QAF-Import-Pfad,
 daher ist qafSource-Existenz das korrekte Gate (kein zusätzliches
 Flag-Prop nötig). Alt-/Hand-/Excel-/LSC-Nodes ohne qafSource behalten die
 alte isValueAdded-Checkbox byte-identisch zu main — dieser Zweig darf
 durch Öffnen+Editieren+Speichern NIEMALS vaClass setzen oder die
 isValueAdded-Semantik ändern. */}
 {selectedNode.qafSource ? (
 <div>
 <label className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">
 {t('wertstrom.editor.panel.vaClassLabel', 'Wertschöpfung')}
 <FieldHint hint={hints['vsm.value_added']} />
 <FieldStatusTag status={selectedNode.fieldStatus?.vaClass} />
 </label>
 <select
 value={deriveVaClassSelection(selectedNode)}
 onChange={e => updateVaClass(selectedNode.id, e.target.value as VaClass)}
 className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary bg-card"
 >
 {(Object.entries(VA_CLASS_LABELS) as Array<[VaClass, string]>).map(([value, label]) => (
 <option key={value} value={value}>{t(VA_CLASS_LABEL_KEYS[value as VaClass], label)}</option>
 ))}
 </select>
 {legacyValueAddedHint(selectedNode) !== null && (
 <p className="mt-1 text-[10px] text-muted-foreground">
 {interpolate(t('wertstrom.editor.panel.legacyVaHint', 'Noch nicht eingeordnet — Alt-Wert „Wertschöpfend”: {value}'), { value: legacyValueAddedHint(selectedNode) ? t('wertstrom.editor.common.yes', 'Ja') : t('wertstrom.editor.common.no', 'Nein') })}
 </p>
 )}
 </div>
 ) : (
 <label className="flex items-center gap-2 cursor-pointer">
 <input
 type="checkbox"
 checked={selectedNode.isValueAdded ?? false}
 onChange={e => updateNode(selectedNode.id, { isValueAdded: e.target.checked })}
 className="w-4 h-4 rounded border border-border accent-primary"
 />
 <span className="text-xs text-foreground">{t('wertstrom.editor.panel.valueAddedCheckbox', 'Wertschöpfend')}</span>
 <FieldHint hint={hints['vsm.value_added']} />
 </label>
 )}

 {/* Inline stopwatch (5.3) */}
 <div className="border-t border-border pt-3 space-y-2">
 <button
 onClick={() => setSwShowPanel(v => !v)}
 className="w-full h-8 rounded-lg border border-border text-[10px] text-muted-foreground hover:border-primary hover:text-primary hover:bg-secondary flex items-center justify-center gap-1.5 transition-colors"
 >
 <Timer size={12} />
 {swShowPanel ? t('wertstrom.editor.panel.stopwatchHide', 'Zeitmessung ausblenden') : t('wertstrom.editor.panel.stopwatchShow', 'Zeitmessung starten')}
 </button>
 {swShowPanel && (
 <div className="bg-background rounded-lg p-3 space-y-2">
 <div className="text-center font-mono text-lg font-bold text-primary-dark">
 {swElapsed.toFixed(1)}s
 </div>
 <div className="flex gap-1">
 {!swRunning ? (
 <button
 onClick={swStart}
 className="flex-1 h-7 rounded-md bg-primary text-white text-[10px] font-semibold hover:bg-primary-dark transition-colors"
 >
 {swElapsed > 0 ? t('wertstrom.editor.panel.stopwatchResume', 'Weiter') : t('wertstrom.editor.panel.stopwatchStart', 'Start')}
 </button>
 ) : (
 <button
 onClick={swStop}
 className="flex-1 h-7 rounded-md bg-destructive text-white text-[10px] font-semibold hover:bg-destructive transition-colors"
 >
 {t('wertstrom.editor.panel.stopwatchStop', 'Stop')}
 </button>
 )}
 <button
 onClick={swReset}
 className="h-7 px-2 rounded-md border border-border text-[10px] text-muted-foreground hover:bg-card transition-colors"
 >
 {t('wertstrom.editor.panel.stopwatchReset', 'Reset')}
 </button>
 </div>
 {!swRunning && swElapsed > 0 && (
 <button
 onClick={() => {
 // Review-Fix F7: routed through the shared downgrade-guard — in
 // practice this never actually prompts (a real stopwatch reading is
 // always 'measured', the top confidence rank, so it can never itself
 // be a downgrade), kept consistent with the Picker below on purpose.
 if (applyProvenanceGuardedCycleTime(selectedNode, Math.round(swElapsed * 100) / 100, 'measured')) swReset()
 }}
 className="w-full h-7 rounded-md bg-success text-white text-[10px] font-semibold hover:bg-status-green transition-colors"
 >
 {interpolate(t('wertstrom.editor.panel.applyCycleTime', 'Als Zykluszeit übernehmen ({sec}s)'), { sec: swElapsed.toFixed(1) })}
 </button>
 )}
 </div>
 )}
 </div>

 {processSteps.length > 0 && (
 <button
 onClick={() => setShowStoppuhrPicker(true)}
 className="w-full h-8 rounded-lg border border-primary text-[10px] text-primary hover:bg-secondary transition-colors"
 >
 {t('wertstrom.editor.panel.importFromStopwatch', 'Aus Stoppuhr importieren')}
 </button>
 )}
 </>
 )}

 {selectedNode.type ==='machine'&& (
 <>
 <div>
 <label className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.oeeLabel', 'OEE (%)')}<FieldHint hint={hints['vsm.oee']} /></label>
 <input
 type="number"
 min={0}
 max={100}
 value={selectedNode.oee ??''}
 onChange={e => updateNode(selectedNode.id, { oee: e.target.value ? Number(e.target.value) : undefined })}
 className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary"
 />
 </div>
 <div>
 <label className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.capacityPerHourLabel', 'Kapazität (Stk/h)')}<FieldHint hint={hints['vsm.capacity_per_hour']} /></label>
 <input
 type="number"
 min={0}
 value={selectedNode.capacityPerHour ??''}
 onChange={e => updateNode(selectedNode.id, { capacityPerHour: e.target.value ? Number(e.target.value) : undefined })}
 className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary"
 />
 </div>
 </>
 )}

 {selectedNode.type ==='inventory'&& (
 <>
 {/* Wertstrom P2 (A8, "Editor: Lager-Untertyp wählen") — one "Add"-style
 select on the existing generic inventory element (Capability-Matrix
 Simplicity-Ansatz), not 3 separate palette slots. Empty option =
 generic/undifferentiated, same as before this feature. */}
 <div>
 <label className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.inventoryKindLabel', 'Lager-Art')}</label>
 <select
 value={selectedNode.inventoryKind ??''}
 onChange={e => {
 const nextKind = (e.target.value as InventoryKind) || undefined
 updateNode(selectedNode.id, {
 inventoryKind: nextKind,
 // Review-Fix F10 (adversarial review, PR #353): Max. Kapazität is only
 // meaningful for a FIFO-Bahn (the Properties-Panel field below is
 // already gated on inventoryKind==='fifo') — leaving fifo must clear
 // it too, not just hide the input, so the canvas card below doesn't
 // keep showing a stale "/ <alte Kapazität>" for a now-Push/Supermarkt/
 // generic node.
 ...(nextKind !== 'fifo' ? { inventoryMaxQuantity: undefined } : {}),
 })
 }}
 className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary bg-card"
 >
 <option value="">{t('wertstrom.editor.panel.genericOption', '— generisch —')}</option>
 {(Object.entries(INVENTORY_KIND_LABELS) as Array<[InventoryKind, string]>).map(([value, label]) => (
 <option key={value} value={value}>{t(INVENTORY_KIND_LABEL_KEYS[value as InventoryKind], label)}</option>
 ))}
 </select>
 </div>
 <div>
 <label className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.quantityLabel', 'Menge (Stk)')}<FieldHint hint={hints['vsm.inventory_quantity']} /></label>
 <input
 type="number"
 min={0}
 value={selectedNode.quantity ??''}
 onChange={e => updateNode(selectedNode.id, { quantity: e.target.value ? Number(e.target.value) : undefined })}
 className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary"
 />
 </div>
 {/* "FIFO mit Kapazität" (Capability-Matrix A8) — only shown for the
 sub-type it's actually meaningful for. */}
 {selectedNode.inventoryKind ==='fifo'&& (
 <div>
 <label className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.maxCapacityLabel', 'Max. Kapazität (Stk)')}</label>
 <input
 type="number"
 min={0}
 value={selectedNode.inventoryMaxQuantity ??''}
 onChange={e => updateNode(selectedNode.id, { inventoryMaxQuantity: e.target.value ? Number(e.target.value) : undefined })}
 className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary"
 />
 </div>
 )}
 <div>
 <label className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.waitTimeSecLabel', 'Wartezeit (s)')}<FieldHint hint={hints['vsm.inventory_wait_time']} /></label>
 <input
 type="number"
 min={0}
 value={selectedNode.waitTimeSec ??''}
 onChange={e => updateNode(selectedNode.id, { waitTimeSec: e.target.value ? Number(e.target.value) : undefined })}
 className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary"
 />
 </div>
 </>
 )}

 {selectedNode.type ==='transport'&& (
 <>
 <div>
 <label className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.distanceLabel', 'Distanz (m)')}<FieldHint hint={hints['vsm.distance']} /></label>
 <input
 type="number"
 min={0}
 value={selectedNode.distance ??''}
 onChange={e => updateNode(selectedNode.id, { distance: e.target.value ? Number(e.target.value) : undefined })}
 className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary"
 />
 </div>
 <div>
 <label className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.transportTimeSecLabel', 'Transportzeit (s)')}<FieldHint hint={hints['vsm.transport_time']} /></label>
 <input
 type="number"
 min={0}
 value={selectedNode.transportTimeSec ??''}
 onChange={e => updateNode(selectedNode.id, { transportTimeSec: e.target.value ? Number(e.target.value) : undefined })}
 className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary"
 />
 </div>
 </>
 )}

 {(selectedNode.type ==='customer'|| selectedNode.type ==='supplier') && (
 <div>
 <label className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.node.unitsPerDay', 'Stk/Tag')}<FieldHint hint={hints['vsm.demand_per_day']} /></label>
 <input
 type="number"
 min={0}
 value={selectedNode.demand ??''}
 onChange={e => updateNode(selectedNode.id, { demand: e.target.value ? Number(e.target.value) : undefined })}
 className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary"
 />
 </div>
 )}

 {selectedNode.type ==='timevalue'&& (
 <div>
 <label className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.timeValueSecLabel', 'Zeitwert (s)')}<FieldHint hint={hints['vsm.time_value']} /></label>
 <input
 type="number"
 min={0}
 value={selectedNode.cycleTimeSec ??''}
 // Review-Fix F6: same "hand-edit clears the now-stale provenance" fix
 // as the process node's Zykluszeit field above — this input writes the
 // same underlying cycleTimeSec.
 onChange={e => updateNode(selectedNode.id, { cycleTimeSec: e.target.value ? Number(e.target.value) : undefined, provenance: undefined })}
 className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary"
 />
 </div>
 )}

 {/* Notes */}
 <div>
 <label className="block text-[10px] font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.panel.notesLabel', 'Notizen')}</label>
 <textarea
 value={selectedNode.notes ??''}
 onChange={e => updateNode(selectedNode.id, { notes: e.target.value || undefined })}
 rows={3}
 className="w-full px-2 py-1.5 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary resize-none"
 />
 </div>

 {/* Remove connections */}
 <button
 onClick={() => {
 // P8.2b-Review-Fix K1 (ur-2, KAR-987): same fix as the NEW panel's
 // identical button above — pushHistoryBeforeChange() itself is a no-op
 // while wertstromUxV2Enabled is off (the only flag under which this OLD
 // flat panel ever renders), so this is a no-behavior-change consistency
 // fix, not a functional change for this branch.
 pushHistoryBeforeChange()
 setConnections(prev => prev.filter(c => c.fromNodeId !== selectedNode.id && c.toNodeId !== selectedNode.id))
 setIsDirty(true)
 }}
 className="w-full h-8 rounded-lg border border-border text-[10px] text-muted-foreground hover:bg-background transition-colors"
 >
 {t('wertstrom.editor.node.deleteConnections', 'Verbindungen löschen')}
 </button>

 {/* Delete node */}
 <button
 onClick={() => deleteNode(selectedNode.id)}
 className="w-full h-8 rounded-lg bg-destructive text-white text-[10px] font-semibold hover:bg-red-800 transition-colors"
 >
 {t('wertstrom.editor.node.deleteNode', 'Knoten löschen')}
 </button>
 </div>
 // Wertstrom P4 (A13): the OLD flat panel immediately above this line is
 // completely untouched (same JSX as before P4, byte-identical when the
 // flag is off) — it is simply the alternate branch of the
 // `wertstromUxV2Enabled ? (NEW) : (OLD)` ternary now nested inside
 // `selectedNode ? … : (EMPTY STATE)` below. Nested-ternary precedence
 // (right-associative, consequent greedily consumes the whole inner
 // ternary including its own `:`) means no extra parens are needed for
 // that nesting — `a ? b ? c : d : e` already parses as `a ? (b ? c : d)
 // : e`.
 ) : selectedConnection && wertstromUxV2Enabled ? (
 // P8.2a (Baustein 3, "Push/Pull/Kanban-Kantensemantik", KAR-878/KAR-986):
 // the FIRST Properties-Panel surface for a VsmConnection (see
 // updateConnection's own doc comment) — deliberately MINIMAL (nur
 // flowControl, Brief-Scope), not a retrofit of full connection-editing
 // parity (label/frequency/transportTimeSec/batchSize stay create-time-
 // only, unchanged, out of this Baustein's scope — Judgment-Call, siehe
 // Abschlussbericht).
 <div className="p-4 space-y-4">
 <div className="flex items-center gap-2 pb-2 border-b border-border">
 <h3 className="text-xs font-semibold text-foreground">{t('wertstrom.editor.connection.heading', 'Verbindung')}</h3>
 </div>
 <p className="text-xs text-muted-foreground">
 {nodes.find(n => n.id === selectedConnection.fromNodeId)?.name ??'—'} → {nodes.find(n => n.id === selectedConnection.toNodeId)?.name ??'—'}
 </p>
 <div>
 <p className="text-xs font-medium text-muted-foreground mb-1 uppercase tracking-wider">{t('wertstrom.editor.connection.kindLabel', 'Art')}</p>
 <p className="text-xs text-foreground">{t(CONNECTION_KIND_LABEL_KEYS[selectedConnection.kind ??'materialFlow'], CONNECTION_KIND_LABELS[selectedConnection.kind ??'materialFlow'])}</p>
 </div>
 {(selectedConnection.kind ??'materialFlow') !=='information'&& (
 <PanelSection title={t('wertstrom.editor.panel.expertSection', 'Experte')}>
 <div>
 <label htmlFor="vsm-panel-flow-control" className="flex items-center gap-1 text-xs font-medium text-muted-foreground mb-1 uppercase tracking-wider">
 {t('wertstrom.editor.connection.flowControlLabel', 'Steuerungsart')}
 </label>
 <select
 id="vsm-panel-flow-control"
 value={selectedConnection.flowControl ??''}
 onChange={e => updateConnection(selectedConnection.id, { flowControl: e.target.value ? (e.target.value as FlowControl) : undefined })}
 className="w-full h-8 px-2 rounded-lg border border-border text-xs text-foreground focus:outline-none focus:border-primary bg-card"
 >
 <option value="">{t('wertstrom.editor.canvas.flowControlNone', '(kein)')}</option>
 {(Object.entries(FLOW_CONTROL_LABELS) as Array<[FlowControl, string]>).map(([fc, label]) => (
 <option key={fc} value={fc}>{t(FLOW_CONTROL_LABEL_KEYS[fc], label)}</option>
 ))}
 </select>
 </div>
 </PanelSection>
 )}
 </div>
 ) : (
 <div className="flex flex-col items-center justify-center flex-1 p-4 text-center">
 <p className="text-xs text-muted-foreground">{t('wertstrom.editor.panel.emptyStateHint', 'Klicken Sie auf einen Knoten, um dessen Eigenschaften zu bearbeiten.')}</p>
 </div>
 )}
 </div>
 </>
 )}
 </div>

 {/* Timeline bar — Wertstrom P3 (A4): the Timeline-Leiter v2 (VA/NVA-
 Treppe + DLZ + PCE, computeTimelineLadder) replaces this ONLY while
 wertstromUxV2Enabled; the old 1-Prozentbalken renders byte-identical
 when the flag is off (Nicht-Scope "Regression = No-Go"). */}
 {wertstromUxV2Enabled ? (
 <VsmTimelineLadder
 nodes={nodes}
 shiftModel={shiftModel}
 // Review-Fix F3+F5: flips shiftModelEverCompleteRef the FIRST time a
 // complete model is seen (loaded already-complete, or completed here);
 // a partial-fill-in-progress `undefined` (every keystroke before the 3rd
 // required field is filled) leaves it untouched — never true from a
 // model that was never actually complete.
 onShiftModelChange={model => { if (model !== undefined) shiftModelEverCompleteRef.current = true; setShiftModel(model); setIsDirty(true) }}
 demandUnitsPerDay={demandUnitsPerDay}
 />
 ) : (
 <div className="h-14 bg-card border-t border-border flex items-center gap-4 px-4 shrink-0">
 <span className="text-xs font-medium text-muted-foreground">{t('wertstrom.editor.timeline.timeShare', 'Zeitanteil')}</span>
 {timelineData.total > 0 ? (
 <>
 <div className="flex-1 h-6 rounded-lg overflow-hidden flex bg-border">
 <div className="h-full bg-success transition-all"style={{ width: `${timelineData.pct}%` }} />
 <div className="h-full bg-destructive/60 transition-all"style={{ width: `${100 - timelineData.pct}%` }} />
 </div>
 <div className="text-xs text-foreground font-semibold whitespace-nowrap">{t('wertstrom.editor.timeline.vaSharePrefix', 'Wertschöpfungsanteil')}: {timelineData.pct}%</div>
 <div className="text-xs text-muted-foreground whitespace-nowrap">{interpolate(t('wertstrom.editor.timeline.vaNvaSummary', 'VA: {va}s | NVA: {nva}s'), { va: String(timelineData.va), nva: String(timelineData.nva) })}</div>
 </>
 ) : (
 <span className="text-xs text-muted-foreground">{t('wertstrom.editor.timeline.addStepsHint', 'Fügen Sie Prozessschritte mit Zykluszeiten hinzu')}</span>
 )}
 </div>
 )}

 {/* Baustein 2 (VSM-Standard-Visualisierung, Kais-Live-Feedback 23.07.,
 KAR-878): die bisherige separate "Zeitlinie (Zickzack-Ansicht)"
 <details>-Sektion (VsmTimelineZigzag) entfällt ERSATZLOS hier — Kais
 wörtlich: "Die Zeitlinie … muss direkt unter dem Wertstrom sein …
 damit es mit dem Wertstrom verlinkt ist und erkannt wird", kein
 separater, aufklappbarer Bereich mit eigener Skala mehr. Die Zeitlinie
 lebt jetzt ALS BAND im Canvas selbst (siehe <VsmCanvasTimelineBand>
 oben, im selben <svg> wie die Connections) — der Durchlaufzeit-Hilfetext
 wandert an die fixe Zeitlinien-Steuerleiste unten im Canvas (unten,
 "Zeitlinien-Steuerleiste"). Flag-aus bleibt byte-gleich (die alte
 Sektion war bereits vollständig hinter wertstromUxV2Enabled verborgen —
 kein Verhalten ändert sich für einen Flag-aus-Aufruf). */}

 {/* Management-Analyse (A15, Wertstrom P7, execution-prompt §18): eigene
 aufklappbare Sektion (P4-Panel-Muster), voller Breite unter der
 Timeline-Leiter — geschlossen per Default (§10.2 "advanced sections
 closed by default"), nimmt daher im Normalfall nur eine Zeile ein.
 max-h-72 + overflow-y-auto begrenzt eine AUFgeklappte Sektion auf einen
 Innen-Scroll statt den h-screen-Editor-Rahmen zu sprengen. */}
 {wertstromUxV2Enabled && (
 <div className="shrink-0 max-h-72 overflow-y-auto bg-card border-t border-border px-4">
 {/* C11-Fix (Referenzwertstrom-Fixrunde, KAR-878): Kais §12 Taktzeit-
     Hilfetext lebt HIER genau einmal ("Takt -> KPI-Leiste/Analyse") —
     zuvor doppelt: hier UND nochmal am CT-Takt-Diagramm-Header direkt
     unter der Zykluszeit-Hint (§22.16 "nicht überladen"). Die
     Zykluszeit-Hint bleibt bewusst am CT-Takt-Diagramm (dort werden
     Zykluszeiten verglichen); die Durchlaufzeit-Hint bleibt an der
     Zeitlinie (siehe oben) — je Begriff genau eine sinnvollste Stelle. */}
 <div className="pt-1 flex items-center gap-1 text-[10px] text-muted-foreground">
 <span>{t('wertstrom.editor.management.taktzeitLabel', 'Taktzeit')}</span>
 <FieldHint hint={taktzeitHint(t)} />
 </div>
 <VsmManagementAnalysisView
 nodes={nodes}
 connections={connections}
 taktTimeSec={taktTimeSec}
 demandUnitsPerDay={demandUnitsPerDay}
 shiftModel={shiftModel}
 capacityOverridesByNodeId={capacityOverridesByNodeId}
 scenarioCompare={scenarioCompareBundle}
 openMeasures={openMeasuresDerivation.items}
 openMeasuresIssue={openMeasuresDerivation.issue}
 vsmTitle={vsm.title}
 projectId={vsm.project_id}
 canManageMeasures={canManageMeasures}
 onCreateMeasure={setMeasureDialogContext}
 />
 </div>
 )}

 {/* Zykluszeit-Taktzeit-Diagramm (Referenzwertstrom-Auftrag, Kais §9):
 "separat einblendbarer Bereich" — eigene geschlossene <details>-Sektion
 (P4-Panel-Muster, wie Management-Analyse oben), belastet den Wertstrom
 selbst nicht dauerhaft (§9 "darf den eigentlichen Wertstrom nicht
 überladen"). Bar-Klick <-> Canvas/Tabellen-Klick teilen sich DIESELBE
 selectedNodeId/setSelectedNodeId-Selektion wie VsmTableView bereits —
 ein Klick hier hebt den Node überall hervor, ein Klick auf den Node
 hebt hier den passenden Balken hervor (beide Richtungen, §9). */}
 {wertstromUxV2Enabled && (
 <details className="shrink-0 border-t border-border px-4 py-2" onToggle={e => setCtTaktOpen(e.currentTarget.open)}>
 <summary className="cursor-pointer text-[10px] font-semibold text-muted-foreground uppercase tracking-wider select-none hover:text-primary">
 <span role="heading" aria-level={3}>
 {t('wertstrom.editor.management.ctVsTaktHeading', 'Zykluszeit im Vergleich zur Taktzeit')}
 </span>
 </summary>
 {ctTaktOpen && (
 <div className="mt-2">
 {/* FieldHint only while open — same collision reasoning as the
 Zickzack-Zeitlinie section above (an always-mounted "Hinweis:
 Zykluszeit" aria-label collided with vsm-editor.test.tsx's own
 unrelated getByLabelText(/Zykluszeit/) query). Taktzeit's hint moved
 to the Management-Analyse section above it (C11-Fix, "Takt ->
 KPI-Leiste/Analyse") — no longer duplicated here. */}
 <div className="mb-1.5 flex items-center gap-3 text-[10px] text-muted-foreground">
 <span className="flex items-center gap-1">{t('wertstrom.editor.management.zykluszeitLabel', 'Zykluszeit')}<FieldHint hint={zykluszeitHint(t)} /></span>
 </div>
 <VsmCtTaktChart
 nodes={nodes}
 taktTimeSec={taktTimeSec}
 bottleneckId={bottleneckId}
 selectedNodeId={selectedNodeId}
 onSelectNode={selectAndRevealNode}
 shiftModel={shiftModel}
 capacityOverridesByNodeId={capacityOverridesByNodeId}
 tier1NodeIds={tier1NodeIds}
 />
 </div>
 )}
 </details>
 )}

 {/* Palette drag ghost */}
 {paletteDragType && (
 <div
 className="fixed pointer-events-none z-50 opacity-80 rounded-lg bg-card border-l-4"
 style={{
 left: paletteDragPos.x - NODE_W / 2,
 top: paletteDragPos.y - 30,
 width: NODE_W,
 height: 60,
 borderLeftColor: NODE_CONFIG[paletteDragType].color,
 }}
 >
 <div className="px-2 py-1.5 flex items-center gap-1.5 text-xs font-medium">
 {/* K7-Fix (Referenzwertstrom-Fixrunde 2, KAR-878): title+aria-label for
 consistency with every other VsmSymbolByKey call site (Brief: "an
 allen VsmSymbolByKey-Stellen") — this ghost is pointer-events-none
 and only visible during an active mouse drag, so it is the least
 practically-reachable of the sites, but kept consistent rather than
 arbitrarily skipped. */}
 <span title={t(VSM_SYMBOL_LABEL_KEYS[NODE_CONFIG[paletteDragType].icon], VSM_SYMBOL_LABELS[NODE_CONFIG[paletteDragType].icon])} aria-label={t(VSM_SYMBOL_LABEL_KEYS[NODE_CONFIG[paletteDragType].icon], VSM_SYMBOL_LABELS[NODE_CONFIG[paletteDragType].icon])} className="inline-flex">
 <VsmSymbolByKey symbolKey={NODE_CONFIG[paletteDragType].icon} size={14} style={{ color: NODE_CONFIG[paletteDragType].color }} />
 </span>
 <span>{t(NODE_TYPE_LABEL_KEYS[paletteDragType], NODE_CONFIG[paletteDragType].label)}</span>
 </div>
 </div>
 )}

 {/* Stoppuhr picker modal */}
 {showStoppuhrPicker && selectedNode && (
 <VsmStoppuhrPickerModal
 steps={processSteps}
 // Wertstrom P2 (A12): despite the "Stoppuhr"/StoppuhrStep naming, this
 // modal picks `planned_cycle_time_sec` (see vsm-stoppuhr-picker-modal.tsx
 // — it is not an average of real cycle_measurements rows) — provenance
 // 'planned', not 'measured', to actually match the field it reads. Same
 // field/provenance pairing as handleLscImport's own planned_cycle_time_sec
 // fallback branch above.
 // Review-Fix F7: routed through the shared downgrade-guard — THE core
 // case this review-fix exists for (a 'planned' pick silently overwriting
 // an already-'measured' value).
 onPick={cycleTimeSec => applyProvenanceGuardedCycleTime(selectedNode, cycleTimeSec, 'planned')}
 onClose={() => setShowStoppuhrPicker(false)}
 />
 )}

 {/* Excel import modal */}
 {showExcelImport && (
 <VsmExcelImportModal
 preview={excelPreview}
 onFile={handleExcelFile}
 onApply={applyExcelImport}
 onClose={() => { setShowExcelImport(false); setExcelPreview(null) }}
 />
 )}

 {/* LSC Workshop import modal (5.4) */}
 {showLscImport && (
 <VsmLscImportModal
 projects={projects}
 projectId={lscProjectId}
 importing={lscImporting}
 onProjectChange={setLscProjectId}
 onImport={handleLscImport}
 onClose={() => { setShowLscImport(false); setLscProjectId('') }}
 />
 )}

 {/* "Quelle anzeigen" (QVS-P3, KAR-972) */}
 {showQafSource && selectedNode?.qafSource && (
 <VsmQafSourceModal
 nodeName={selectedNode.name}
 qafSource={selectedNode.qafSource}
 onClose={() => setShowQafSource(false)}
 />
 )}

 {/* "Maßnahme erstellen" (Wertstrom P8.1, A18, Baustein 2) — EIN Dialog,
 geöffnet entweder vom Node-Panel-Button oben oder von der Management-
 Analyse's Engpass-Aktion (setMeasureDialogContext, an beide durchgereicht).
 `vsm.project_id` ist hier zusätzlich zur Panel-/Analyse-seitigen
 Deaktivierung geprüft (defense-in-depth, kein Trust in einen bereits
 gesetzten Kontext ohne Projekt). */}
 {measureDialogContext && vsm.project_id && (() => {
 const dialogContext = measureDialogContext
 const projectId = vsm.project_id
 return (
 <VsmMeasureDialog
 projectId={projectId}
 context={dialogContext}
 existingMeasures={measures === null || measures === 'error' ? [] : measures}
 onOpenChange={open => { if (!open) setMeasureDialogContext(null) }}
 // Wertstrom P8.1 fix (K1, Orphan-Maßnahme — PR #363 adversarial
 // review): superseded the pre-fix "reuses updateNode, queued via
 // isDirty/Speichern/Autosave, kein Sofort-Save" Judgment-Call — see
 // TODO.md/CHANGELOG.md's Fix-Runde entries for the corrected risk
 // framing. onCreated now AWAITS the immediate link-PUT
 // (linkMeasureToNode above, which folds in the C3 closure-race fix
 // too) and reports the outcome back so the dialog can hold its
 // loading state until persistence is confirmed (Brief: "Erst nach
 // PUT-Erfolg gilt der Dialog als abgeschlossen").
 onCreated={async action => {
 setMeasures(prev => [...(prev === null || prev === 'error' ? [] : prev), action])
 return linkMeasureToNode(dialogContext.sourceNodeId, action.id)
 }}
 />
 )
 })()}

 {/* "Bestehende Maßnahme verknüpfen" (Wertstrom P8.2a, Baustein 4,
 KAR-878/KAR-986) — the SECOND path into measureRefs. Reuses
 linkMeasureToNode verbatim (SAME K1 Sofort-PUT semantics, same
 defense-in-depth duplicate-guard) — no parallel persistence path.
 `vsm.project_id` re-checked here for the same defense-in-depth reason as
 the create-dialog mount above. */}
 {linkMeasureDialogNodeId && vsm.project_id && (() => {
 const targetNodeId = linkMeasureDialogNodeId
 const targetNode = nodes.find(n => n.id === targetNodeId)
 return (
 <VsmLinkMeasureDialog
 availableMeasures={measures === null || measures === 'error' ? [] : measures}
 loadError={measures ==='error'}
 alreadyLinkedIds={targetNode?.measureRefs ?? []}
 onOpenChange={open => { if (!open) setLinkMeasureDialogNodeId(null) }}
 onLink={measureId => linkMeasureToNode(targetNodeId, measureId)}
 />
 )
 })()}

 {/* "Synchronisieren" (QVS-P5, KAR-974) — applying reloads the page (see
 qvs-reimport-dialog.tsx), so no onApplied callback is needed here. */}
 {showReimport && (
 <QvsReimportDialog
 valueStreamId={vsm.id}
 onClose={() => setShowReimport(false)}
 />
 )}

 {/* Präsentationsmodus (A17, Wertstrom P7, execution-prompt §11.6) — full
 overlay, own component (no editing controls exist in it at all, see its
 own module doc). */}
 {wertstromUxV2Enabled && presentationModeOpen && (
 <VsmPresentationMode
 title={vsm.title}
 nodes={nodes}
 connections={connections}
 taktTimeSec={taktTimeSec}
 demandUnitsPerDay={demandUnitsPerDay}
 shiftModel={shiftModel}
 capacityOverridesByNodeId={capacityOverridesByNodeId}
 scenarioCompare={scenarioCompareBundle}
 onClose={() => setPresentationModeOpen(false)}
 />
 )}
 </div>
 )
}
