/* =============================================================================
   The dependency map.

   Same instrument rack as the control room, same tokens, and the same rule about
   movement: nothing on this screen animates by itself. The only transition is the
   dim that follows the pointer, because a forty node graph is unreadable until
   everything unrelated to the box under the cursor gets out of the way.

   Colour carries meaning and nothing else:
     green   can be started right now
     violet  the critical path, the chain that sets the finish date
     amber   how much work this task is holding up
     red     a circular dependency, which nothing will ever release
     blue    somebody is on it
     grey    waiting

   This file used to carry a complete second palette as var() fallbacks, on the
   theory that a fixture page might load it without styles.css. All sixteen of
   them had gone stale: violet was #a874ff against a live #009d89, red #ff5f57
   against #e56f72, amber #ffab26 against #e6a05d, and every neutral in the set
   had a blue cast where the live neutrals have a teal one. They were also the
   source of the three stale literals that turned up in styles.css. Dormant
   wrong answers waiting for one token rename, so they are gone: the map is only
   ever rendered inside the shell, where the tokens are always defined.
   ========================================================================== */

.dm {
  display: block;
  width: 100%;
  height: 100%;
  /* Insurance against a collapsed container, not a floor on the design: height
     100% inside an auto-height parent resolves to zero, and an invisible map is a
     baffling thing to debug. Below this the strips drop and it is all canvas. */
  min-height: 140px;
  background: var(--void);
  font-family: var(--ui);
  font-stretch: 92%;
  cursor: grab;
  outline: none;
  /* The wheel and the drag both belong to the map. Without this the browser
     scrolls the page out from under a pan on a touchpad. */
  touch-action: none;
  user-select: none;
  -webkit-user-select: none;
}

.dm[data-pan='1'] { cursor: grabbing; }
.dm:focus-visible { outline: 1px solid var(--violet); outline-offset: -1px; }
.dm-bg { fill: transparent; }

/* ---- edges ----------------------------------------------------------------- */

/* Stroke widths are held in screen pixels at every zoom. A hairline that thins
   with the scale disappears at the exact moment the reader zooms out to see the
   shape of the whole graph, which is when the edges matter most. */
.dm-edge-line,
.dm-edge-case,
.dm-box,
.dm-halo,
.dm-key-line,
.dm-tick {
  vector-effect: non-scaling-stroke;
}

.dm-edge { transition: opacity 130ms ease; }

.dm-edge-line {
  fill: none;
  stroke: var(--rule-hot);
  stroke-width: 1.1;
}

/* Drawn under the line and only switched on for the edges that carry an answer,
   so the critical path stays one continuous violet stroke through a crossing
   instead of being sliced up by whatever the mesh happens to draw over it. */
.dm-edge-case {
  fill: none;
  stroke: var(--void);
  stroke-width: 4.5;
  opacity: 0;
}

.dm-arrow { fill: var(--rule-hot); }
.dm-arrow-path { fill: var(--violet); }
.dm-arrow-cycle { fill: var(--red); }

.dm-edge[data-kind='path'] .dm-edge-line { stroke: var(--violet); stroke-width: 1.9; }
.dm-edge[data-kind='path'] .dm-edge-case { opacity: 1; }

.dm-edge[data-kind='cycle'] .dm-edge-line {
  stroke: var(--red);
  stroke-width: 1.5;
  stroke-dasharray: 5 4;
}

.dm-edge[data-kind='cycle'] .dm-edge-case { opacity: 1; }

/* The one edge in a ring that runs against the flow of work, arced over the top
   of both boxes so it cannot be mistaken for an ordinary dependency. */
.dm-edge[data-back='1'] .dm-edge-line { stroke-dasharray: 5 4; }

.dm-edge[data-lit] .dm-edge-case { opacity: 1; }
.dm-edge[data-kind='plain'][data-lit='near'] .dm-edge-line { stroke: var(--ink-dim); stroke-width: 1.4; }
.dm-edge[data-kind='plain'][data-lit='far'] .dm-edge-line { stroke: var(--ink-faint); }

/* ---- nodes ----------------------------------------------------------------- */

