# Changelog

All notable changes to `@mp70/react-networks` are documented here. This project
adheres to [Semantic Versioning](https://semver.org/).

Release headings explicitly identify the `next` and `latest` channels. The
site's /changelog page uses those markers rather than inferring publication
state from the version checked out in package.json.

## Unreleased

## Releases on `latest`

## 0.12.0-rc.0

2026-10-21. A release candidate published on the `latest` channel: the
`next` tree at `0.10.0-beta.1`, plus the fixes from the pre-release QA pass
below. Install with `npm install @mp70/react-networks`.

`0.10.0` was tagged but never reached npm (GitHub Actions was not running
jobs), and the `v0.10.0` tag predates the QA fixes, so this release supersedes
it. The version skips to `0.12` because `0.11.x` prereleases already exist on
npm.

**Upgrading from npm.** The previous `latest` on npm is `0.9.102`. A few
versions in this changelog (`0.9.103`, `0.9.98`–`0.9.101`, `0.9.2` and
`0.8.0`) were version bumps in the repository that were never published to
npm; their changes ship in the next published version. Coming from `0.9.102`,
read everything under `0.9.103`, `0.10.0-beta.0`, `0.10.0-beta.1` and this
entry.

- Everything documented under `0.9.103`, `0.10.0-beta.0`, and `0.10.0-beta.1`
  ships in this release, including the `--rn-*` design-token theme
  contract, keyboard accessibility, the power validation subsystem, and the
  performance work.
- Hidden device faces keep their connection anchors mounted and
  non-interactive, preventing repeated missing-handle warnings; connections
  hide when either endpoint's face is inactive.
- The rack "Zoom to edit" action enlarges U rows to at least 24 screen pixels
  within the configured zoom limits. Disable it with
  `actionPanel.showZoomToEdit: false`.

### Fixed in the pre-release QA pass

Diagram and interaction:

- **The default initial fit frames the whole scene.** It previously framed
  whichever device measured first (a 42U rack opened zoomed in on one 1U
  server). The library now runs the default fit once, after every mounted node
  is measured; React Flow's own `fitView` prop is no longer passed.
  `initialViewport: { mode: 'restore' }` now also suppresses the default fit.
- **`uPosition` is authoritative for racked devices.** A device whose parent is
  a rack is snapped to its U slot on load and whenever `parentId`, `uPosition`
  or `uHeight` changes, even when a stale or arbitrary `position` was supplied
  (previously only `{x: 0, y: 0}` triggered placement). x is kept; controlled
  hosts get one `onNodesChange` for the correction.
- **Devices parented to a rack render at the rack's U pitch** and face width
  without needing `data.isRackMounted`.
- **Narrow desktop windows are no longer treated as mobile.** Mobile now means
  a coarse primary pointer or a mobile user agent, not a window ≤768px. Wheel
  pan/zoom and the device action panel (De-rack, face toggle) work in narrow
  windows, and device selection works on phones too. `preventScrolling` now
  follows whether wheel interaction is enabled.
- The default `initialViewport.refitOnLayoutChange` refits only until the user
  moves the camera; De-rack no longer snaps a zoomed-in view back to the
  overview. `true` keeps the old always-refit behaviour.
- The action panel disappears when the host removes the selected node.
- The rack drop-target highlight renders while dragging an unracked device
  over a rack (and the diagram no longer re-renders on every drag-over change).
- `flowMethods.setNodes` / `setEdges` work in read-only uncontrolled diagrams
  and notify a controlled host exactly once per call.
- `onConnectRejected` no longer fires when a drag is released over empty
  canvas.
- Face toggle: passing `onFaceChange` in uncontrolled mode no longer disables
  the built-in toggle; Cmd/Ctrl+click toggles without a callback; the Rear
  button is hidden in `readOnly` unless a face callback is supplied.
- Rack `uHeight` resolves the same way everywhere (invalid → 42, capped at
  100): box size, U markers, snapping and placement. A racked device without
  `uPosition` occupies the slot its position indicates instead of U1.
- The edge-connect animation timer is cleared on unmount.

Devices, ports and rendering:

- **Server, firewall, storage and other generic devices can be cabled.**
  Unconnected generic ports only mounted a target handle, so any edge leaving
  one (server→server, firewall→switch) was silently dropped and drag-connect
  between two servers was rejected. Generic ports now always mount the bare id
  as source plus a `-target` sibling, like switches, whatever
  `port.connected` says.
- Reconnecting one end of a cable keeps the saved handle id of the end that
  did not move (it was rewritten to the port's canonical directional id).
- On 2- and 3-port devices a port alone in its row sits at the right end
  instead of over the centred label.
- FibreFlowMap edge colours match their strand handles on auto-grouped cables
  over 24 fibres; strand aria names no longer read "tube undefined".
- Fibre 20 (and 13–24 on smooth-step edges) draws the TIA-598 tracer; fibre 20
  gets a yellow tracer on its black base.
- SpliceNode reports a connection on either side whatever the edge direction;
  ribbon splices render at their positioned 69×32 size; ribbon splice-tray
  holder markers use the 36px ribbon pitch.
- FibreNode handles have ids (`top`, `bottom`, `left`, `right`) and can be
  drag-connected; edges saved without handle ids still resolve.
- OtdrTraceStrip events are keyboard-accessible buttons when `onEventClick` is
  set (the root `role` becomes `group`).
- MultiTubeCableNode no longer shows a made-up "CBL-002" id and announces
  tubes with the right number.
- StepFibreEdge ribbon labels are placed correctly at negative coordinates.
- RackNode follows later changes to `data.showUMarkers`.
- The distribution board is themed for dark mode (new `--rn-board-*` tokens);
  the PDU inlet, breaker ways and incomer have aria labels.
- PowerEdge arrow markers are namespaced per instance, so two diagrams with the
  same edge ids no longer share marker colours.
- Hidden-face connection anchors no longer capture near-miss drops on
  non-patch-panel devices.
- A failed device image load is retried on the next mount instead of being
  cached forever.

NetBox, inventory and documents:

- **NetBox converters target the NetBox 4.5 REST API.** Device `role` and
  multi-termination `a_terminations` / `b_terminations` cables no longer
  throw (the legacy `device_role` / `termination_a/b` shapes still load);
  choice fields may be objects or strings and nested fields are null-safe.
- `netboxToNetworkDiagram` / `netboxToDiagramDocument` accept the optional
  `deviceTypes` (`/api/dcim/device-types/`) and `interfaces`
  (`/api/dcim/interfaces/`) lists. A 4.5 device listing nests only a brief
  device type without `u_height`, so devices were always drawn 1U, and
  interfaces are not nested in devices. Racks with `desc_units` number their
  units top-down. New `NetBoxDiagramInput` and `NetBoxDeviceType` types.
- `netboxToNetworkDiagram` only emits a handle when the device node has that
  port, so cables stay visible with the default `includeInterfacePorts: false`
  (previously every cable was dropped). Cables with an unresolvable end are
  dropped instead of dangling.
- Edge `type` follows the cable type (power → `power`, fibre/AOC → `fiber`,
  other named types → `ethernet`) in the NetBox and inventory converters.
- Unracked and 0U devices no longer get `uPosition: 1`. Cable lengths carry
  `data.lengthUnit` / `data.lengthCm`, which PWR-A12 now prefers.
- The inventory converter resolves string/UUID device ids and name-only rack
  references (no more dangling edges or `parentId: 'rack-undefined'`).
- Document round-trips are lossless and idempotent: node `type` and
  `deviceType` are kept apart (equipment `subtype` is now the node type), rear
  ports that share an id with a front port keep their face (`<id>--rear`),
  untyped ports stay untyped, and rack `ports`, edge `deletable` and
  `interactionWidth` are preserved. Malformed nodes/edges are skipped with a
  warning instead of throwing.
- `validateDeviceTemplate` reports `INVALID_*` issues for `null` entries
  instead of throwing; a missing `u_height` means 1 (NetBox's default), not
  0U; colliding generated port ids are de-duplicated; numeric names are kept;
  C15/C16/C21/C22 connectors are recognised.
- Rack placement rejects positions below U1 and heights below 1; strict mode's
  `DevicePlacementError` lists every conflict (`.issues`).

Power validation:

- PDU outlet connectors (from `pduPortGroups` or the positional default) are
  now checked by PWR-A05, A06 and A11.
- Voltage basis: new board `phaseVoltageV`, `lineToLineVoltageV`,
  `powerFactor` and per-way `voltageV`. Single-pole ways use line-to-neutral,
  two-pole line-to-line, and three-phase √3 × line-to-line voltage. Boards that
  only declare `voltageV` behave as before, and PWR-B03 flags a single
  `voltageV` that can't be right. With no voltage declared, NEC boards assume
  120/208 V and IEC boards 230/400 V.
- PWR-A06's downstream walk stops at sub-boards and at PDUs that declare the
  new `outletProtectionA`.
- C20 inlets accept C21 cords; per-rule `options.region` is honoured; node ids
  containing `#` or `%` are escaped in issue keys; PWR-A01 runs in linear time
  on meshed boards.

Docs and tooling:

- The README, root README and docs-site Quick Start / home examples were
  rewritten against the real API (the docs Quick Start rendered an empty
  canvas). CI now type-checks and server-renders every README and docs sample
  (`npm run docs:check-samples`).
- `check-server-entries` also catches bare side-effect imports and scans the
  type declarations of the server-safe entries.

### Compatibility

- Use React Flow 12's `parentId` for parent relationships; the library no
  longer reads the old `parentNode` alias. Migrate older saved graphs once when
  loading them: `parentId: node.parentId ?? node.parentNode`, then remove
  `parentNode`. See the `0.9.103` compatibility notes.
- **NetBox DTO types are looser** to match real payloads: many fields are now
  optional or nullable, so code that *reads* e.g. `device.device_role.name` or
  `cable.termination_a` needs null checks. New `/netbox` types:
  `NetBoxChoice`, `NetBoxNamedRef`, `NetBoxCableTermination`,
  `NetBoxLegacyCableTermination`.
- **Connections onto generic device ports** now store `<port>-target` as the
  target handle. Existing edges with bare ids keep working in the default
  `Loose` connection mode; with `connectionMode="strict"`, re-save edges that
  target a generic port by its bare id.
- **Documents:** equipment `subtype` now carries the node type (older documents
  that stored `deviceType` there still load), and emitted endpoint arrays are
  fresh copies on every emit.
- **Validation output can change:** PDU outlets are now judged, NEC boards
  default to 120/208 V, and PWR-B03 / PWR-A06 issue ids and counts differ on
  some graphs.
- **Deprecated:** the readable-zoom-floor API (`computeReadableZoomFloor`,
  `ReadableZoomFloorOptions`, `DIAGRAM_SMALLEST_LABEL_PX`,
  `DIAGRAM_MIN_READABLE_LABEL_PX`, `DIAGRAM_MAX_READABLE_ZOOM_FLOOR`,
  `DIAGRAM_MAX_READABLE_ZOOM_LIFT`, and `FitViewPolicyContext.fittedZoom` /
  `readableZoomFloor`) no longer affects the default fit and will be removed
  in a future minor.

## 0.9.103

2026-09-29. Releases the reviewed `next` tree using the smallest increment from
`0.9.102`. This pre-1.0 patch includes the compatibility changes below.

### Compatibility change

- Use React Flow 12's `parentId` for parent relationships. The library no longer
  reads the old `parentNode` alias during layout, connection validation, or
  document conversion. Migrate older saved graphs once when loading them:
  `parentId: node.parentId ?? node.parentNode`, then remove `parentNode`. The
  racks example starts fresh under its v4 storage key; it does not migrate old
  parent fields or saved handle IDs.

### Review fixes and performance

- Use React Flow's maintained absolute geometry for rack dragging and de-racking,
  including node origins, instead of repeatedly walking parent chains.
- Validate controlled edges once and apply the filtered result immediately.
  Preserve the input array when it already satisfies the connection policy.
- Normalize each port position once and remove redundant port-block cloning
  during document serialization. Input layout and compatibility arrays remain
  independent of emitted endpoints.
- Isolate graph state, export/capture, viewport recovery, rack dragging, and port
  layout behind the existing component API.

- Share node/edge indexes across scoped React Flow subscriptions, avoiding full
  graph scans on each pan/zoom update. Highlight dimming now uses set lookups.
- Honour explicitly empty power-source lists and detect independent unfed
  PDU/UPS supplies alongside distribution boards. Clear obsolete edge flow
  directions when power is lost and retain unchanged overlay edge identities.
- Make power-validation ancestor traversal iterative and cycle-safe, orient
  backwards-authored cords toward consumers, and index breaker-way links.
- Resolve declared node dimensions independently per axis and reject non-finite
  container dimensions/positions before computing an initial viewport.
- Remeasure device handles after position-only port edits, keeping connected
  edges anchored correctly. Reuse distribution-board layout on state-only edits.
- Honour device-port sizes below React Flow's default minimum and constrain
  inferred dense image-backed port widths to available spacing. Explicit
  authored sizes remain unchanged.

### Added

- Added a `--rn-*` **design token contract**: surfaces (`--rn-surface`,
  `--rn-surface-raised`, `--rn-surface-sunken`), borders (`--rn-border`,
  `--rn-border-strong`, `--rn-border-subtle`), text (`--rn-text`,
  `--rn-text-muted`, `--rn-text-strong`, `--rn-text-on-accent`), accent and
  selection (`--rn-accent`, `--rn-accent-selected`, `--rn-selection-ring`),
  status (`--rn-status-ok`, `--rn-status-ok-border`, `--rn-status-warn`,
  `--rn-status-error`, `--rn-status-unknown`) and rack/device/PDU chrome
  (`--rn-rack-*`, `--rn-device-body`, `--rn-device-label`, `--rn-pdu-*`). Every
  token is declared in both the `.react-flow-dark` and `.react-flow-light`
  blocks and documented in the README.
- Added an `@media (prefers-color-scheme: dark)` fallback so a shell that has
  not applied either theme class follows the OS preference. An explicit
  `.react-flow-light` / `.react-flow-dark` still wins.
- Added `getStatusColorHex(status)` for consumers that need a parseable hex
  (canvas, SVG export, colour maths).
- Added the shared `.rn-node--selected` class: one 2px `--rn-selection-ring`
  for every node type.

- Clickable ports and bays support Enter and Space, visible port handles expose
  accessible names and roles, and rack/tray toggles expose pressed state.
- Added the `--rn-focus-ring` theme token and visible keyboard focus styles.
- Reduced-motion preferences stop looping edge animations and collapse library
  entrance and hover transitions.
- Exported the `NodeTypeCarrier` type from the root and `/model` entries so
  consumers wrapping `getNodeKind`, `isRackNode`, or `isDeviceNode` can name
  their public parameter type.
- `NetworkDiagram` renders a delayed, themed empty state when it has no nodes.
  The `emptyState` prop replaces or disables this feedback without intercepting
  pointer events on the canvas.
- `DiagramErrorBoundary` gained an `onReset` callback, and `NetworkDiagram`
  warns in development when its measured container has no width or height.
- Exported the fit-view and label level-of-detail policy helpers and constants
  so applications driving their own camera can match the diagram.

### Changed

- `llms.txt` (shipped in the package) is now the single source of truth for the
  LLM-facing API reference. `npm run llms:sync` copies it out to the repo-root
  `LLMS.txt` and the example site's `public/llms.txt` instead of overwriting it
  from the root copy, and `node scripts/sync-llms.mjs --check` fails CI when the
  copies drift. Also listed `OtdrTraceStrip` among the importable components.
- **Light-mode rendering of the rack, vertical PDU, splice-tray and fibre nodes
  now uses light surfaces.** These node types previously hardcoded `#1f2937`
  inline with no light override, so they rendered as dark slabs on a light
  canvas. Dark mode is unchanged.
- `getStatusColor(status)` now returns a `var(--rn-status-*, <hex>)` string
  instead of a bare hex, so status colours follow the active theme. The
  historical hex remains as the `var()` fallback, so it still renders correctly
  outside the library stylesheet; use `getStatusColorHex` where a hex is
  required.
- Node components now read their colours from the tokens instead of inlining
  hex values, and `DeviceNode`'s selection ring is 2px like every other node
  (it was 1px).


- Dropped the unused `@xyflow/system` peer dependency; `@xyflow/react` resolves
  its own internal dependency.
- Added development-only warnings for incomplete controlled graph props,
  document props combined with graph props, and changing custom type maps.
- `NetworkDiagram` now catches errors from its React Flow subtree by default.
  Use `onError` for reporting, `errorFallback` to customize the fallback, or
  `errorBoundary={false}` to let errors propagate to an outer boundary.
- Fit views now share a `0.14` default padding and a bounded readable-zoom floor,
  and the diagram hides micro-labels below `0.8` zoom. Explicit fit options
  continue to override the defaults.
- Touch selection, pinch zoom, and double-tap zoom are enabled by default while
  remaining overridable through the `interaction` prop.
- UI panels, cards, buttons and rack/device shells use corners of at most 1px.
  Circular connection handles, status indicators and hardware geometry retain
  their shapes.
- Rack-mounted device images default to `contain`, preserving the photograph's
  aspect ratio and authored port alignment. Explicit `imageFit` and
  `stretchToFit: true` still take precedence; standalone devices retain `cover`.
- NetBox-derived Palo Alto Networks template and port IDs now use the canonical
  `palo-alto-networks-pa-*` manufacturer namespace. Persisted
  `netboxTemplateId` or catalog port references must be regenerated against the
  refreshed catalog; this intentional identity change has no legacy alias layer.

### Fixed

- Include `llms.txt` in the published package, matching the documented reference.
- Controls and the public `fitView` helper now apply the same padding and bounded
  readable-zoom policy as initial fits. Helper options override configured
  defaults, and subset fits derive their floor from current measured bounds.
- Image exports hide empty/error feedback and disable virtualization for every
  capture. Concurrent and rejected exports keep capture state balanced, while
  label detail follows the current zoom after the final capture settles.
- Built-in node component identities remain stable when an external `nodeTypes`
  map changes. The development warning now accurately explains that newly
  created component values remount only the nodes or edges that use them.
- Restored initial viewports wait for React Flow initialization before applying
  the requested transform.
- Controlled graph updates preserve incoming node properties such as rack parent,
  visibility, dimensions and selection, and retain requested node/edge ordering.
  The image-display cache also preserves these updates and removes deleted
  nodes when a global image override is set. Document-derived graphs still
  retain omitted React Flow runtime state.

### Power and OTDR workstreams

The following work was originally planned under 0.10.0 and ships in 0.9.103.

### Changed

- **Power connections are no longer PDU-only** (`.`). The connection rule reads
  `POWER_SOURCE_NODE_TYPES` (`vertical-pdu`, `distribution-board`) instead of
  hard-coding `vertical-pdu`, so a board way can feed a board or PDU inlet
  directly. The same set is exposed from `/validation` as
  `POWER_SOURCE_CAPABLE_NODE_TYPES`. Existing PDU wiring is unaffected.
- **A breaker way must terminate on an inlet** (`.`). In this release, the
  widening above is narrowed on electrical grounds: a way terminates
  a *branch circuit*, so it may land on a PDU/board inlet but **not** on a
  device's IEC inlet or a PDU outlet. `defaultValidateConnection` rejects those
  with `CIRCUIT_TERMINATION_REASON` ("Branch circuits terminate on a PDU inlet —
  feed devices from PDU outlets"), and PWR-A13 reports them on an existing
  graph (warning-graded when the strip declares no inlet yet, as a migration
  path). Cord wiring (PDU outlet → device inlet) is untouched: graphs with no
  inlets and no ways validate exactly as before.

### Added

- **PDU supply inlet** (`.`). `VerticalPduNodeData` (the `vertical-pdu` entry in
  `NetworkNodeDataByType`, previously `NetworkNodeDataBase`) adds
  `inletConnector?: string` (free text, e.g. `"IEC 60309 32A"`),
  `inletAmps?: number`, `inletGender?: 'male' | 'female'`,
  `feedType?: 'primary' | 'secondary'`, plus the previously untyped
  `boardType?: 'pdu' | 'rpp' | 'panel' | 'busway' | 'other'`,
  `supply?: 'ac' | 'dc'`, `voltage?: number`, `phases?: 1 | 3` and `name?`.
  All optional and additive. When inlet data is present `VerticalPDU` mounts one
  extra **target** handle, `PDU_INLET_HANDLE_ID` (`"inlet"`), at the top of the
  strip above the header — chunkier than an outlet, tooltipped with the
  connector/rating/supply/feed. It is absolutely positioned, so node height and
  every outlet handle are unchanged; a strip that declares no inlet renders
  pixel-identically to 0.9.x. The id is non-numeric by construction and can
  never collide with the bare 1-based outlet ids `"1"…"N"`.
- **Power endpoint roles** (`.`). `getPowerEndpointRole(node, handle)` and
  `EndpointMetadata.powerRole` classify a power endpoint as `circuit-source`
  (breaker way), `circuit-sink` (PDU/board inlet), `cord-source` (PDU outlet or
  device `power_outlet`), `cord-sink` (device power inlet) or `unknown`.
  `EndpointMetadata` also gains `portType` / `connectorType`. Companion handle
  helpers: `PDU_INLET_HANDLE_ID`, `BOARD_INCOMER_HANDLE_ID`,
  `isPowerInletHandleId`. `unknown` endpoints are never judged — that is what
  keeps pre-inlet-model graphs valid.
- **IEC 60320 mate reason on cords** (`.`). A cord whose connectors cannot mate
  is now rejected with the mate reason (`"C13 cord will not mate with a C20
  inlet — fit the correct IEC 60320 cord"`) instead of a generic compatibility
  miss. PDU outlet connectors now honour an explicit `data.portTypes` sequence
  rather than always assuming the 8×C13 + 2×C19 cycle.
- **Outlet phase attribution** (`.`). `toOutletPhaseIndex(position, phases)`,
  `getPduOutletPhaseLabel`, `PHASE_LABELS` and `getPduOutletConnectorType`.
  Convention: outlet `p` on an `n`-phase strip sits on phase `(p - 1) % n`,
  round-robin from the top — fixed and shared so a diagram and its consuming
  application never compute a balance figure under different attributions.
- **Two new preset rules** (`.`). `PWR-A13` "circuit termination" (basis
  `convention`, severity `error`) flags a breaker way landing on a device inlet
  or a PDU outlet; `PWR-B08` "three-phase imbalance" (basis `convention`,
  severity `warning`, option `maxImbalancePct` default 10) flags a multi-phase
  strip whose per-phase nameplate load differs by more than the convention,
  using the attribution above. Both are in `standardPowerRules`. Nameplate sums
  are an upper bound, not measured draw, and B08 never cites NEMA MG-1
  (that standard is about *voltage* unbalance).
- **Energisation through the inlet** (`.`). `computeEnergization` treats a
  PDU/board inlet as the node's upstream connection point: whichever end of an
  edge lands on an inlet is the one being fed, and power never leaves a strip
  through its inlet. A de-energised circuit into the inlet leaves every outlet
  on that strip dead. The A08 invariant still holds — breaker state never
  changes the edge set.
- **`distribution-board` node type** (`.`). A new `NetworkNodeType` with
  `DistributionBoardNodeData`: `ways: BreakerWay[]` (`id`, `label`, `poles?`,
  `ratingA?`, `phase?`, `position?`), `wayState?: Record<string,
  BreakerWayInstanceState>` (`switchState: 'closed' | 'open' | 'tripped'` plus
  an optional health `status`), `voltageV?`, `codeMode?: 'NEC' | 'IEC'`,
  `maxUtilization?` (continuous-load derate ceiling, default 80),
  `wayColumns?`. Boards are **free-floating by default** —
  `distribution-board` is deliberately *not* in `RACK_ASSIGNABLE_NODE_TYPES`;
  the rack-mounted minority opts in with `rackMountable: true`. Each way mounts
  exactly one power handle, minted by `buildBreakerWayHandleId`; a way is not
  its own node.
- **`DistributionBoardNode` renderer** (`.`). Registered internally by
  `NetworkDiagram` (not exported, like every other node component since 0.9.0):
  draws the board frame and a generated faceplate grid of way rows, with
  open/tripped ways visually distinct from closed ones.
- **Device power-load fields** (`.`). `DeviceNodeData` gains
  `maximumDrawW?` (nameplate watts, NetBox `maximum_draw`),
  `allocatedDrawW?` (budgeted watts, NetBox `allocated_draw`) and
  `expectedFeeds?: 1 | 2` (`2` marks an A+B device, so losing one feed reads as
  `degraded` rather than unpowered). Units are watts so a NetBox sync is a
  straight field copy. All optional and additive.
- **Energisation overlay — `computeEnergization` / `applyEnergizationToEdges`**
  (`.`). Pure reachability over the *existing* power edges: from the source
  nodes (every `distribution-board` by default, else unfed PDU/UPS nodes) out
  through **closed** breaker ways, onward through power-distribution nodes, and
  terminating at consumers. Returns `EnergizationResult`
  (`edges: Record<string, EdgeEnergization>`, `devices: Record<string,
  DevicePowerState>` — `powered` / `degraded` / `unpowered` — and
  `energizedNodeIds`); `applyEnergizationToEdges` stamps it onto the edge array.
  `PowerEdge` reads the new `data.energized` and
  `data.flowDirection: 'source-to-target' | 'target-to-source'` to draw live vs
  de-energised styling and a flow arrow. **Invariant:** breaker state never
  alters the edge set — opening or tripping a way changes only the overlay, the
  input arrays are never mutated, and a de-energised edge is still an edge.
  Related exports: `EnergizationOptions` (`sources`, `defaultWayState`, which
  defaults to `closed`), `EdgeEnergization`, `PowerFlowDirection`,
  `DevicePowerState`.
- **New `./validation` subpath export** —
  `@mp70/react-networks/validation` (fifth entry point). Server-safe like
  `./model` and `./inventory` (zero React/React Flow at runtime), so a checks
  engine can validate a diagram in an API route or worker. Exports
  `runPowerValidation(nodes, edges, { rules, config })` returning
  `PowerValidationResult` (`issues`, `warnings`, `evaluatedRuleIds`,
  `skippedRuleIds`), the `PowerRule` / `PowerIssue` interfaces and their
  supporting types, the graph builder (`buildPowerValidationContext`,
  `isPowerEdge`, `powerElementKey`, `parsePowerElementKey`,
  `POWER_SOURCE_CAPABLE_NODE_TYPES`) and the IEC 60320 connector tables
  (`getConnectorAmperage`, `getConnectorRole`, `isValidIecMate`,
  `IEC_60320_*`). The engine ships **zero default rules**: opt into
  `standardPowerRules` (Tier A A01–A07/A09–A12 + Tier B B01–B05 from
  `docs/electrical-validation-rules-2026-08-14.md`, with
  `getStandardPowerRule` for single-rule lookup) or pass your own. The preset
  is tree-shaken away when it is not imported; Tier C (selectivity, trip
  curves, arc-flash, earth-loop impedance) is deliberately excluded as not
  computable from a diagram.

### Added

- **`OtdrTraceStrip` accepts the library's full `ColorMode`**, including
  `'system'`, which follows the OS `prefers-color-scheme` reactively via the
  same `useResolvedColorMode` hook `NetworkDiagram` uses. The default stays
  `'light'` for backwards compatibility. `otdrTraceStripPalette` accepts
  `'system'` too (resolved once, against `window.matchMedia`).
- **`--rn-otdr-*` design tokens.** The strip's eight colours (`text`,
  `subtext`, `axis`, `fibre`, `cumulative`, `event-fill`, `end`, `fault`) are
  now declared in `index.css` on `.rn-otdr-light` / `.rn-otdr-dark` (and on
  `.react-flow-light` / `.react-flow-dark`, so a strip inside a themed diagram
  container inherits them), and the SVG paints with
  `var(--rn-otdr-…, <hex fallback>)`. Hosts can retheme the strip in CSS;
  `otdrTraceStripPalette()` keeps returning the same hex values, generated from
  the same table so the two cannot drift.
- **`@mp70/react-networks/otdr` entry point.** `OtdrTraceStrip`, its types and
  `formatOtdrDistance` / `otdrEventKindLabel` / `otdrTraceStripPalette` now
  ship on their own subpath, so a host that only wants the strip skips the
  React Flow canvas entirely. It is a client entry (it uses hooks), not a
  server-safe one like `/model`. The root export is unchanged and nothing is
  deprecated.

- **`OtdrTraceStrip` `density` prop** (`'auto' | 'full' | 'compact'`, default
  `'auto'`). `'auto'` keeps the existing behaviour — the legend and unselected
  dB labels drop below 640 CSS px of rendered width — while `'compact'` forces
  that treatment at any width and `'full'` opts out of it entirely, for hosts
  that want a dense strip in a narrow card.
- **`formatOtdrDistance`**, **`otdrEventKindLabel`** and
  **`otdrTraceStripPalette`** are exported from the package root, so a host's
  map popups, event tables and chrome can label and colour distances and event
  kinds exactly as the strip does instead of maintaining a second vocabulary
  that drifts from the legend.

### Changed

- **The strip's clickable-event styling now comes from the stylesheet.** Each
  `OtdrTraceStrip` used to inject an unprefixed global `<style>` block
  (`.otdr-event-clickable`, `.otdr-event-ring`); those rules moved to
  `index.css` under `rn-otdr-`-prefixed class names and the inline `<style>`
  element is gone. **Consumers must import `@mp70/react-networks/index.css`**
  (the README already requires it) for the pointer cursor and hover-ring
  affordance; without it the strip still renders correctly, just without them.
- **BEHAVIOUR CHANGE — `focusRangeM` is no longer auto-collapsed.** The strip
  used to silently discard any range covering ≥98% of the axis, drawing no focus
  band; it now renders exactly the span it is given (clamped to the axis), so a
  near-full range paints a band across almost the whole strip where it
  previously painted nothing. **Hosts that relied on that suppression must now
  pass `null` themselves** when the visible span is effectively the whole route
  — a `null`/`undefined` range is the only way to say "spotlight nothing".
  Deciding that "everything is visible" belongs to the producer of the range,
  which knows how much of the route is on screen.
- **A `trace` no longer extends the axis past `lengthM`.** An OTDR captures well
  past the far end of the fibre; folding that tail into the axis left a strip
  reading 8 km under its own `Length 3.79 km` caption, and gave two strips of
  the same route different scales. When `lengthM` is supplied it is the
  authority on the axis and the waveform is clipped to the axis end; with no
  `lengthM` the trace's own end may still define the axis. An event or
  `faultDistanceM` beyond `lengthM` still extends the axis, and the waveform is
  drawn out to wherever that lands. Because clipping discards measured samples,
  the strip now captions it (`capture clipped at 3.79 km declared length`) when
  any sample was dropped.
- The strip's label thinning and waveform sampling now work in measured CSS
  pixels rather than viewBox units: the ~40px label clearance holds at any
  width, and the waveform gets one vertex per rendered pixel (so phones stop
  drawing 908 vertices and wide screens stop being undersampled). Output at
  1000 px wide is unchanged.

### Fixed

- **`OtdrTraceStrip` with a fault before the first event.** The loss axis
  collapsed to 0.1 dB and the caption read a pristine `0.00 dB cumulative`. The
  axis is scaled from every event again, the caption reads
  `0.00 dB cumulative to fault`, and events beyond the break are dimmed to match
  the derived line, which already stops there.
- **No more compact-mode flash.** The strip measures its width in a layout
  effect before first paint, so a phone-width card no longer renders the
  full-width layout for a frame before compacting.

## Historical bay workstream (planned as 0.11.0)

The version in this heading was a planning label, not a stable publication. These bay
capabilities are included in the current package.

Template **bays** + a bay renderer. Devices have containable elements (drive /
PSU / fan bays, line-card and PCIe slots) that a purely port-and-power model
could not express. This batch adds them under one principle — **capabilities,
not categories**: an element is *connectable* (mounts a cable handle),
*containable* (holds a field-replaceable unit), or both. A transceiver cage is
both, so it stays a **port** carrying `bay` metadata (the frozen handle-id
grammar is untouched and cable endpoints survive optic swaps); containable-only
bays are a new array that mounts **no** handles, ever.

**No schema-version bump.** `templateSchemaVersion` stays `2`: every field here
is additive and optional, a bay-free template parses byte-identically to before
(regression-guarded on the Cisco C9300-48P fixture — still 50 front / 4 rear),
and this historical workstream did not require a V3 schema.

### Added

- **Template schema — containable bays** (root entry). `TemplateFaceData` gains an
  optional `bays?: TemplateBay[]`; `TemplateBay` is
  `{ id, label, bayKind, position?, slots? }` with per-slot sub-rectangles, and
  `TemplateBayKind` is `storage_bay | psu_bay | fan_bay | pcie_slot |
  linecard_slot | supervisor_slot | fabric_slot | io_slot | other` (mirrors
  dc_designer's `bay_kind`). `TemplatePort` gains `bay?: { bayKind:
  TemplateBayKind | 'transceiver_cage'; accepts?: string[] }` — the cage's one
  element, one geometry box. `widthPercent` / `heightPercent` on `PortPosition`
  are now documented as contractual (percent of the owning face frame). New
  `TemplateBaySlot` type.
- **`parseNetboxTemplate` ingests bays** (root entry). Front `bays` / rear `rearBays`
  (with `rear_bays` and `bay_kind` snake_case aliases), raw port `type` strings
  still normalized through the existing visual-kind resolver. Authored bay
  geometry is stored **verbatim** (the parser never mutates it — the SAME policy
  as ports); out-of-range percents are surfaced as a `BAY_GEOMETRY_OUT_OF_RANGE`
  **warning** and clamped by the renderer at draw. `TemplateValidationReport`
  gains an **optional** additive `bayCounts?: { front, rear }` (always populated
  by the parser). New raw input types `RawTemplateBay`, `RawTemplateBaySlot`,
  `RawTemplatePortBay`; the raw position object also gains
  `widthPercent`/`heightPercent` (+snake aliases), closing a pre-existing port
  authoring gap.
- **`validateDeviceTemplate(template: unknown): TemplateParseResult`** (`.`, root
  entry) — validates a `DeviceTemplateV2` by running the SAME normalization as
  the parse pipeline (repairing un-canonical `bayKind` / port `type` in a
  community submission), then the shared structural checks (unique port ids,
  pass-through link refs, verbatim bay geometry ranges). Requires
  `templateSchemaVersion === 2` and returns the normalized template. The
  first-class CI entry point for a community template library
  (netbox-community/devicetype-library model).
- **`DeviceNode` draws bays** — outlines + labels for node-data `bays` on both
  faces (split-face included), placed with the same face-frame percent math as
  absolute ports and clamped to the frame at draw. Containable-only bays mount
  **no** React Flow handle, sit **beneath** the port handles (so a clickable bay
  never steals a port's pointer events), and carry `nodrag`. Node data gains
  `bays?: DeviceBay[]` and `bayState?: { [bayId]: { occupied?, status?: 'ok' |
  'failed' | 'predicted' } }` (rides alongside `handleSideMap`). Failed /
  highlighted / occupied / predicted bays get distinct treatment via a new
  `--rn-bay-*` CSS variable family (light + dark), mirroring the port highlight
  system. New render types `DeviceBay`, `DeviceBaySlot`, `DeviceBayStatus`,
  `DeviceBayInstanceState`.
- **`NetworkDiagram` bay props** — `highlightedBays?: DiagramBayRef[]` and
  `onBayClick?: (payload: { nodeId, bayId, bayKind, bayLabel, face }) => void`,
  the bay analogue of `highlightedPortHandles` / `onPortClick` (the payload
  carries `bayId`/`bayKind`/`bayLabel` in place of
  `handleId`/`groupName`/`portLabel`). Bay highlighting flows through a second
  instance-scoped highlight store, exposed as `DiagramBayHighlightSync` and
  `useDiagramBayHighlighted`. New `DiagramBayRef` / `DiagramBayClickPayload`
  types.

## Prereleases on `next`

## 0.10.0-beta.1

2026-10-08. Rack fixes from PR #86, merged into `next` at `42d41e2`.
Published under the npm `next` dist-tag; install with
`npm install @mp70/react-networks@0.10.0-beta.1`.

- Wait for React Flow's viewport to initialize before applying the initial fit.
  Compute declared top-level bounds without the development `nodeLookup` warning.
- Keep hidden device-face connection anchors mounted and non-interactive to
  prevent repeated missing-handle warnings; hide connections when either
  endpoint's face is inactive.
- Add a rack "Zoom to edit" action that enlarges U rows to at least 24 screen
  pixels within the configured zoom limits. Fit still shows the complete scene;
  disable the action with `actionPanel.showZoomToEdit: false`.

### Known limitation

The native `fitViewOnInit` path still consumes its initial fit request once.
Use `initialViewport: { mode: 'fit' }` for the declared-fit initialization policy,
which waits for React Flow initialization and retries when the container resizes.

## 0.10.0-beta.0

2026-10-06. Beta release of the `next` branch at `5856066`, including the
September workstream and the October 3 review fixes. Published under the npm
`next` dist-tag; install with `npm install @mp70/react-networks@0.10.0-beta.0`.

- Includes the design tokens, keyboard accessibility, validation subsystem,
  public API additions, and performance improvements documented under 0.9.103.
- Preserves the editing viewport in the racks example, shares newsletter state,
  and lets native fit-view frame complete diagrams without lifting the zoom.
- Includes the connection validation, edge deletion, and stacked-dialog focus
  fixes from the October review.

### Compatibility

Use React Flow 12's `parentId` instead of `parentNode`. For older saved graphs,
set `parentId: node.parentId ?? node.parentNode` on load, then remove
`parentNode`. See the 0.9.103 compatibility notes for the full migration details.

## 0.10.0-rc.0

The consumer-review batch: additive APIs driven by a structured review of the
primary consumer (dc_designer) on 0.9.0, plus hardening for the two footguns
that review surfaced. No breaking changes; no serialized value changes.

### Added

- **`handlesMatch(a, b)` / `findEdgesForPortHandle(edges, nodeId, handleId)`**
  (`.` and `./model`) — first-class handle equivalence (directional +
  face-alias normalization on both sides, plus the narrowed `+`-insensitive
  rule) and the edge lookup consumers were hand-rolling on top of it.
  `sanitizeHandleIdForMatching` is now `@deprecated` in favour of
  `handlesMatch`.
- **`encodeHandle(shape, opts?)` / `decodeHandle(id)`** (`.` and `./model`) —
  a single codec over the structural `HandleShape` union. Port shapes now
  carry a best-effort `{ group, label }` decode (percent-unescaped) or
  `verbatim: true`, and `ParsedHandleId.alternates` flags bases that
  structurally admit more than one reading (e.g. an explicit `port.id` of
  `tube-5`). Primary classifications and all emitted handle strings are
  unchanged.
- **Node-id helpers** (`.` and `./model`) — `buildRackNodeId`,
  `buildDeviceNodeId`, `parseNodeId` for the converter-minted `rack-${id}` /
  `device-${id}` prefixes, and type-driven `isRackNode` / `isDeviceNode` /
  `getNodeKind` predicates mirroring the document adapter's kind
  discrimination. `node.type` stays authoritative; the id helpers only invert
  the converters.
- **Fibre scene geometry restored to the public surface** (`.` and
  `./model`) — `calculateSplicePositionFromNumber`,
  `calculateSplicePositionFromNumberWithData`, and the `SPLICE_*` /
  `HOLDER_HEIGHT_PX_*` constants join the existing tray constants. 0.9.0
  internalized the positioner while keeping its constants public, which forced
  hand-composed scenes (a real consumer pattern) to fork the formula.
- **`highlightGroups` on `NetworkDiagram`** — declarative multi-severity
  highlighting: ordered groups of node/edge ids with per-group color/width,
  later-group-wins overlap, composed with `dimUnhighlighted`/`dimOpacity`
  (group members union with the legacy `highlighted*` props for the dim
  complement). Pure helpers (`applyHighlightGroupsToEdges`, collectors,
  `deriveHighlightGroupEdgePaint`) are exported for testing.
- **`FibreFlowMap` `onFlowMethods`** — export parity with `NetworkDiagram`:
  `{ toImage, fitView, getNodes, getEdges }` (`FibreFlowMapFlowMethods`),
  forwarded by reference from the inner diagram.
- **`FibreFlowMap` `resetKey`** — when provided, internal layout/edit state
  resets ONLY on `resetKey` change, never on prop identity.

### Fixed

- **`FibreFlowMap` no longer resets on value-equal props.** `closures` /
  `circuits` default to frozen empties and the internal reset effect is
  guarded by a content signature over the derived layout (node ids + positions
  + data, edge ids + handles + data, circuit ids, layout-affecting scalars),
  so `closures={x ?? []}` is safe while id-stable content edits — a re-pointed
  splice, a reassigned `circuitId` — still reset correctly. Referential
  stability is demoted from a correctness requirement to a perf nicety; a
  dev-only warning points out wasted re-allocations.
- **PNG export no longer omits culled elements.** `toImage` (and the built-in
  download button) force-disable `onlyRenderVisibleElements` for the capture
  and restore it after, so virtualized diagrams export complete images.

### Changed

- **Document adapter memoizes endpoint derivation** on `node.data` identity
  (WeakMap, with a node-id collision guard): position-only changes — e.g.
  every drag frame in controlled document mode — no longer re-derive and
  deep-clone every port block.
- **Reverse adapter treats `DiagramEndpoint.id` as opaque.** Edge handle
  bases now come from the resolved endpoint RECORD rather than re-parsing the
  edge's `endpointId` string, so consumer-minted opaque endpoint ids
  round-trip and a relabel no longer implies re-keying. Grammar-minted ids are
  byte-identical to before; the forward adapter still mints them.
- Docs: server-entry steering (`README` "Server-side usage" +
  `docs/eslint-server-imports.md` with a `no-restricted-imports` snippet), a
  root-entry banner note, and full documentation for every API above.

## Releases on `latest`

## 0.9.102

### Added

- **`OtdrTraceStrip` `focusRangeM`** (`[number, number] | null`) focuses the
  trace on a selected distance range.
- OTDR map focus interactions and denser trace rendering.

### Fixed

- Improved OTDR waveform rendering to match instrument-style backscatter
  displays.

### Public API

- **`OtdrTraceStripProps.focusRangeM`** is the only new surface in this
  release.
- It closes the OTDR trace batch opened in 0.9.99: **`OtdrTraceStripProps.trace`**
  with the exported **`OtdrTraceStripTrace`** interface (0.9.100) and
  **`OtdrTraceStripEvent.slopeDbPerKm`** (0.9.99) are exported from the root
  entry alongside it. All four are optional and additive — nothing in the
  0.9.99–0.9.102 range is breaking.

## 0.9.101

### Fixed

- **`OtdrTraceStrip` waveform rendering.** The min/max vertical-sliver
  decimation painted the noise floor as a solid block — technically the data,
  visually nothing like an instrument display. The waveform is now drawn as a
  connected jagged polyline: every bucket contributes its max, so the
  backscatter line and reflection spikes survive at any width, and only
  genuinely noisy buckets (more than 1.5 dB of internal range, i.e. the noise
  floor) also draw their min at the bucket edge — the characteristic hash with
  gaps that real OTDR viewers show. Same samples, verified byte-for-byte
  against pyOTDR's reference parse of the same file.

### Changed

- Example-site OTDR demo preamble rewritten to explain the concept: a
  deliberately simplified view for locating an issue, not a replacement for
  detailed manual trace analysis.

## 0.9.100

### Added

- **`OtdrTraceStrip` `trace`** prop (`{ stepM, levelsDb, startM? }`, typed by
  the newly exported `OtdrTraceStripTrace` interface) — uniformly sampled power
  levels as decoded from an SR-4731 file's `DataPts`. When present the strip
  draws the measured backscatter waveform (launch spike, dead zones, event
  reflections, end-of-fibre spike, post-end noise floor) using
  min/max-per-bucket decimation so spikes survive any width. Nothing is
  smoothed or synthesised.

### Changed

- The derived cumulative line is hidden whenever `trace` is supplied. Without
  `trace` the strip renders exactly as before.
- Example site: the full 16,000-sample waveform decoded from the Noyes M200
  sample `.sor` (v1 SR-4731 layout) is baked as JSON, with a toggle between
  real backscatter, measured-slope loss budget and flat events-only.

## 0.9.99

### Added

- **`OtdrTraceStripEvent.slopeDbPerKm`** (optional) — the measured attenuation
  of the fibre section leading into an event, as SR-4731 parsers report it.
  When slopes are present the cumulative line slopes across each section like
  real backscatter and lands on the measured link loss (M200: 1.351 dB of event
  loss + 1.215 dB of section loss = 2.566 dB, against 2.564 dB measured),
  instead of the flat step-sum of event losses that contradicted the measured
  total-loss header.

### Changed

- Events without `slopeDbPerKm` keep the previous flat step-line — the strip
  still never estimates a slope it was not given.
- Example-site demo carries the real M200 section slopes
  (0.120 / 0.362 / 0.334 / 0.321 dB/km); the break section reads 2.02 dB to the
  fault.

## 0.9.98

Backport of the fibre-flow work from the `next` staging branch (the power
distribution workstream stays on `next` for 0.10.0).

### Added

- **`FibreFlowMap` `hideUnusedFibres`** prop. Strands that carry no splice in
  any closure collapse into a per-group indicator row
  (`T2 · 12 of 12 unused`); a fully unused group collapses to the indicator
  alone. "Used" is computed across every closure sharing the cable, so
  pass-through strands spliced only in an adjacent closure are never hidden.
  Hidden strands mount no handles, so new splices cannot target them —
  intended for read-only viewing.
- **`computeUsedFibreStrands`**, **`fibreStrandKey`** and the
  **`FibreFlowTubeIndicator`** type are exported from the package root, so
  hosts can compute the used-strand set (or render their own indicators)
  without duplicating the key format.
- **Flat `fiberCount` auto-grouping.** A `simple` cable with more fibres than
  one tube's worth (`fibersPerTube`, default 12, up to 24) is now split into
  virtual tubes instead of being silently clamped to 24 — a 288F cable renders
  24 groups of 12. Fibre numbering stays continuous across the whole cable
  (labels and handle ids are unchanged) while colour and striping restart per
  virtual tube (TIA-598), so fibre 1 of every group is solid blue and striping
  only appears above position 12 within a group. Virtual groups get the same
  tube segments and borders as real tubes. Remaining clamps warn instead of
  truncating silently.

### Changed

- **`FibreFlowRow.groupIndex`** is populated for every row emitted by
  `buildFibreFlowCableLayout`. It is declared optional on the exported type, so
  hand-built layouts keep type-checking; read it as
  `groupIndex ?? tubeIndex ?? 1`.

## 0.9.2

### Changed

- **`interaction.showMiniMap` now defaults to `false` on all viewports**
  (previously `!isMobile`, i.e. on by default for desktop). The MiniMap
  overlaps diagram content, so it is now strictly opt-in:
  `interaction={{ showMiniMap: true }}` restores it.

### Fixed

- **Broken-build example: issue panel no longer covers the diagram on
  desktop.** The `Build checks` panel was an absolutely-positioned overlay, so
  zoom-to-issue could centre the target rack behind it, hiding the very fault
  being highlighted. The panel is now a static flex column beside the canvas
  at every viewport width (the ≤900px stacked layout is unchanged), and a
  Playwright regression spec guards the layout and the MiniMap default.

## 0.9.1

### Added

- **`PortPositionInput`** (root entry). `DevicePort.position` / `Port.position` now
  accept the canonical `PortPosition` object **or** a semantic edge string
  (`'left-bottom'`). Renderers normalize via `normalizePortPositionInput`
  before field access — closing the type/runtime gap called out below.
- **`RackDevice.bays`** (root entry). `RackConfig.devices[]` entries take an optional
  `bays?: DeviceBay[]`, plumbed straight through to the `DeviceNodeData.bays`
  the renderer already consumes — so a host building from a rack config no
  longer has to smuggle bays through the per-device extra node-data channel.
  `createRackConfigFromNodes` reads them back, so the round trip is lossless.
  Additive and optional: a rack config without `bays` behaves exactly as before.

### Changed

- **Inventory cables no longer invent dangling endpoints** (`/inventory`).
  `inventoryCableToNetworkEdge` returns `null` when a termination cannot be
  resolved (same strictness as NetBox), and `inventoryToNetworkDiagram` skips
  those cables. Empty `color: ''` normalizes to `undefined`.
- **`looksLikePowerHandleId` no longer treats every “power” substring as power**
  (root entry). Matches library shapes (`Power Ports-1`, `pwr-*`, `PSU-*`, `outlet-*`)
  without misclassifying `Power Room Uplinks-1`; recognizes `PSU-*` without
  `portType`.
- **`readDeviceBays` validates structure** (root entry). Malformed entries are dropped;
  unknown `bayKind` strings are kept for lossless round trips.
- **`core/store` renamed to `core/connection-validation`** (root entry). The old path
  re-exports for compatibility; public exports are unchanged.
- Document↔runtime merge helpers extracted to `document-runtime-merge` (internal).
- **Fibre-scene geometry coverage** (root entry). Unit tests for `coupler-positioning`,
  `fibre-flow-layout`, and `placement-validation` (panel/closure/tube/coupler
  dimensions, slot snap, multi-tube row layout).
- Example-site racks demo wires `onBayClick` / `highlightedBays` on App Server A1
  (storage + PSU bays, failed drive state).
- Docs/root README/Vocs aligned to `@mp70/react-networks` post-0.9 public API
  (internalized node pages are redirect notes).
- E2E tests fail when diagram fixtures are missing instead of self-skipping.
- **Test files are now type-checked** (root entry). `tsconfig.json` excludes tests and
  ts-jest runs transpile-only, so a type error inside a test passed CI silently
  (proven by injecting one). New `tsconfig.tests.json` + `npm run
  type-check:tests`, wired into `release:check` and CI. This surfaced and fixed
  282 pre-existing type errors across 15 test files — no assertions were
  weakened and no suppressions were added. New `src/test-utils/flow-props.ts`
  supplies `makeNodeProps` / `makeEdgeProps` builders for the render-time
  `NodeProps` / `EdgeProps` fields tests do not care about.
- **NetBox transforms normalize unset scalars to absent** (root entry). `netboxRackToNetworkNode`
  now emits `facilityId` / `serial` / `assetTag` as `undefined` rather than
  passing NetBox's `null` through, and `netboxCableToNetworkEdge` emits no
  `color` for NetBox's `''` (an empty string is not a paintable colour) — the
  same normalization the adjacent `length: cable.length || 0` already applied.
  This is what the existing "minimal rack" / "minimal cable" tests always
  asserted; they only passed because untyped fixtures could omit the fields
  entirely, which the NetBox response types do not permit.
- **`port-position` normalization is now unit-tested** (root entry). The canonical
  output of the raw authoring forms (semantic edge strings such as
  `'left-bottom'`, a semantic string in `side`, snake_case percent keys) is
  pinned in `utils/__tests__/port-position.test.ts`.

## 0.9.0

The 1.0 API-shape freeze, shipped as a final pre-1.0 minor bump. This is the
largest breaking batch: package rename, British-spelling unification, a slimmed
root surface with server-safe subpaths, and hardened wire formats. Everything
below is identifier/packaging/contract level — serialized data values (handle-id
strings, `'fiber'` node/edge type strings, `fiberId`/`ribbonFiberIds` data keys)
are deliberately preserved.

### Migration guide

A consumer on `0.8.0` (or the old unscoped `react-networks`) should be able to
migrate with the following steps.

**1. Package rename: `react-networks` → `@mp70/react-networks`.** The published
package is now scoped. Update `package.json`, every import specifier, and the
CSS import:

```diff
- npm install react-networks
+ npm install @mp70/react-networks

- import { NetworkDiagram } from 'react-networks';
- import 'react-networks/index.css';
+ import { NetworkDiagram } from '@mp70/react-networks';
+ import '@mp70/react-networks/index.css';
```

(This scoped name has existed since `0.6.0`/`0.7.x` via publish-time swaps; from
`0.9.0` the manifest name *is* the published name and the old unscoped name is
gone.)

**2. `Fiber*` → `Fibre*` symbol renames.** The public API is now uniformly
British-spelled. The renamed **public** symbols are:

| Removed (`Fiber`) | Use (`Fibre`) | Entry |
| --- | --- | --- |
| `FIBER_COLORS_12` | `FIBRE_COLORS_12` | `.` |
| `fiberSolidOrStriped` | `fibreSolidOrStriped` | `.` |
| `getFiberColor` | `getFibreColor` | `.` |
| `FiberColorId` (type) | `FibreColorId` (type) | `.` |
| `FiberCable` (type) | `FibreCable` (type) | `.` |
| `FiberCableType` (type) | `FibreCableType` (type) | `.` |
| `buildFiberHandleId` | `buildFibreHandleId` | `.`, `./model` |
| `isFiberConnectorType` | `isFibreConnectorType` | `./model` |
| `classifyFiberEndpoint` | `classifyFibreEndpoint` | `./model` |
| `evaluateFiberConnectionRule` | `evaluateFibreConnectionRule` | `./model` |
| `FiberEndpointClass` (type) | `FibreEndpointClass` (type) | `./model` |

There are **no** back-compat aliases. Serialized values are unchanged:
`buildFibreHandleId` still emits `fiber-N` handle strings, the `'fiber'`
node/edge type strings and `fiberId`/`ribbonFiberIds` data keys are untouched —
so no stored document or handle id needs rewriting.

**3. Root exports moved to server-safe subpaths.** The NetBox and generic
inventory converters are off the root entry. Import them from the subpath (they
now pull in **zero** React/React Flow runtime, so they are usable from Node/SSR):

```diff
- import { netboxToDiagramDocument, netboxToNetworkDiagram } from 'react-networks';
+ import { netboxToDiagramDocument, netboxToNetworkDiagram } from '@mp70/react-networks/netbox';

- import { inventoryToDiagramDocument, inventoryToNetworkDiagram, generateLayout } from 'react-networks';
+ import { inventoryToDiagramDocument, inventoryToNetworkDiagram, generateLayout } from '@mp70/react-networks/inventory';
```

The `NetBox*` DTO types now come from `/netbox` and the `Inventory*DTO` types
from `/inventory`.

**4. Node/edge components and low-level geometry constants internalized.** The
~16 node/edge components (`RackNode`, `DeviceNode`, `FibreNode`, `FibreEdge`,
`PowerEdge`, `VerticalPDU`, `SpliceNode`, `SpliceTrayNode`, `TubeNode`,
`CableNode`, `MultiTubeCableNode`, `CouplerNode`, `PatchPanelNode`,
`ClosureNode`, `FibreSplitNode`, `FibreCableWithTubesExpanded`, …) and the
coupler/splice/panel geometry constants and helpers (`COUPLER_SLOT_HEIGHT_PX`,
`COUPLERS_PER_PANEL`, `PANEL_WIDTH_PX`, `getPanelWidth`, `snapToCouplerPosition`,
etc.) are **no longer exported**. `NetworkDiagram` and `FibreFlowMap` register
these internally — render diagrams through those components rather than mounting
the primitives directly.

**5. Seven `ReactNetworks*` port-contract aliases dropped → canonical
`*DevicePort*` names.** These `@deprecated` aliases are removed:

| Removed | Use |
| --- | --- |
| `buildReactNetworksHandleId` | `buildPortHandleId` |
| `resolveReactNetworksPortFace` | `resolveDevicePortFace` |
| `resolveReactNetworksPortGroup` | `resolveDevicePortGroup` |
| `normalizeReactNetworksPorts` | `normalizeDevicePorts` |
| `resolveReactNetworksNodePorts` | `resolveNodeDevicePorts` |
| `flattenReactNetworksPortGroups` | `flattenDevicePortsFromGroups` |
| `buildReactNetworksPortGroupsFromPorts` | `buildDevicePortGroupsFromPorts` |

**6. Headless store API — already removed in `0.8.0`.** If you are coming from
`0.7.x`, `createNetworkDiagramStore` / `useNetworkDiagram` / `useDiagramNodes` /
`useDiagramEdges` and the `store` prop are gone. Use the controlled
`nodes`/`edges` (or `document`) props; connection validation remains as the pure
functions `validateNetworkConnection` / `computeEdgeZIndex`.

**7. CSS custom properties normalized to a single `--rn-*` prefix.** If you
override theming variables, rename:

| Removed | Use |
| --- | --- |
| `--react-flow-background`, `--react-flow-text`, `--react-flow-border`, `--react-flow-controls-*`, `--react-flow-minimap-*` | `--rn-flow-background`, `--rn-flow-text`, `--rn-flow-border`, `--rn-flow-controls-*`, `--rn-flow-minimap-*` |
| `--fiber-bg`, `--fiber-bg-image` | `--rn-fibre-bg`, `--rn-fibre-bg-image` |
| `--rotation` | (deleted; was dead — rotation is applied via transform) |

The invented `--react-flow-*` set never was xyflow's namespace (v12 uses
`--xy-*`). The theme class hooks (`react-flow-dark`, `react-flow-light`,
`react-networks-attribution`) are unchanged and now documented as stable.

**8. `DiagramDocument` now requires `version: 1`.**
`networkGraphToDiagramDocument` always emits it; `diagramDocumentToNetworkGraph`
treats a missing `version` as legacy `v0` (with a warning) and warns on an
unknown future version. If you hand-construct a `DiagramDocument`, add
`version: 1`. If you have persisted documents from `0.8.0` or earlier, they load
as legacy `v0` — re-save through the adapter to stamp `version: 1`.

**9. `FibreFlowMap` `readOnly` now defaults to `true`.** It previously defaulted
to `false` but exposed no edit callback, so connect/reconnect/delete mutated
internal state only and silently vanished. It is now a viewer by default; pass
`readOnly={false}` to opt into editing. Also: `closures`, `circuits`, and
`highlightedCircuitId` must be referentially stable (per-instance SVG ids are
now namespaced via `React.useId`).

**10. Handle-id wire format now percent-escapes `-` and `%` in device-port
group/label segments.** `buildPortHandleId` previously joined `${group}-${label}`
unescaped, which was non-injective (group `A` + label `B-C` collided with group
`A-B` + label `C`) and could reproduce a fixed grammar shape or a
directional/face suffix that `normalizeHandleId` would truncate. Each segment is
now percent-escaped, and the encoding is reversible. **Segments
containing neither `-` nor `%` are byte-for-byte unchanged**, so ordinary ids
are stable, but ids built from labels that contained `-` will differ from the
`0.8.0` output. `buildHandleMetadataMap` now keeps the first colliding entry and
dev-warns instead of silently overwriting.

### BREAKING

- Package published only as `@mp70/react-networks` (the unscoped `react-networks`
  name is retired). ESM consumers now get true `.d.mts` types; `./package.json`
  is exported. The `'use client'` banner is emitted on the client entry only,
  not on `./model`.
- Public API unified on British `Fibre` spelling (see migration step 2).
- Root surface finalized (`317 → 243` exports): NetBox/inventory converters moved
  to `./netbox` / `./inventory`; node/edge components and low-level geometry
  constants internalized; seven `ReactNetworks*` aliases dropped (migration steps
  3–5).
- CSS theming variables normalized to a single `--rn-*` prefix; dead `--rotation`
  removed (migration step 7).
- `DiagramDocument` gained a required `version: 1` discriminator (migration
  step 8).
- `FibreFlowMap` `readOnly` defaults to `true`; per-instance SVG ids namespaced
  (migration step 9).
- Device-port handle-id grammar is now injective and lossless via per-segment
  percent-escaping (migration step 10).

### Added

- **`@mp70/react-networks/netbox` and `@mp70/react-networks/inventory`** — the
  data converters now ship on dedicated server-safe subpath entries (zero
  React/React Flow at runtime), alongside the existing `./model` entry. All
  three are governed by their own API Extractor reports.
- **`onlyRenderVisibleElements`** — documented opt-in prop for large topologies.
- **`DiagramDocumentInput`** and **`ViewFace`** — exported so every public
  signature's types are nameable; six fibre layout constants
  (`PANEL_HEADER_HEIGHT`, `TRAY_NODE_HEIGHT`, `TRAY_NODE_HEIGHT_RIBBON`,
  `TRAY_WIDTH_PX`, `TRAY_HEADER_HEIGHT`, `HOLDER_HEIGHT_PX`) remain public as
  stable layout constants for manual fibre-scene composition.

### Fixed (post-merge review)

- Persisted `DiagramEndpoint` ids are now minted with `buildPortHandleId` — the
  same grammar the renderer mounts handles with. Previously a raw `group-label`
  join diverged from the escaped handle grammar for labels containing `-`/`%`
  (and for id-less power ports), so persisted edges could dangle after a
  document round-trip. The CHANGELOG also now ships in the npm tarball.

### Security

- Clamp untrusted numeric fields from a persisted `DiagramDocument` so a crafted
  document cannot exhaust memory/CPU on load or re-serialize: `powerPorts` is
  capped at 256 (`MAX_POWER_PORTS`) at every PDU port-allocation sink, and rack
  `uHeight` at 100 (`MAX_RACK_U`) at the U-marker render loop and the
  placement-availability scan. Previously these drove an unbounded `Array.from`
  / per-U loop (e.g. `powerPorts: 2e9` or `uHeight: 5e6`). Legitimate values are
  unaffected. Also indexed the document deserializer's endpoint resolution
  (was `O(edges × endpoints)`, now linear).

### Changed

- The reverse document adapter is now defensive: it guards missing
  `nodes`/`edges`, defaults a missing `placement`/`position`, skips unknown node
  kinds with a warning (instead of emitting bogus types), stops re-persisting
  the derived `isRackMounted` flag, and reconstructs container port groups on the
  reverse pass.
- `sanitizeHandleIdForMatching` is documented as intentionally many-to-one and
  must never be persisted; `+`-insensitive matching is narrowed to
  builder-produced ids.
- The `initialViewport: 'fit'` init fit no longer stomps a user's pan/zoom; the
  instance-scoped port-highlight store no longer clears highlights on a
  co-mounted diagram.

## 0.8.0

Breaking change shipped as a pre-1.0 minor bump — `0.7.x` remains the last
`reactflow` v11 line.

### BREAKING

- **Removed the headless store API.** `NetworkDiagram` no longer maintains a
  parallel `localStore` shadowing React Flow's own state — React Flow is the
  single source of truth for nodes and edges. Removed exports:
  `createNetworkDiagramStore`, `useNetworkDiagram`, `useDiagramNodes`,
  `useDiagramEdges`, the `store` prop, and related store types. Use React
  Flow's `useReactFlow()` / controlled `nodes`/`edges` props instead.
  Connection validation helpers remain as pure functions:
  `validateNetworkConnection`, `computeEdgeZIndex`.

- **Renders on `@xyflow/react` v12 instead of `reactflow` v11.** The peer
  dependency is now `@xyflow/react@^12`; consumers must install it and drop
  `reactflow`. Import the stylesheet from `@xyflow/react/dist/style.css`.
  This completes the v11 → v12 migration (native `colorMode`, native delete
  via `deleteKeyCode`/`onBeforeDelete`, the `node.measured` rendered-size
  seam, scoped `useNodeEdges` selectors, all node/edge components memoized).

### Added

- **`fitViewOnInit?: boolean`** (defaults to `true`, so no behaviour change).
  React Flow's built-in fit-on-init does not run at mount — it runs once node
  dimensions are first measured, which can be seconds later. A host that
  restored a persisted viewport would see that init fit land *afterwards* and
  silently overwrite the restored pan/zoom. `fitView` was previously hardcoded
  on, giving such hosts no way out; pass `fitViewOnInit={false}` to opt out.

### Fixed

- **De-rack restored on the racks demo** — the diagram had been mounted with
  the action panel disabled, removing the only control that pulls a device
  back out of a rack. The panel's button set is now derived from the reactive
  node array, so the De-rack button also updates immediately after use instead
  of lingering on an already-unracked device.
- **ODF demo render loop (also affected `0.7.x` / v11).** In the controlled
  document model, React Flow's `dimensions` (measurement) changes on the ~84
  rack-mounted panels were forwarded to the parent, round-tripping through the
  document adapter, which re-derived fresh nodes that React Flow re-measured —
  an unbounded "maximum update depth" loop that left `/odf` blank.
  `handleReactFlowNodesChange` now drops measurement-only change batches in
  controlled mode (the measured sizes are already applied to React Flow's
  internal state, so rendering is unaffected); real edits (position/add/
  remove/select) are still forwarded. `/odf` now renders reliably. Regression
  test: `example-site/src/__tests__/odf-render.spec.ts`.

### Performance

- **Drag frames no longer run O(E) edge z-index writes or O(N) layout effects**
  while any node is dragging. Face visibility and one-time layout fixes are
  deferred to drag end, removing the main-thread stall that made `/racks` feel
  frozen during device moves.
- **Panel auto-size** (PatchPanel, Closure) no longer writes `panelWidth`/`Height`
  back into node data on every measure — dimensions are computed for render and
  React Flow is notified via `updateNodeInternals`, eliminating the `/odf`
  write-back loop.
- **Device port highlight** uses per-handle subscriptions so unrelated devices
  do not re-render when another port is highlighted.
- **Rack/panel node self-mutations** route through `DiagramMutationProvider`
  (`updateNodeData` / `updateNodes`) so controlled hosts receive changes.
- **Fibre colour lookup** for WDM/splitter outputs uses a target-indexed edge
  map instead of scanning the full edge array.

- The fibre demo mounts with `onlyRenderVisibleElements`, cutting initial
  main-thread blocking ~30% on the ~240-node / ~760-handle graph.

## 0.7.1

### Fixed

- **Store recreated on every controlled edges change (CRITICAL perf/state
  bug)** — the internal store's creation memo depended on the validated
  controlled `edges` prop, so each edges-identity change rebuilt the store,
  wiped runtime state, and re-synced the whole graph; in controlled apps this
  fed a mount-time re-render cascade costing seconds of main thread. The
  store is now created exactly once per component lifetime (external `store`
  prop still honored), pinned by a regression test.
- **`mergeRuntimeStateIntoEdges` cloned every edge on every controlled
  sync** — it now mirrors the nodes merge: content-equal edges keep their
  identity and an unchanged set returns the previous array, so downstream
  subscribers stop observing phantom edge changes.

### Performance

- The 13 remaining node/edge components wrap in `React.memo`
  (Cable/Closure/Coupler/Fiber/FibreCableWithTubesExpanded/FibreSplit/
  MultiTubeCable/PatchPanel/Splice/SpliceTray/Tube nodes, StepFiber/ThickCable
  edges): parent state changes no longer re-render every node on the canvas.

## 0.7.0

The 0.6.1 fix batch below was never published; it ships here together with the
0.7 contract work.

### Added

- **Typed handle-id grammar module** — `utils/handle-ids` is now the single
  authority for the handle-id wire protocol:
  - `parseHandleId` + `ParsedHandleId`/`HandleShape`: total structural parse
    of `<base>[--rear|--front][-source|-target]`, with shapes for device
    ports, PDU outlets (bare numeric), fiber (`fiber-N` and FibreFlowMap's
    `fiber-T-N`), tubes, splice sides (bare `left`/`right`), coupler ports
    (`left-N`/`right-N`), fibre-split io (`input-N`/`output-N`), and the
    `cable-button` literal.
  - Builders — `buildFiberHandleId`, `buildTubeHandleId`,
    `buildSpliceHandleId`, `buildCouplerHandleId`, `buildSplitIoHandleId`,
    `applyFaceAliasHandleId` — components and consumers no longer template
    grammar strings.
  - `getOppositeSideHandleId` — splice `left` \<-\> `right`, coupler
    `left-N` \<-\> `right-N`.
  - A golden grammar contract test freezes every shape.
- **`describePortHandle`** (+ `DescribedPortHandle`) — resolve a
  rendered/stored handle id back to `{ port, blockName, face }`; the exact
  inverse of `buildPortHandleId`/`listPossiblePortHandleIds`.
  `validatePortHandleOnNodeData` is now its boolean shell.
- **`sanitizeHandleIdForMatching`** — `+`-stripping comparison normalizer
  (`SFP+-1` \<-\> `SFP-1` parity with app-side builders); a matching aid, not
  a build-grammar change.
- **`react-networks/model` subpath** — server-safe entry (zero runtime
  react/reactflow): core data types, the handle-id grammar, port-contract,
  port-position, document adapters, path-layout, and the fiber connection
  policy. Guarded by a release check that scans the built entry and imports
  it in bare Node.
- **React Flow type seam** — the root barrel re-exports `ConnectionMode`,
  `Position`, and the `Connection`/`Viewport`/`FitViewOptions`/
  `ReactFlowInstance`/`NodeTypes`/`EdgeTypes`/`XYPosition` types so consumers
  never import from `reactflow` directly (future React Flow major bumps
  change one seam, not every consumer file).
- **`applyDirectionalPortHandleId`** (+ exported
  `ApplyDirectionalPortHandleIdOptions`) — appends/replaces the
  `-source`/`-target` suffix on a rendered handle id, the inverse of
  `normalizeDirectionalHandleId`. Power handles are returned unchanged; pass
  `options.portType` so power-ness is decided by `isPowerPortType` (the same
  rule the renderer uses). Without a `portType` the decision falls back to the
  `looksLikePowerHandleId` string heuristic, which is documented as unable to
  recognize power ports whose ids lack `power`/`pwr`.
- **`ResolvedPortBlockInput`** exported from render-ports;
  **`RuntimeNode`/`RuntimeEdge`** exported from diagram-focus.

### Fixed

- **`normalizeHandleId` strips `--front` ghost aliases** — docs always
  claimed both split-face aliases were normalized, but only `--rear` was;
  endpoint metadata now also recognizes `--front` as an explicit face.
- **`classifyFiberEndpoint` tolerates directional suffixes** on splice
  handles (routes through the grammar parser).
- **`onConnectRejected` covers fiber-rule rejections** — rejections decided
  inside the polled `isValidConnection` are recorded and surfaced exactly once
  at gesture end (`onConnectEnd`/`onEdgeUpdateEnd`). Rejected reconnects now
  report `phase: "reconnect"` across the real event interleaving:
  `onConnectStart` (which React Flow fires inside an edge-update gesture) no
  longer clobbers the reconnect flag.
- **`listPossiblePortHandleIds`** decides power-ness via `isPowerPortType`
  (matching the renderer and handle-id builder) instead of a three-literal
  list, so `power`/`power_dc` ports no longer report phantom directional
  handles.
- **`resolveNodeDevicePorts`** accepts grouped `PortBlock[]` carried on
  `ports` (taking precedence over `portGroups`) plus a `rearPorts` fallback,
  matching the renderer's `getResolvedPortBlocks` precedence.
- **StrictMode safety** — a render-phase ref write in the
  `nodesWithDeviceDisplay` memo moved into an effect.

### Packaging

- License is now a valid SPDX expression
  (`AGPL-3.0-only OR LicenseRef-Commercial`) and the dual-license `LICENSE`
  file ships in the tarball.
- `npm test` works from a fresh clone: CSS imports are stubbed via Jest
  `moduleNameMapper` instead of a mock that only resolved once `dist/` was
  built.
- CI never publishes on branch pushes; the Release workflow
  (workflow_dispatch or `v*` tags) validates, publishes BOTH npm names with
  per-name version-exists guards, and creates the GitHub release.

## 0.6.0

### Added

- **`NetworkDiagram` zoom bounds** — new `minZoom` and `maxZoom` props to
  constrain the canvas zoom range.
- **`connectionRadius` prop** — forwards React Flow's `connectionRadius`, so
  small port handles are easier to grab when dragging connections.
- **`onConnectRejected` callback** — fires when a connect or reconnect attempt is
  rejected by the diagram's validation rules, letting consumers surface feedback
  (e.g. a toast) instead of the attempt silently failing.
- **`NetworkConnectionRejection` type** (exported) — payload passed to
  `onConnectRejected`:
  ```ts
  interface NetworkConnectionRejection {
    connection: Connection;          // the attempted connection
    reason: string;                  // human-readable rejection reason
    phase: "connect" | "reconnect";  // whether creating or reconnecting an edge
  }
  ```

### Changed

- **Fiber connection rules refactored** — the connection-validity logic moved out
  of the store into `evaluateFiberConnectionRule` / `classifyFiberEndpoint`
  (`utils/connection-rules.ts`), driven by an explicit allowed-pairs set. This is
  behaviour-preserving; all existing pairings (including the deliberate
  splice-side → splitter-input feed-in asymmetry) are unchanged.
- Patch-panel ghost handles, zoom, and the racks demo were refined.

### Fixed

- **Edge-id collisions** — edge ids now carry a monotonic sequence suffix in
  addition to the timestamp, preventing duplicate ids when an edge is deleted and
  a new one is created within the same millisecond.

### Packaging

- The library now publishes via `npm run publish:library`, which validates and
  publishes under the scoped name `@mp70/react-networks` while the workspace keeps
  the bare `react-networks` name for local development. The repo-root package is
  now `private` to stop it shadowing the library on publish.