/* ---- how a node recedes -----------------------------------------------------

   NOT with group opacity, and this is the second time that lesson has been paid
   for in this file. `opacity` on a <g> composites the whole node, text
   included, against the pure black canvas, so the numbers it produced were not
   the numbers the rules above them claimed. Measured on 25 August 2026 at the
   0.45 tier: .dm-title came out rgb(74,78,77) = 2.36:1 at 11px, .dm-who
   rgb(46,72,79) = 2.05:1 at 10px, and .dm-id rgb(104,105,104) = 3.62:1, under a
   comment asserting that 0.45 "holds the ids above 4:1". It does not. Worse,
   `.dm[data-focus='1'] .dm-node` is the state EVERY unlit node enters the
   moment anything at all is hovered, so on a 60-node graph a mouse crossing the
   map made 59 of them unreadable.

   So the three tiers are custom properties and every one of them names a real
   colour. Measured against the plate each tier actually paints: full is
   --ink 14.4:1 / --ink-dim 8.59 / --blue 5.51; the middle tier keeps those and
   only recedes the plate and the border; the recessive tier is --ink-dim 8.59
   for the id and --ink-faint 5.60 for the title and the assignee, on --rack.
   Nothing in a node is below 4.5:1 in any tier. The fade that is left is on the
   BOX and the ACCENT BAR, which carry no type and can be faded honestly. */
.dm-node {
  --dm-id-ink: var(--ink);
  --dm-title-ink: var(--ink-dim);
  --dm-who-ink: var(--blue);
  --dm-plate: var(--rack-2);
  --dm-border: var(--rule-hot);
  --dm-accent-alpha: 1;
  cursor: pointer;
  transition: fill 130ms ease, stroke 130ms ease;
}

.dm-node:focus { outline: none; }

.dm-box {
  fill: var(--dm-plate);
  stroke: var(--dm-border);
  stroke-width: 1;
}

.dm-node:hover .dm-box { fill: var(--rack-3); }
.dm-node:focus-visible .dm-box { stroke: var(--ink); }

/* The border says where this task sits in the shape of the graph, the accent bar
   says what state it is in. Two separate questions, two separate channels, so the
   head of the critical path can also be the next thing to start. */
.dm-node[data-path='1'] .dm-box { stroke: var(--violet); stroke-width: 1.6; }
.dm-node[data-cycle='1'] .dm-box { stroke: var(--red); stroke-width: 1.4; stroke-dasharray: 4 3; }
.dm-node[data-selected='1'] .dm-box { stroke: var(--ink); stroke-width: 1.6; stroke-dasharray: none; }

.dm-accent { fill: var(--grey); fill-opacity: var(--dm-accent-alpha); }
.dm-node[data-status='review'] .dm-accent { fill: var(--teal); }
.dm-node[data-status='done'] .dm-accent { fill: var(--green); }
.dm-node[data-status='dead'] .dm-accent { fill: var(--red); }
.dm-node[data-assigned='1'] .dm-accent,
.dm-node[data-status='doing'] .dm-accent { fill: var(--blue); }
.dm-node[data-ready='1'] .dm-accent { fill: var(--green); }

/* Finished work recedes rather than disappearing: it is still the reason the task
   in front of it can start. It recedes on declared colours, for the reason set
   out at .dm-node above: `opacity: 0.45` here took every done task's title to
   2.36:1 and its assignee to 2.05:1, permanently. */
.dm-node[data-status='done'] {
  --dm-id-ink: var(--ink-dim);
  --dm-title-ink: var(--ink-faint);
  --dm-who-ink: var(--ink-faint);
  --dm-plate: var(--rack);
  --dm-border: var(--rule);
  --dm-accent-alpha: 0.55;
}
.dm-node[data-status='dead'] .dm-box { stroke-dasharray: 2 3; }

.dm-node[data-ready='1'] .dm-box { fill: var(--rack-3); }

.dm-halo {
  fill: none;
  stroke: var(--green);
  stroke-opacity: 0.5;
  stroke-width: 1.5;
}

.dm-id {
  font-family: var(--mono);
  font-size: 11.5px;
  font-weight: 700;
  fill: var(--dm-id-ink);
}

.dm-who {
  font-family: var(--mono);
  font-size: 10px;
  fill: var(--dm-who-ink);
}

.dm-title { font-size: 11px; fill: var(--dm-title-ink); }

.dm-badge-box {
  fill: rgba(255, 171, 38, 0.14);
  stroke: rgba(255, 171, 38, 0.45);
  stroke-width: 1;
}

.dm-badge-text {
  font-family: var(--mono);
  font-size: 10px;
  fill: var(--amber);
}

/* The single worst blocker wears the awaiting-input treatment from the control
   room, because it is the same message: everything else is queued behind this. */
.dm-node[data-weight='top'] .dm-badge-box { fill: var(--amber); stroke: var(--amber); }
.dm-node[data-weight='top'] .dm-badge-text { fill: #241500; font-weight: 700; }

.dm-pip-dot { fill: var(--green); stroke: var(--void); stroke-width: 1.5; }

.dm-pip-text {
  font-family: var(--mono);
  font-size: 9.5px;
  font-weight: 700;
  fill: #04150c;
}

/* Drafting-table corner ticks, the same mark the coordinator pane wears. */
.dm-tick { fill: none; stroke: var(--ink); stroke-width: 1.4; }

/* ---- zoom detail ------------------------------------------------------------ */

/* Below half size a title is a grey smear. Dropping it is both a legibility win
   and the cheapest way to make two hundred boxes pan smoothly. */
.dm[data-zoom='far'] .dm-title,
.dm[data-zoom='far'] .dm-who { display: none; }
.dm[data-zoom='tiny'] .dm-text { display: none; }
.dm[data-zoom='tiny'] .dm-pip-text { display: none; }

/* Arrowheads scale with the drawing, so at a distance they turn into specks that
   cost a marker render each and say nothing. */
.dm[data-zoom='far'] .dm-edge-line,
.dm[data-zoom='tiny'] .dm-edge-line { marker-end: none; }

/* ---- hover and selection ---------------------------------------------------- */

/* Three tiers, not two. A cone can be half the board, and half a board at full
   brightness is not a highlight, so the direct neighbours stay brighter than the
   rest of what the hovered task reaches. */
/* 0.14 was the floor here, then 0.45, and both were the same mistake measured
   differently: an opacity on a <g> takes the type down with the box. This state
   is entered by every unlit node the instant anything is hovered, which on the
   graph that was measured meant 59 nodes at once, so it is the single most
   expensive place in the app to get a contrast pair wrong.

   Three tiers, all of them real colours: unlit recedes the ink AND the plate,
   the far cone keeps full-strength type and recedes only the plate and border,
   and near and self are the base. */
.dm[data-focus='1'] .dm-node {
  --dm-id-ink: var(--ink-dim);
  --dm-title-ink: var(--ink-faint);
  --dm-who-ink: var(--ink-faint);
  --dm-plate: var(--rack);
  --dm-border: var(--rule);
  --dm-accent-alpha: 0.5;
}

.dm[data-focus='1'] .dm-node[data-lit='far'] {
  --dm-id-ink: var(--ink);
  --dm-title-ink: var(--ink-dim);
  --dm-who-ink: var(--blue);
  --dm-plate: var(--rack);
  --dm-border: var(--rule);
  --dm-accent-alpha: 0.8;
}

.dm[data-focus='1'] .dm-node[data-lit='near'],
.dm[data-focus='1'] .dm-node[data-lit='self'] {
  --dm-id-ink: var(--ink);
  --dm-title-ink: var(--ink-dim);
  --dm-who-ink: var(--blue);
  --dm-plate: var(--rack-2);
  --dm-border: var(--rule-hot);
  --dm-accent-alpha: 1;
}

/* The one under the pointer takes the white border, the same mark selection
   wears, because in both cases it means "this is the one you mean". */
.dm[data-focus='1'] .dm-node[data-lit='self'] .dm-box {
  stroke: var(--ink);
  stroke-width: 2;
  stroke-dasharray: none;
}

.dm[data-focus='1'] .dm-edge { opacity: 0.05; }
.dm[data-focus='1'] .dm-edge[data-lit='far'] { opacity: 0.55; }
.dm[data-focus='1'] .dm-edge[data-lit='near'] { opacity: 1; }

/* ---- strips ------------------------------------------------------------------ */

.dm-ui { pointer-events: none; }
.dm-hit { pointer-events: all; cursor: pointer; }

.dm-strip-bg { fill: var(--rack); fill-opacity: 0.93; }
.dm-strip-rule { stroke: var(--rule); stroke-width: 1; }

.dm-stat-value {
  font-family: var(--mono);
  font-size: 11px;
  font-weight: 700;
  fill: var(--ink);
}

.dm-stat-label,
.dm-key-label,
.dm-chip-label {
  font-size: 9.5px;
  letter-spacing: 0.13em;
  text-transform: uppercase;
  fill: var(--ink-faint);
}

.dm-stat[data-tone='ready'] .dm-stat-value { fill: var(--green); }
.dm-stat[data-tone='running'] .dm-stat-value { fill: var(--blue); }
.dm-stat[data-tone='path'] .dm-stat-value { fill: var(--violet); }

.dm-chip-box { fill: transparent; stroke-width: 1; }

.dm-chip-value {
  font-family: var(--mono);
  font-size: 10.5px;
  fill: var(--ink-dim);
}

.dm-chip[data-tone='cycle'] .dm-chip-box {
  stroke: var(--red);
  fill: rgba(255, 95, 87, 0.08);
}

.dm-chip[data-tone='cycle'] .dm-chip-label { fill: var(--red); }
.dm-chip[data-tone='cycle'] .dm-chip-value { fill: #ffd7d5; }

.dm-chip[data-tone='block'] .dm-chip-box {
  stroke: rgba(255, 171, 38, 0.45);
  fill: rgba(255, 171, 38, 0.08);
}

.dm-chip[data-tone='block'] .dm-chip-value { fill: var(--amber); }
.dm-chip:hover .dm-chip-box { fill-opacity: 1; fill: var(--rack-3); }
.dm-chip:focus-visible .dm-chip-box { stroke: var(--ink); }

.dm-key-swatch { fill: var(--grey); stroke: none; }
.dm-key[data-tone='ready'] .dm-key-swatch { fill: var(--green); }
.dm-key[data-tone='block'] .dm-key-swatch { fill: var(--amber); }
.dm-key[data-tone='done'] .dm-key-swatch { fill: var(--green); fill-opacity: 0.4; }
.dm-key-line { stroke: var(--violet); stroke-width: 2; }
.dm-key[data-tone='cycle'] .dm-key-line { stroke: var(--red); stroke-dasharray: 4 3; }

.dm-btn-box {
  fill: var(--rack-3);
  stroke: var(--rule-hot);
  stroke-width: 1;
}

.dm-btn-text {
  font-size: 11px;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  fill: var(--ink-dim);
}

.dm-btn:hover .dm-btn-box { stroke: var(--ink-faint); }
.dm-btn:hover .dm-btn-text { fill: var(--ink); }
.dm-btn:focus-visible .dm-btn-box { stroke: var(--violet); }

.dm-scale {
  font-family: var(--mono);
  font-size: 10.5px;
  fill: var(--ink-faint);
}

/* ---- empty ------------------------------------------------------------------- */

.dm-empty-title {
  font-size: 11px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  fill: var(--ink-dim);
}

.dm-empty-body { font-size: 12px; fill: var(--ink-faint); }

@media (prefers-reduced-motion: reduce) {
  .dm-node,
  .dm-edge { transition: none; }
}

/* ---- the map in daylight ----------------------------------------------------
   Almost everything above is already var() onto the control room's tokens, so
   the light theme retints the map for free. These three are the exceptions: two
   are inks written to sit on a FILLED badge, and in daylight those badges are
   dark rather than light, so the ink has to invert with them. The third is a
   pale pink that only ever worked on a black ground.

   They are overrides rather than edits above because the dark sheet is correct
   as written and the values are documented where they are; this block is the
   one place that says what daylight does differently. */
html[data-theme='light'] .dm-node[data-weight='top'] .dm-badge-text {
  /* --on-warn is exactly this decision, already named: the ink that sits on a
     filled --amber. It was the literal #241500 here because the map predates the
     token, and a fallback would be a second palette waiting to go stale. */
  fill: var(--on-warn);
}

html[data-theme='light'] .dm-pip-text {
  /* No --on-ok exists; the pip is a filled --green and --on-accent is the same
     decision one hue over, which is closer than leaving a near-black on it. */
  fill: var(--on-accent);
}

html[data-theme='light'] .dm-chip[data-tone='cycle'] .dm-chip-value {
  /* #ffd7d5 is a tint of --red lifted AWAY from a black ground, so on white it
     is barely a colour at all. Daylight wants the step in the other direction,
     and the label beside it is already flat --red. */
  fill: color-mix(in srgb, var(--red) 78%, var(--ink));
}
