{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "opportunities",
  "title": "Opportunities",
  "description": "The opportunity matrix family: every routing pathway considered for a material requirement — multi-step routes, itemised cost estimates, compatibility scores and AI viability assessments — presented visually first (a proximity map, a cost-proportional flow canvas and a cost-vs-readiness decision grid) with comparable cards, a baseline-anchored comparison, a full transparency matrix and a composable detail record behind.",
  "dependencies": [
    "@untitledui/icons@^0.0.22"
  ],
  "registryDependencies": [
    "@nx-ui/badge",
    "@nx-ui/card",
    "@nx-ui/table",
    "@nx-ui/utils"
  ],
  "files": [
    {
      "path": "components/blocks/domain/opportunities/cost-breakdown.tsx",
      "content": "import { cn } from \"@/lib/utils\"\nimport { SavingsDelta } from \"./opportunity-primitives\"\nimport {\n  COST_KIND_LABELS,\n  formatMoney,\n  formatPerTonne,\n  opportunityTotalCost,\n  type CostKind,\n  type MaterialRequirement,\n  type Opportunity,\n} from \"./types\"\n\n/**\n * CostBreakdown — the itemised cost estimate for one pathway: a proportional\n * composition bar (positive lines only, coloured by cost kind), the line\n * items with notes and estimated/quoted treatment, the net total (with a\n * per-tonne rate when the requirement is supplied) and, against a baseline,\n * the delta. Credits (negative lines) render green and sit outside the bar.\n */\n\nconst COST_KIND_BAR_CLASSES: Record<CostKind, string> = {\n  haulage: \"bg-utility-blue-500\",\n  gate_fee: \"bg-utility-orange-500\",\n  landfill_tax: \"bg-utility-red-500\",\n  treatment: \"bg-utility-purple-500\",\n  testing: \"bg-utility-sky-500\",\n  storage: \"bg-utility-amber-500\",\n  material_purchase: \"bg-utility-indigo-500\",\n  processing: \"bg-utility-fuchsia-500\",\n  admin: \"bg-utility-neutral-500\",\n  credit: \"bg-utility-green-500\",\n  other: \"bg-utility-slate-500\",\n}\n\nfunction amountLabel(amount: number, estimated?: boolean): string {\n  const formatted = formatMoney(amount)\n  return estimated ? `~${formatted}` : formatted\n}\n\nexport function CostBreakdown({\n  opportunity,\n  baseline,\n  requirement,\n  className,\n}: {\n  opportunity: Opportunity\n  /** When given (and this isn't it), a comparison row is appended. */\n  baseline?: Opportunity\n  /** Enables the per-tonne rate on the total row. */\n  requirement?: MaterialRequirement\n  className?: string\n}) {\n  const positive = opportunity.costs.filter((line) => line.amount > 0)\n  const grossPositive = positive.reduce((sum, line) => sum + line.amount, 0)\n  const total = opportunityTotalCost(opportunity)\n  const legendKinds = [...new Set(positive.map((line) => line.kind))]\n\n  return (\n    <div data-slot=\"cost-breakdown\" className={cn(\"flex flex-col gap-3\", className)}>\n      {positive.length > 0 ? (\n        <div className=\"flex flex-col gap-1.5\">\n          <div className=\"flex h-2.5 w-full gap-px overflow-hidden rounded-full\">\n            {positive.map((line) => (\n              <span\n                key={line.id}\n                className={cn(\"h-full\", COST_KIND_BAR_CLASSES[line.kind])}\n                style={{ width: `${(line.amount / grossPositive) * 100}%` }}\n                title={`${line.label} — ${amountLabel(line.amount, line.estimated)}`}\n              />\n            ))}\n          </div>\n          <div className=\"flex flex-wrap gap-x-3 gap-y-1\">\n            {legendKinds.map((kind) => (\n              <span\n                key={kind}\n                className=\"flex items-center gap-1.5 text-xs text-text-tertiary\"\n              >\n                <span\n                  className={cn(\n                    \"size-2 shrink-0 rounded-full\",\n                    COST_KIND_BAR_CLASSES[kind]\n                  )}\n                />\n                {COST_KIND_LABELS[kind]}\n              </span>\n            ))}\n          </div>\n        </div>\n      ) : null}\n\n      <ul className=\"flex flex-col gap-2\">\n        {opportunity.costs.map((line) => {\n          const credit = line.amount < 0\n          return (\n            <li key={line.id} className=\"flex items-start justify-between gap-4\">\n              <span className=\"flex min-w-0 items-start gap-2\">\n                <span\n                  className={cn(\n                    \"mt-1.5 size-2 shrink-0 rounded-full\",\n                    COST_KIND_BAR_CLASSES[line.kind]\n                  )}\n                />\n                <span className=\"flex min-w-0 flex-col\">\n                  <span className=\"text-sm text-text-secondary\">{line.label}</span>\n                  {line.note ? (\n                    <span className=\"text-xs text-text-tertiary\">{line.note}</span>\n                  ) : null}\n                </span>\n              </span>\n              <span\n                className={cn(\n                  \"text-sm font-medium whitespace-nowrap tabular-nums\",\n                  credit ? \"text-success-primary\" : \"text-text-secondary\"\n                )}\n              >\n                {amountLabel(line.amount, line.estimated)}\n              </span>\n            </li>\n          )\n        })}\n      </ul>\n\n      <div className=\"flex items-baseline justify-between gap-4 border-t border-border-secondary pt-3\">\n        <span className=\"text-sm font-semibold text-foreground\">\n          Estimated net cost\n        </span>\n        <span className=\"flex items-baseline gap-2\">\n          {requirement ? (\n            <span className=\"text-xs text-text-tertiary tabular-nums\">\n              {formatPerTonne(total, requirement)}\n            </span>\n          ) : null}\n          <span className=\"text-sm font-semibold text-foreground tabular-nums\">\n            {formatMoney(total)}\n          </span>\n        </span>\n      </div>\n\n      {baseline && !opportunity.isBaseline ? (\n        <div className=\"flex items-baseline justify-between gap-4 rounded-lg bg-bg-secondary px-3 py-2\">\n          <span className=\"text-xs text-text-tertiary\">\n            Baseline — {baseline.name}: {formatMoney(opportunityTotalCost(baseline))}\n          </span>\n          <SavingsDelta opportunity={opportunity} baseline={baseline} />\n        </div>\n      ) : null}\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/blocks/domain/opportunities/cost-breakdown.tsx"
    },
    {
      "path": "components/blocks/domain/opportunities/data.ts",
      "content": "/**\n * Demo datasets for the opportunity matrix family. Two scenarios exercise\n * both requirement directions: a topsoil surplus (export) with disposal,\n * re-use, treatment and storage pathways, and a 6F2 capping need (import)\n * with primary, recycled, surplus and site-won sources. Figures are\n * illustrative but internally consistent (same load counts, per-tonne rates\n * and volumes across lines) so comparison surfaces read sensibly.\n */\n\nimport type { MaterialRequirement, Opportunity } from \"./types\"\n\n// ---------------------------------------------------------------------------\n// Scenario 1 — export: surplus topsoil, Riverside Quarter (Bristol)\n// ---------------------------------------------------------------------------\n\nexport const TOPSOIL_REQUIREMENT: MaterialRequirement = {\n  id: \"req-topsoil-export\",\n  direction: \"export\",\n  title: \"Surplus topsoil — Riverside Quarter\",\n  projectName: \"Riverside Quarter\",\n  material: {\n    classLabel: \"Topsoil\",\n    termLabel: \"Topsoil — natural, stripped\",\n    ewcCode: \"17 05 04\",\n    regulatoryStatus: \"waste\",\n    qualityLevel: \"tested\",\n  },\n  quantity: { value: 3800, unit: \"m³\", volumeM3: 3800, tonnes: 5300 },\n  location: { name: \"Bedminster, Bristol\", postcode: \"BS3 4NA\" },\n  window: { from: \"2026-08-03\", until: \"2026-10-30\" },\n  summary:\n    \"Phase 1 earthworks strip generates 3,800 m³ of tested topsoil that must leave site between August and the end of October. The engine considered 7 pathways; 5 remain open.\",\n}\n\nexport const TOPSOIL_OPPORTUNITIES: Opportunity[] = [\n  {\n    id: \"opp-shortwood-landfill\",\n    requirementId: \"req-topsoil-export\",\n    name: \"Disposal — Shortwood Landfill\",\n    strategy: \"landfill_disposal\",\n    isBaseline: true,\n    headline: \"The default route: guaranteed capacity at the highest cost.\",\n    partner: { name: \"Vale Waste Management\", siteName: \"Shortwood Landfill\" },\n    route: [\n      { id: \"n1\", kind: \"site\", label: \"Riverside Quarter\" },\n      {\n        id: \"n2\",\n        kind: \"landfill\",\n        label: \"Shortwood Landfill\",\n        geo: { bearingDeg: 75, distanceKm: 16 },\n        detail: \"Disposal as inert waste, EWC 17 05 04\",\n        legDistanceKm: 18,\n      },\n    ],\n    totalDistanceKm: 18,\n    costs: [\n      {\n        id: \"c1\",\n        kind: \"haulage\",\n        label: \"Haulage — 265 loads to Shortwood\",\n        amount: 47700,\n        note: \"18 km each way at ~£9/t\",\n        estimated: true,\n      },\n      {\n        id: \"c2\",\n        kind: \"gate_fee\",\n        label: \"Gate fee\",\n        amount: 95400,\n        note: \"£18/t on 5,300 t\",\n      },\n      {\n        id: \"c3\",\n        kind: \"landfill_tax\",\n        label: \"Landfill tax — lower (inert) rate\",\n        amount: 21465,\n        note: \"£4.05/t; standard rate £126.15/t applies if WAC fails\",\n      },\n    ],\n    timing: { fit: \"good\", note: \"Accepts material year-round\" },\n    viability: {\n      verdict: \"viable\",\n      confidence: 0.95,\n      summary:\n        \"Always available as a fallback, but the most expensive open pathway by a wide margin and the worst sustainability outcome.\",\n      strengths: [\"Guaranteed capacity with no counterparty risk\", \"No programme dependency\"],\n      risks: [\n        \"A failed WAC test reclassifies the load at the standard tax rate — roughly £640,000 more\",\n        \"Scores zero against the project's material re-use targets\",\n      ],\n      conditions: [\n        {\n          id: \"v1\",\n          label: \"WAC characterisation testing\",\n          status: \"pending\",\n          detail: \"Sampling booked for the week of 20 July\",\n        },\n      ],\n      assessedAt: \"2026-07-04T06:12:00Z\",\n    },\n    compliance: [\n      { id: \"k1\", label: \"Waste transfer notes per load\", status: \"ok\" },\n      { id: \"k2\", label: \"Carrier registration (WCL)\", status: \"ok\" },\n      { id: \"k3\", label: \"Receiver environmental permit\", status: \"ok\" },\n      { id: \"k4\", label: \"WAC test results\", status: \"pending\" },\n    ],\n    status: \"new\",\n  },\n  {\n    id: \"opp-meadowbank-reuse\",\n    requirementId: \"req-topsoil-export\",\n    name: \"Direct re-use — Meadowbank Park\",\n    strategy: \"direct_reuse\",\n    headline: \"Their landscaping needs 5,000 m³ from September — an exact material match 12 km away.\",\n    partner: { name: \"Harborne Developments\", siteName: \"Meadowbank Park\" },\n    route: [\n      { id: \"n1\", kind: \"site\", label: \"Riverside Quarter\" },\n      {\n        id: \"n2\",\n        kind: \"receiving_site\",\n        label: \"Meadowbank Park\",\n        geo: { bearingDeg: 20, distanceKm: 11 },\n        detail: \"Placed as landscaping fill under DoW:CoP\",\n        legDistanceKm: 12,\n      },\n    ],\n    totalDistanceKm: 12,\n    costs: [\n      {\n        id: \"c1\",\n        kind: \"haulage\",\n        label: \"Haulage — 265 loads to Meadowbank\",\n        amount: 31800,\n        note: \"12 km each way at ~£6/t\",\n        estimated: true,\n      },\n      {\n        id: \"c2\",\n        kind: \"testing\",\n        label: \"BS 3882 laboratory suite\",\n        amount: 3400,\n        note: \"MFT, pH, nutrients and contamination screen\",\n      },\n      {\n        id: \"c3\",\n        kind: \"admin\",\n        label: \"DoW:CoP declaration and materials management plan\",\n        amount: 1900,\n      },\n    ],\n    timing: {\n      fit: \"good\",\n      note: \"They take delivery from September; your clearance runs to end October\",\n    },\n    compatibility: {\n      score: 92,\n      breakdown: { material: 1, distance: 0.93, quantity: 0.85 },\n      relation: \"exact\",\n      explainers: [\n        \"Exact material term match\",\n        \"Very close proximity (12 km)\",\n        \"Receiving requirement of 5,000 m³ absorbs the full volume\",\n      ],\n    },\n    viability: {\n      verdict: \"conditional\",\n      confidence: 0.78,\n      summary:\n        \"Strong match with major savings, provided the BS 3882 results come back within specification.\",\n      strengths: [\n        \"Waste-to-product transfer under DoW:CoP removes gate fee and landfill tax entirely\",\n        \"Programme windows align with six weeks of slack\",\n        \"Shortest haul of any open pathway\",\n      ],\n      risks: [\n        \"Prior observations show slightly elevated pH — a failed MFT would need blending before placement\",\n        \"Receiving site's landscaping package is not yet let\",\n      ],\n      conditions: [\n        { id: \"v1\", label: \"BS 3882 laboratory results\", status: \"pending\", detail: \"Results expected 24 July\" },\n        { id: \"v2\", label: \"DoW:CoP declaration\", status: \"action_needed\", detail: \"Draft prepared; needs both parties' sign-off\" },\n        { id: \"v3\", label: \"Receiving materials management plan\", status: \"met\" },\n      ],\n      assessedAt: \"2026-07-04T06:12:00Z\",\n    },\n    compliance: [\n      { id: \"k1\", label: \"Materials management plan\", status: \"ok\" },\n      { id: \"k2\", label: \"DoW:CoP declaration\", status: \"gap\" },\n      { id: \"k3\", label: \"Carrier registration (WCL)\", status: \"ok\" },\n    ],\n    status: \"shortlisted\",\n  },\n  {\n    id: \"opp-avonmouth-hartcliffe\",\n    requirementId: \"req-topsoil-export\",\n    name: \"Treat, then re-use — Hartcliffe Green\",\n    strategy: \"treat_then_reuse\",\n    headline: \"Screening at Avonmouth brings the soil to BS 3882, opening a confirmed placement.\",\n    partner: { name: \"Severn Soils\", siteName: \"Avonmouth Soil Treatment Centre\" },\n    route: [\n      { id: \"n1\", kind: \"site\", label: \"Riverside Quarter\" },\n      {\n        id: \"n2\",\n        kind: \"treatment_facility\",\n        label: \"Avonmouth Soil Treatment Centre\",\n        geo: { bearingDeg: 310, distanceKm: 8 },\n        detail: \"Screen to 10 mm and blend to BS 3882 general purpose\",\n        legDistanceKm: 9,\n      },\n      {\n        id: \"n3\",\n        kind: \"receiving_site\",\n        label: \"Hartcliffe Green scheme\",\n        geo: { bearingDeg: 170, distanceKm: 5 },\n        detail: \"Placed as amenity topsoil\",\n        legDistanceKm: 14,\n      },\n    ],\n    totalDistanceKm: 23,\n    costs: [\n      {\n        id: \"c1\",\n        kind: \"haulage\",\n        label: \"Haulage — site to Avonmouth\",\n        amount: 23850,\n        note: \"9 km at ~£4.50/t\",\n        estimated: true,\n      },\n      {\n        id: \"c2\",\n        kind: \"treatment\",\n        label: \"Screening and blending\",\n        amount: 42400,\n        note: \"£8/t on 5,300 t, quoted\",\n      },\n      {\n        id: \"c3\",\n        kind: \"haulage\",\n        label: \"Haulage — Avonmouth to Hartcliffe\",\n        amount: 37100,\n        note: \"14 km at ~£7/t\",\n        estimated: true,\n      },\n      {\n        id: \"c4\",\n        kind: \"testing\",\n        label: \"Post-treatment validation\",\n        amount: 2800,\n      },\n    ],\n    timing: { fit: \"good\", note: \"Facility throughput 1,200 t/week — five weeks of processing\" },\n    compatibility: {\n      score: 81,\n      breakdown: { material: 0.7, distance: 0.88, quantity: 1 },\n      relation: \"same_class\",\n      explainers: [\n        \"Same material class; treatment closes the specification gap\",\n        \"Both legs well inside the haulage radius\",\n        \"Quantity fully meets the receiving requirement\",\n      ],\n    },\n    viability: {\n      verdict: \"viable\",\n      confidence: 0.86,\n      summary:\n        \"The treatment step guarantees specification, converting an uncertain match into a confirmed placement at well under landfill cost.\",\n      strengths: [\n        \"Facility has confirmed capacity in writing\",\n        \"Treatment de-risks the specification question entirely\",\n        \"Receiving scheme has an agreed take-off\",\n      ],\n      risks: [\"Two handling steps add programme dependency\", \"Quoted treatment rate holds until 15 August\"],\n      conditions: [\n        { id: \"v1\", label: \"Facility acceptance criteria\", status: \"met\" },\n        { id: \"v2\", label: \"Post-treatment validation testing\", status: \"pending\" },\n      ],\n      assessedAt: \"2026-07-04T06:12:00Z\",\n    },\n    compliance: [\n      { id: \"k1\", label: \"Waste transfer notes per load\", status: \"ok\" },\n      { id: \"k2\", label: \"Treatment facility permit\", status: \"ok\" },\n      { id: \"k3\", label: \"Carrier registration (WCL)\", status: \"ok\" },\n      { id: \"k4\", label: \"Post-treatment quality declaration\", status: \"pending\" },\n    ],\n    status: \"new\",\n  },\n  {\n    id: \"opp-treat-and-return\",\n    requirementId: \"req-topsoil-export\",\n    name: \"Treat off site and return\",\n    strategy: \"treat_and_return\",\n    headline: \"Screen at Avonmouth, hold, and return for phase 2 landscaping — avoiding a future purchase.\",\n    partner: { name: \"Severn Soils\", siteName: \"Avonmouth Soil Treatment Centre\" },\n    route: [\n      { id: \"n1\", kind: \"site\", label: \"Riverside Quarter\" },\n      {\n        id: \"n2\",\n        kind: \"treatment_facility\",\n        label: \"Avonmouth Soil Treatment Centre\",\n        geo: { bearingDeg: 310, distanceKm: 8 },\n        detail: \"Screen and blend to BS 3882, then hold\",\n        legDistanceKm: 9,\n        dwellNote: \"held ~10 weeks\",\n      },\n      {\n        id: \"n3\",\n        kind: \"site\",\n        label: \"Riverside Quarter (return)\",\n        geo: { bearingDeg: 0, distanceKm: 0 },\n        detail: \"Placed in phase 2 landscaping\",\n        legDistanceKm: 9,\n      },\n    ],\n    totalDistanceKm: 18,\n    costs: [\n      {\n        id: \"c1\",\n        kind: \"haulage\",\n        label: \"Haulage — site to Avonmouth\",\n        amount: 23850,\n        note: \"9 km at ~£4.50/t\",\n        estimated: true,\n      },\n      {\n        id: \"c2\",\n        kind: \"treatment\",\n        label: \"Screening and blending\",\n        amount: 42400,\n        note: \"£8/t on 5,300 t, quoted\",\n      },\n      {\n        id: \"c3\",\n        kind: \"storage\",\n        label: \"Holding at facility — 10 weeks\",\n        amount: 13250,\n        estimated: true,\n      },\n      {\n        id: \"c4\",\n        kind: \"haulage\",\n        label: \"Haulage — return to site\",\n        amount: 23850,\n        estimated: true,\n      },\n      {\n        id: \"c5\",\n        kind: \"credit\",\n        label: \"Avoided phase 2 topsoil purchase\",\n        amount: -83600,\n        note: \"3,800 m³ at ~£22/m³ delivered\",\n        estimated: true,\n      },\n    ],\n    timing: { fit: \"tight\", note: \"Return depends on phase 2 landscaping starting in November as programmed\" },\n    viability: {\n      verdict: \"conditional\",\n      confidence: 0.62,\n      summary:\n        \"The cheapest net position of any pathway if phase 2 proceeds on programme — but highly sensitive to the phase 2 start date.\",\n      strengths: [\n        \"Offsets a purchase you would otherwise make in November\",\n        \"Material provenance stays under your control\",\n        \"Waste-to-product transfer removes disposal costs\",\n      ],\n      risks: [\n        \"A phase 2 slip turns holding into an open-ended monthly cost\",\n        \"Double handling raises the damage and segregation risk\",\n      ],\n      conditions: [\n        { id: \"v1\", label: \"Phase 2 start date confirmed\", status: \"action_needed\", detail: \"Awaiting the November programme freeze\" },\n        { id: \"v2\", label: \"Facility storage agreement\", status: \"pending\" },\n      ],\n      assessedAt: \"2026-07-04T06:12:00Z\",\n    },\n    compliance: [\n      { id: \"k1\", label: \"Waste transfer notes per load\", status: \"ok\" },\n      { id: \"k2\", label: \"Treatment facility permit\", status: \"ok\" },\n      { id: \"k3\", label: \"Post-treatment quality declaration\", status: \"pending\" },\n    ],\n    status: \"new\",\n  },\n  {\n    id: \"opp-severnside-stockpile\",\n    requirementId: \"req-topsoil-export\",\n    name: \"Stockpile — Severnside Storage Yard\",\n    strategy: \"stockpile_hold\",\n    headline: \"Move it 6 km and hold while a better match is found.\",\n    partner: { name: \"Severnside Yard Ltd\" },\n    route: [\n      { id: \"n1\", kind: \"site\", label: \"Riverside Quarter\" },\n      {\n        id: \"n2\",\n        kind: \"stockpile\",\n        label: \"Severnside Storage Yard\",\n        geo: { bearingDeg: 343, distanceKm: 5.5 },\n        detail: \"Open stockpile, tarped\",\n        legDistanceKm: 6,\n        dwellNote: \"held until matched\",\n      },\n    ],\n    totalDistanceKm: 6,\n    costs: [\n      {\n        id: \"c1\",\n        kind: \"haulage\",\n        label: \"Haulage — 265 loads to Severnside\",\n        amount: 15900,\n        note: \"6 km at ~£3/t\",\n        estimated: true,\n      },\n      {\n        id: \"c2\",\n        kind: \"storage\",\n        label: \"Storage — 4 months modelled\",\n        amount: 44520,\n        note: \"£2.10/t per month; accrues until matched\",\n        estimated: true,\n      },\n      { id: \"c3\", kind: \"admin\", label: \"Yard licence and records\", amount: 950 },\n    ],\n    timing: { fit: \"tight\", note: \"Clears your window, but the end use is unmatched\" },\n    viability: {\n      verdict: \"unlikely\",\n      confidence: 0.45,\n      summary:\n        \"Defers the problem rather than solving it: storage accrues monthly, a second haul is still to pay, and no end use is matched yet.\",\n      strengths: [\"Preserves optionality if a premium match appears later\", \"Clears the site fastest\"],\n      risks: [\n        \"Open-ended monthly cost with no matched buyer\",\n        \"Outdoor storage degrades topsoil structure over winter\",\n        \"Second haulage movement still to fund\",\n      ],\n      conditions: [\n        { id: \"v1\", label: \"End use identified\", status: \"action_needed\" },\n        { id: \"v2\", label: \"Yard permit headroom\", status: \"met\" },\n      ],\n      assessedAt: \"2026-07-04T06:12:00Z\",\n    },\n    compliance: [\n      { id: \"k1\", label: \"Waste transfer notes per load\", status: \"ok\" },\n      { id: \"k2\", label: \"Storage site permit\", status: \"ok\" },\n    ],\n    status: \"new\",\n  },\n  {\n    id: \"opp-wraxall-recovery\",\n    requirementId: \"req-topsoil-export\",\n    name: \"Land recovery — Wraxall Farm\",\n    strategy: \"recovery\",\n    headline: \"Agricultural improvement 21 km south — capped by the receiving exemption.\",\n    partner: { name: \"Wraxall Farm Partnership\" },\n    route: [\n      { id: \"n1\", kind: \"site\", label: \"Riverside Quarter\" },\n      {\n        id: \"n2\",\n        kind: \"receiving_site\",\n        label: \"Wraxall Farm\",\n        geo: { bearingDeg: 215, distanceKm: 18 },\n        detail: \"Spread for agricultural improvement under a U1 exemption\",\n        legDistanceKm: 21,\n      },\n    ],\n    totalDistanceKm: 21,\n    costs: [\n      {\n        id: \"c1\",\n        kind: \"haulage\",\n        label: \"Haulage — full volume\",\n        amount: 55650,\n        note: \"21 km at ~£10.50/t\",\n        estimated: true,\n      },\n      { id: \"c2\", kind: \"processing\", label: \"Spreading and placement\", amount: 12000, estimated: true },\n    ],\n    timing: { fit: \"good\", note: \"Receiving land available through autumn\" },\n    compatibility: {\n      score: 74,\n      breakdown: { material: 0.7, distance: 0.62, quantity: 1 },\n      relation: \"same_class\",\n      explainers: [\"Same material class\", \"Within the haulage radius\", \"Land could notionally take the full volume\"],\n    },\n    viability: {\n      verdict: \"ruled_out\",\n      confidence: 0.9,\n      summary:\n        \"The receiving land's U1 exemption caps deposits at 1,000 t — under a fifth of the volume — so the pathway cannot clear the requirement.\",\n      strengths: [],\n      risks: [],\n      conditions: [],\n      ruledOutReason: \"Volume exceeds the receiving U1 exemption limit (1,000 t)\",\n      assessedAt: \"2026-07-04T06:12:00Z\",\n    },\n    compliance: [\n      { id: \"k1\", label: \"Receiving exemption headroom\", status: \"gap\" },\n    ],\n    status: \"dismissed\",\n  },\n  {\n    id: \"opp-bathside-link\",\n    requirementId: \"req-topsoil-export\",\n    name: \"Direct re-use — Bathside Link Road\",\n    strategy: \"direct_reuse\",\n    headline: \"An exact material match, but their programme starts after your site must clear.\",\n    partner: { name: \"Kennet Infrastructure\", siteName: \"Bathside Link Road\" },\n    route: [\n      { id: \"n1\", kind: \"site\", label: \"Riverside Quarter\" },\n      {\n        id: \"n2\",\n        kind: \"receiving_site\",\n        label: \"Bathside Link Road\",\n        geo: { bearingDeg: 100, distanceKm: 23 },\n        detail: \"Verge and embankment topsoiling\",\n        legDistanceKm: 26,\n      },\n    ],\n    totalDistanceKm: 26,\n    costs: [\n      {\n        id: \"c1\",\n        kind: \"haulage\",\n        label: \"Haulage — full volume\",\n        amount: 63600,\n        note: \"26 km at ~£12/t\",\n        estimated: true,\n      },\n      { id: \"c2\", kind: \"testing\", label: \"BS 3882 laboratory suite\", amount: 3400 },\n    ],\n    timing: { fit: \"mismatch\", note: \"They need material from January 2027; you must clear by end October\" },\n    compatibility: {\n      score: 68,\n      breakdown: { material: 1, distance: 0.55, quantity: 0.6 },\n      relation: \"exact\",\n      explainers: [\"Exact material term match\", \"Moderate haul (26 km)\", \"Their requirement takes ~60% of the volume\"],\n    },\n    viability: {\n      verdict: \"ruled_out\",\n      confidence: 0.85,\n      summary:\n        \"Programme windows do not overlap: the receiving scheme cannot take material until January, three months after your clearance deadline.\",\n      strengths: [],\n      risks: [],\n      conditions: [],\n      ruledOutReason: \"Programme windows do not overlap (theirs opens January 2027)\",\n      assessedAt: \"2026-07-04T06:12:00Z\",\n    },\n    compliance: [],\n    status: \"dismissed\",\n  },\n]\n\n// ---------------------------------------------------------------------------\n// Scenario 2 — import: 6F2 capping aggregate, Temple Quay Phase 2 (Bristol)\n// ---------------------------------------------------------------------------\n\nexport const AGGREGATE_REQUIREMENT: MaterialRequirement = {\n  id: \"req-aggregate-import\",\n  direction: \"import\",\n  title: \"6F2 capping aggregate — Temple Quay Phase 2\",\n  projectName: \"Temple Quay Phase 2\",\n  material: {\n    classLabel: \"Aggregate\",\n    termLabel: \"6F2 capping / fill\",\n    regulatoryStatus: \"product\",\n    qualityLevel: \"declared\",\n  },\n  quantity: { value: 2400, unit: \"m³\", volumeM3: 2400, tonnes: 4800 },\n  location: { name: \"Temple Quay, Bristol\", postcode: \"BS1 6DG\" },\n  window: { from: \"2026-09-14\", until: \"2026-11-20\" },\n  summary:\n    \"The podium slab programme needs 2,400 m³ of 6F2 capping between mid-September and late November. The engine considered 5 sources; 4 remain open.\",\n}\n\nexport const AGGREGATE_OPPORTUNITIES: Opportunity[] = [\n  {\n    id: \"opp-wenvoe-quarry\",\n    requirementId: \"req-aggregate-import\",\n    name: \"Primary supply — Wenvoe Quarry\",\n    strategy: \"supply_primary\",\n    isBaseline: true,\n    headline: \"The default route: quarried limestone 6F2, guaranteed spec, longest haul.\",\n    partner: { name: \"Cambrian Aggregates\", siteName: \"Wenvoe Quarry\" },\n    route: [\n      {\n        id: \"n1\",\n        kind: \"supplier\",\n        label: \"Wenvoe Quarry\",\n        detail: \"Quarried limestone 6F2\",\n        geo: { bearingDeg: 275, distanceKm: 32 },\n      },\n      { id: \"n2\", kind: \"site\", label: \"Temple Quay Phase 2\", legDistanceKm: 34 },\n    ],\n    totalDistanceKm: 34,\n    costs: [\n      {\n        id: \"c1\",\n        kind: \"material_purchase\",\n        label: \"Material — quarried 6F2\",\n        amount: 64800,\n        note: \"£13.50/t on 4,800 t\",\n      },\n      {\n        id: \"c2\",\n        kind: \"haulage\",\n        label: \"Haulage — 240 loads from Wenvoe\",\n        amount: 52800,\n        note: \"34 km at ~£11/t\",\n        estimated: true,\n      },\n    ],\n    timing: { fit: \"good\", note: \"Quarry can supply on two days' notice\" },\n    viability: {\n      verdict: \"viable\",\n      confidence: 0.92,\n      summary:\n        \"Guaranteed specification and availability at the highest cost, with the heaviest embodied carbon of the open sources.\",\n      strengths: [\"Certain specification and supply rate\", \"No dependency on another project's programme\"],\n      risks: [\"Highest cost and highest embodied carbon of the open pathways\"],\n      conditions: [{ id: \"v1\", label: \"Framework order placed\", status: \"action_needed\" }],\n      assessedAt: \"2026-07-04T06:12:00Z\",\n    },\n    compliance: [\n      { id: \"k1\", label: \"Declaration of performance\", status: \"ok\" },\n      { id: \"k2\", label: \"Delivery tickets per load\", status: \"ok\" },\n    ],\n    status: \"new\",\n  },\n  {\n    id: \"opp-avon-recycled\",\n    requirementId: \"req-aggregate-import\",\n    name: \"Recycled supply — Avon Aggregates\",\n    strategy: \"supply_recycled\",\n    headline: \"WRAP-protocol crushed concrete 6F2 from 11 km away at roughly half the cost.\",\n    partner: { name: \"Avon Aggregates Recycling\" },\n    route: [\n      {\n        id: \"n1\",\n        kind: \"recycling_facility\",\n        label: \"Avon Aggregates Recycling\",\n        geo: { bearingDeg: 355, distanceKm: 10 },\n        detail: \"Crushed concrete 6F2, WRAP quality protocol\",\n      },\n      { id: \"n2\", kind: \"site\", label: \"Temple Quay Phase 2\", legDistanceKm: 11 },\n    ],\n    totalDistanceKm: 11,\n    costs: [\n      {\n        id: \"c1\",\n        kind: \"material_purchase\",\n        label: \"Material — recycled 6F2\",\n        amount: 38400,\n        note: \"£8/t on 4,800 t\",\n      },\n      {\n        id: \"c2\",\n        kind: \"haulage\",\n        label: \"Haulage — 240 loads\",\n        amount: 21600,\n        note: \"11 km at ~£4.50/t\",\n        estimated: true,\n      },\n    ],\n    timing: { fit: \"good\", note: \"Stock on the ground covers the full requirement\" },\n    compatibility: {\n      score: 88,\n      breakdown: { material: 0.85, distance: 0.95, quantity: 1 },\n      relation: \"same_class\",\n      explainers: [\n        \"Recycled 6F2 meets the capping specification\",\n        \"Very close proximity (11 km)\",\n        \"Stocked volume fully covers the requirement\",\n      ],\n    },\n    viability: {\n      verdict: \"viable\",\n      confidence: 0.88,\n      summary:\n        \"A drop-in substitute under the WRAP quality protocol at 49% below the primary baseline, with a fraction of the embodied carbon.\",\n      strengths: [\n        \"Quality protocol means the material arrives as product, not waste\",\n        \"£57,600 below the primary baseline\",\n        \"Stock already crushed and graded\",\n      ],\n      risks: [\"Grading envelope certificate for the current stock batch still to be issued\"],\n      conditions: [\n        { id: \"v1\", label: \"WRAP quality protocol certificate\", status: \"met\" },\n        { id: \"v2\", label: \"Grading envelope confirmation\", status: \"pending\" },\n      ],\n      assessedAt: \"2026-07-04T06:12:00Z\",\n    },\n    compliance: [\n      { id: \"k1\", label: \"Quality protocol documentation\", status: \"ok\" },\n      { id: \"k2\", label: \"Delivery tickets per load\", status: \"ok\" },\n    ],\n    status: \"shortlisted\",\n  },\n  {\n    id: \"opp-redcliffe-surplus\",\n    requirementId: \"req-aggregate-import\",\n    name: \"Surplus — Redcliffe Wharf demolition\",\n    strategy: \"supply_surplus\",\n    headline: \"Crushed demolition arisings 7 km away, offered at a loading charge only.\",\n    partner: { name: \"Brunel Demolition\", siteName: \"Redcliffe Wharf\" },\n    route: [\n      {\n        id: \"n1\",\n        kind: \"site\",\n        label: \"Redcliffe Wharf demolition\",\n        geo: { bearingDeg: 265, distanceKm: 5 },\n        detail: \"Crushed demolition arisings, 6F2 equivalent\",\n      },\n      { id: \"n2\", kind: \"site\", label: \"Temple Quay Phase 2\", legDistanceKm: 7 },\n    ],\n    totalDistanceKm: 7,\n    costs: [\n      {\n        id: \"c1\",\n        kind: \"material_purchase\",\n        label: \"Loading charge\",\n        amount: 9600,\n        note: \"£2/t on 4,800 t\",\n      },\n      {\n        id: \"c2\",\n        kind: \"haulage\",\n        label: \"Haulage — 240 loads\",\n        amount: 14400,\n        note: \"7 km at ~£3/t\",\n        estimated: true,\n      },\n      {\n        id: \"c3\",\n        kind: \"testing\",\n        label: \"Grading and contamination testing\",\n        amount: 1850,\n      },\n    ],\n    timing: { fit: \"tight\", note: \"Their crushing runs to mid-October; your need opens mid-September\" },\n    compatibility: {\n      score: 84,\n      breakdown: { material: 0.7, distance: 0.96, quantity: 0.75 },\n      relation: \"cross_class_rule\",\n      explainers: [\n        \"Compatible by rule: crushed C&D arisings → 6F2 capping\",\n        \"Very close proximity (7 km)\",\n        \"Their surplus covers ~75% of the requirement\",\n      ],\n    },\n    viability: {\n      verdict: \"conditional\",\n      confidence: 0.71,\n      summary:\n        \"By far the cheapest source if grading results confirm the 6F2 envelope — but the supply rate tracks their demolition programme and covers three-quarters of the need.\",\n      strengths: [\n        \"£91,750 below the primary baseline for the covered volume\",\n        \"Shortest haul of any source\",\n        \"Diverts a neighbouring project's waste stream\",\n      ],\n      risks: [\n        \"Supply rate depends on their crushing programme\",\n        \"Remaining ~25% must come from another source\",\n        \"Grading results not yet returned\",\n      ],\n      conditions: [\n        { id: \"v1\", label: \"Grading test results\", status: \"pending\", detail: \"Samples taken 1 July\" },\n        { id: \"v2\", label: \"Supply rate agreement\", status: \"action_needed\" },\n      ],\n      assessedAt: \"2026-07-04T06:12:00Z\",\n    },\n    compliance: [\n      { id: \"k1\", label: \"Quality protocol or DoW:CoP position\", status: \"pending\" },\n      { id: \"k2\", label: \"Delivery tickets per load\", status: \"ok\" },\n    ],\n    status: \"shortlisted\",\n  },\n  {\n    id: \"opp-site-won\",\n    requirementId: \"req-aggregate-import\",\n    name: \"Site-won — crush demolition arisings\",\n    strategy: \"site_won\",\n    headline: \"A mobile crusher turns your own arisings into two-thirds of the capping, with zero haulage.\",\n    route: [\n      {\n        id: \"n1\",\n        kind: \"site\",\n        label: \"Temple Quay Phase 2\",\n        detail: \"Mobile crusher processes ~3,100 t of demolition arisings in situ\",\n      },\n    ],\n    totalDistanceKm: 0,\n    costs: [\n      { id: \"c1\", kind: \"processing\", label: \"Crusher mobilisation\", amount: 8500 },\n      {\n        id: \"c2\",\n        kind: \"processing\",\n        label: \"Crushing — 3,100 t site arisings\",\n        amount: 19220,\n        note: \"£6.20/t\",\n        estimated: true,\n      },\n      {\n        id: \"c3\",\n        kind: \"material_purchase\",\n        label: \"Top-up — recycled 6F2 (1,700 t)\",\n        amount: 13600,\n        note: \"£8/t from Avon Aggregates\",\n      },\n      {\n        id: \"c4\",\n        kind: \"haulage\",\n        label: \"Top-up haulage\",\n        amount: 7650,\n        estimated: true,\n      },\n    ],\n    timing: { fit: \"tight\", note: \"Crusher slot must land before the podium programme opens\" },\n    viability: {\n      verdict: \"conditional\",\n      confidence: 0.66,\n      summary:\n        \"The best carbon outcome and £68,600 below baseline, if the mobile plant permit and site space can be secured in time.\",\n      strengths: [\n        \"Zero haulage on two-thirds of the requirement\",\n        \"Lowest embodied carbon of any pathway\",\n        \"Arisings volume already surveyed\",\n      ],\n      risks: [\n        \"Mobile plant permit (noise) not yet applied for\",\n        \"Crushing pad competes with the phase 2 laydown area\",\n      ],\n      conditions: [\n        { id: \"v1\", label: \"Mobile plant permit\", status: \"action_needed\" },\n        { id: \"v2\", label: \"Arisings volume survey\", status: \"met\" },\n        { id: \"v3\", label: \"Top-up source confirmed\", status: \"met\" },\n      ],\n      assessedAt: \"2026-07-04T06:12:00Z\",\n    },\n    compliance: [\n      { id: \"k1\", label: \"Mobile plant permit\", status: \"gap\" },\n      { id: \"k2\", label: \"Crushing records and quality declaration\", status: \"pending\" },\n    ],\n    status: \"new\",\n  },\n  {\n    id: \"opp-severn-vale\",\n    requirementId: \"req-aggregate-import\",\n    name: \"Surplus — Severn Vale earthworks\",\n    strategy: \"supply_surplus\",\n    headline: \"A large surplus 68 km north — the haul erases the discount.\",\n    partner: { name: \"Vale Groundworks\", siteName: \"Severn Vale earthworks\" },\n    route: [\n      {\n        id: \"n1\",\n        kind: \"site\",\n        label: \"Severn Vale earthworks\",\n        detail: \"Surplus crushed fill\",\n        geo: { bearingDeg: 25, distanceKm: 60 },\n      },\n      { id: \"n2\", kind: \"site\", label: \"Temple Quay Phase 2\", legDistanceKm: 68 },\n    ],\n    totalDistanceKm: 68,\n    costs: [\n      { id: \"c1\", kind: \"material_purchase\", label: \"Loading charge\", amount: 9600, note: \"£2/t\" },\n      {\n        id: \"c2\",\n        kind: \"haulage\",\n        label: \"Haulage — 240 loads over 68 km\",\n        amount: 79200,\n        note: \"~£16.50/t\",\n        estimated: true,\n      },\n    ],\n    timing: { fit: \"good\", note: \"Material available now\" },\n    compatibility: {\n      score: 58,\n      breakdown: { material: 0.7, distance: 0.18, quantity: 1 },\n      relation: \"cross_class_rule\",\n      explainers: [\"Compatible by rule\", \"Far outside the economic haulage radius (68 km)\"],\n    },\n    viability: {\n      verdict: \"ruled_out\",\n      confidence: 0.82,\n      summary:\n        \"Haulage over 68 km erases the loading-charge discount: modelled £28,800 above the recycled supply option, with added programme risk.\",\n      strengths: [],\n      risks: [],\n      conditions: [],\n      ruledOutReason: \"Haulage cost erases the saving (£28,800 over the recycled source)\",\n      assessedAt: \"2026-07-04T06:12:00Z\",\n    },\n    compliance: [],\n    status: \"dismissed\",\n  },\n]\n\n// ---------------------------------------------------------------------------\n// Scenario bundles\n// ---------------------------------------------------------------------------\n\nexport interface OpportunityScenario {\n  requirement: MaterialRequirement\n  opportunities: Opportunity[]\n}\n\nexport const OPPORTUNITY_SCENARIOS: OpportunityScenario[] = [\n  { requirement: TOPSOIL_REQUIREMENT, opportunities: TOPSOIL_OPPORTUNITIES },\n  { requirement: AGGREGATE_REQUIREMENT, opportunities: AGGREGATE_OPPORTUNITIES },\n]\n",
      "type": "registry:component",
      "target": "components/blocks/domain/opportunities/data.ts"
    },
    {
      "path": "components/blocks/domain/opportunities/index.ts",
      "content": "export * from \"./types\"\nexport * from \"./data\"\nexport {\n  CompatibilityRing,\n  OpportunityStatusBadge,\n  RouteNodeIcon,\n  RouteSummary,\n  SavingsDelta,\n  StrategyBadge,\n  TimingBadge,\n  ViabilityBadge,\n} from \"./opportunity-primitives\"\nexport { OpportunityCard } from \"./opportunity-card\"\nexport { RoutePathway } from \"./route-pathway\"\nexport { CostBreakdown } from \"./cost-breakdown\"\nexport { ViabilityPanel } from \"./viability-panel\"\nexport { OpportunityComparison } from \"./opportunity-comparison\"\nexport { OptionsMatrix } from \"./options-matrix\"\nexport { OpportunityDetail } from \"./opportunity-detail\"\nexport { OpportunityMap } from \"./opportunity-map\"\nexport { OpportunityFlow } from \"./opportunity-flow\"\nexport { OpportunityDecision, opportunityReadiness } from \"./opportunity-decision\"\nexport { VerdictLegend } from \"./viz-utils\"\n",
      "type": "registry:component",
      "target": "components/blocks/domain/opportunities/index.ts"
    },
    {
      "path": "components/blocks/domain/opportunities/opportunity-card.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport {\n  Card,\n  CardAction,\n  CardContent,\n  CardDescription,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\"\nimport { cn } from \"@/lib/utils\"\nimport {\n  CompatibilityRing,\n  OpportunityStatusBadge,\n  RouteSummary,\n  SavingsDelta,\n  StrategyBadge,\n  TimingBadge,\n  ViabilityBadge,\n} from \"./opportunity-primitives\"\nimport {\n  formatDistanceKm,\n  formatMoney,\n  formatPerTonne,\n  opportunityIsEstimated,\n  opportunityTotalCost,\n  type MaterialRequirement,\n  type Opportunity,\n} from \"./types\"\n\n/**\n * OpportunityCard — the comparable unit of the opportunity matrix. One card\n * per considered pathway: strategy and status up top, the route strip in the\n * middle, and the money story (net estimated cost, per-tonne rate and the\n * saving against the baseline route) along the foot. Ruled-out pathways stay\n * visible but muted, keeping the \"everything was considered\" promise; the\n * baseline route reads as the dashed anchor the others are measured against.\n */\n\nexport function OpportunityCard({\n  requirement,\n  opportunity,\n  baseline,\n  selected = false,\n  onSelect,\n  className,\n}: {\n  requirement: MaterialRequirement\n  opportunity: Opportunity\n  /** The pathway savings are measured against (usually `isBaseline`). */\n  baseline?: Opportunity\n  selected?: boolean\n  onSelect?: (id: string) => void\n  className?: string\n}) {\n  const total = opportunityTotalCost(opportunity)\n  const estimated = opportunityIsEstimated(opportunity)\n  const ruledOut = opportunity.viability.verdict === \"ruled_out\"\n  const interactive = onSelect != null\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    if (event.key === \"Enter\" || event.key === \" \") {\n      event.preventDefault()\n      onSelect?.(opportunity.id)\n    }\n  }\n\n  return (\n    <Card\n      size=\"sm\"\n      data-selected={selected || undefined}\n      data-ruled-out={ruledOut || undefined}\n      role={interactive ? \"button\" : undefined}\n      tabIndex={interactive ? 0 : undefined}\n      aria-pressed={interactive ? selected : undefined}\n      onClick={interactive ? () => onSelect(opportunity.id) : undefined}\n      onKeyDown={interactive ? handleKeyDown : undefined}\n      className={cn(\n        \"gap-3\",\n        interactive &&\n          \"cursor-pointer transition-shadow outline-none hover:shadow-md focus-visible:ring-4 focus-visible:ring-ring/25\",\n        selected && \"ring-2 ring-ring\",\n        opportunity.isBaseline && !selected && \"border border-dashed border-border ring-0\",\n        className\n      )}\n    >\n      <CardHeader className={cn(ruledOut && \"opacity-60\")}>\n        <div className=\"flex min-w-0 flex-col gap-1.5\">\n          <div className=\"flex flex-wrap items-center gap-1.5\">\n            <StrategyBadge strategy={opportunity.strategy} />\n            <OpportunityStatusBadge status={opportunity.status} />\n          </div>\n          <CardTitle className=\"text-base leading-snug\">{opportunity.name}</CardTitle>\n          <CardDescription>{opportunity.headline}</CardDescription>\n          {opportunity.partner ? (\n            <p className=\"text-xs text-text-tertiary\">\n              with {opportunity.partner.name}\n              {opportunity.partner.siteName ? ` · ${opportunity.partner.siteName}` : null}\n            </p>\n          ) : null}\n        </div>\n        <CardAction>\n          <ViabilityBadge viability={opportunity.viability} />\n        </CardAction>\n      </CardHeader>\n\n      <CardContent className={cn(\"flex flex-col gap-2\", !ruledOut && \"grow\")}>\n        <RouteSummary route={opportunity.route} className={cn(ruledOut && \"opacity-60\")} />\n      </CardContent>\n\n      {ruledOut && opportunity.viability.ruledOutReason ? (\n        <CardContent>\n          <p className=\"text-xs font-medium text-error-primary\">\n            Ruled out: {opportunity.viability.ruledOutReason}\n          </p>\n        </CardContent>\n      ) : null}\n\n      <CardFooter\n        className={cn(\"mt-auto items-end gap-3 border-t pt-3\", ruledOut && \"opacity-60\")}\n      >\n        <div className=\"flex min-w-0 flex-col gap-0.5\">\n          <span\n            className=\"text-xl font-semibold text-foreground tabular-nums\"\n            title={estimated ? \"Includes modelled estimates\" : \"Quoted costs\"}\n          >\n            {estimated ? \"~\" : \"\"}\n            {formatMoney(total)}\n          </span>\n          <span className=\"text-xs text-text-tertiary tabular-nums\">\n            {formatPerTonne(total, requirement)}\n            {opportunity.totalDistanceKm > 0\n              ? ` · ${formatDistanceKm(opportunity.totalDistanceKm)}`\n              : \" · on site\"}\n          </span>\n          <SavingsDelta opportunity={opportunity} baseline={baseline} />\n        </div>\n        <div className=\"ml-auto flex shrink-0 items-center gap-2.5\">\n          <TimingBadge timing={opportunity.timing} />\n          {opportunity.compatibility ? (\n            <CompatibilityRing compatibility={opportunity.compatibility} />\n          ) : null}\n        </div>\n      </CardFooter>\n    </Card>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/blocks/domain/opportunities/opportunity-card.tsx"
    },
    {
      "path": "components/blocks/domain/opportunities/opportunity-comparison.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Check } from \"@untitledui/icons\"\n\nimport { cn } from \"@/lib/utils\"\nimport {\n  CompatibilityRing,\n  SavingsDelta,\n  StrategyBadge,\n  TimingBadge,\n  ViabilityBadge,\n} from \"./opportunity-primitives\"\nimport {\n  baselineOf,\n  formatDistanceKm,\n  formatMoney,\n  formatPerTonne,\n  opportunityIsEstimated,\n  opportunityTotalCost,\n  rankOpportunities,\n  type MaterialRequirement,\n  type Opportunity,\n} from \"./types\"\n\n/**\n * OpportunityComparison — the side-by-side view: pathways as columns,\n * decision dimensions as rows. Only open pathways are shown (ruled-out\n * options live in the options matrix); the baseline route is pinned as the\n * first, tinted column so every figure reads against the industry default.\n * Winning cells for cost, saving and distance carry a check so the best\n * option surfaces at a glance. Scrolls horizontally with a sticky label\n * column rather than squashing.\n */\n\ninterface ComparisonRow {\n  key: string\n  label: string\n  render: (opportunity: Opportunity) => React.ReactNode\n}\n\nexport function OpportunityComparison({\n  requirement,\n  opportunities,\n  selectedIds,\n  onSelect,\n  className,\n}: {\n  requirement: MaterialRequirement\n  opportunities: Opportunity[]\n  /** Column headers render as pressed when included here. */\n  selectedIds?: string[]\n  /** Makes column headers selectable (e.g. for shortlisting). */\n  onSelect?: (id: string) => void\n  className?: string\n}) {\n  const open = opportunities.filter(\n    (opportunity) => opportunity.viability.verdict !== \"ruled_out\"\n  )\n  const baseline = baselineOf(open)\n  const rest = rankOpportunities(open.filter((opportunity) => !opportunity.isBaseline))\n  const shown = baseline ? [baseline, ...rest] : rest\n\n  const totals = new Map(shown.map((o) => [o.id, opportunityTotalCost(o)]))\n  const bestCost = Math.min(...totals.values())\n  const bestDistance = Math.min(...shown.map((o) => o.totalDistanceKm))\n\n  const winner = {\n    cost: (o: Opportunity) => totals.get(o.id) === bestCost,\n    distance: (o: Opportunity) => o.totalDistanceKm === bestDistance,\n  }\n\n  const winnerMark = (\n    <Check className=\"size-3.5 text-fg-success-primary\" aria-label=\"Best in row\" />\n  )\n\n  const rows: ComparisonRow[] = [\n    {\n      key: \"cost\",\n      label: \"Estimated net cost\",\n      render: (o) => (\n        <span\n          className={cn(\n            \"flex items-center gap-1.5 text-lg font-semibold text-foreground tabular-nums\",\n            winner.cost(o) && \"text-success-primary\"\n          )}\n        >\n          {opportunityIsEstimated(o) ? \"~\" : \"\"}\n          {formatMoney(totals.get(o.id) ?? 0)}\n          {winner.cost(o) ? winnerMark : null}\n        </span>\n      ),\n    },\n    {\n      key: \"saving\",\n      label: \"Saving vs baseline\",\n      render: (o) => (\n        <span className=\"flex items-center gap-1.5\">\n          <SavingsDelta opportunity={o} baseline={baseline} className=\"text-sm\" />\n          {!o.isBaseline && winner.cost(o) ? winnerMark : null}\n        </span>\n      ),\n    },\n    {\n      key: \"per-tonne\",\n      label: \"Cost per tonne\",\n      render: (o) => (\n        <span className=\"text-sm text-text-secondary tabular-nums\">\n          {formatPerTonne(totals.get(o.id) ?? 0, requirement)}\n        </span>\n      ),\n    },\n    {\n      key: \"distance\",\n      label: \"Total distance\",\n      render: (o) => (\n        <span\n          className={cn(\n            \"flex items-center gap-1.5 text-sm text-text-secondary tabular-nums\",\n            winner.distance(o) && \"font-semibold text-success-primary\"\n          )}\n        >\n          {formatDistanceKm(o.totalDistanceKm)}\n          {winner.distance(o) ? winnerMark : null}\n        </span>\n      ),\n    },\n    {\n      key: \"timing\",\n      label: \"Timing\",\n      render: (o) => (\n        <span className=\"flex flex-col items-start gap-1\">\n          <TimingBadge timing={o.timing} />\n          <span className=\"text-xs whitespace-normal text-text-tertiary\">{o.timing.note}</span>\n        </span>\n      ),\n    },\n    {\n      key: \"compatibility\",\n      label: \"Compatibility\",\n      render: (o) =>\n        o.compatibility ? (\n          <CompatibilityRing compatibility={o.compatibility} />\n        ) : (\n          <span className=\"text-sm text-text-quaternary\">—</span>\n        ),\n    },\n    {\n      key: \"conditions\",\n      label: \"Conditions\",\n      render: (o) => {\n        const outstanding = o.viability.conditions.filter(\n          (condition) => condition.status !== \"met\"\n        ).length\n        return outstanding > 0 ? (\n          <span className=\"text-sm font-medium text-warning-primary\">\n            {outstanding} outstanding\n          </span>\n        ) : (\n          <span className=\"text-sm text-text-tertiary\">None</span>\n        )\n      },\n    },\n    {\n      key: \"compliance\",\n      label: \"Compliance\",\n      render: (o) => {\n        const gaps = o.compliance.filter((item) => item.status === \"gap\").length\n        const pending = o.compliance.filter((item) => item.status === \"pending\").length\n        if (gaps > 0)\n          return (\n            <span className=\"text-sm font-medium text-error-primary\">\n              {gaps} {gaps === 1 ? \"gap\" : \"gaps\"}\n            </span>\n          )\n        if (pending > 0)\n          return (\n            <span className=\"text-sm font-medium text-warning-primary\">\n              {pending} pending\n            </span>\n          )\n        return <span className=\"text-sm text-success-primary\">In order</span>\n      },\n    },\n  ]\n\n  const columnClass = (o: Opportunity) =>\n    cn(\"px-4 py-3\", o.isBaseline && \"bg-secondary/50\")\n\n  return (\n    <div\n      data-slot=\"opportunity-comparison\"\n      className={cn(\"overflow-x-auto rounded-xl border border-border bg-card\", className)}\n    >\n      <div\n        className=\"grid w-max min-w-full\"\n        style={{\n          gridTemplateColumns: `minmax(150px, 190px) repeat(${shown.length}, minmax(230px, 1fr))`,\n        }}\n      >\n        {/* Header row */}\n        <div className=\"sticky left-0 z-10 border-b border-border bg-card px-4 py-3\" />\n        {shown.map((o) => {\n          const selected = selectedIds?.includes(o.id) ?? false\n          const header = (\n            <div className=\"flex flex-col items-start gap-1.5\">\n              <span className=\"flex flex-wrap items-center gap-1.5\">\n                <span className=\"text-sm font-semibold whitespace-normal text-foreground\">\n                  {o.name}\n                </span>\n                {o.isBaseline ? (\n                  <span className=\"rounded-full border border-border px-1.5 py-px text-[10px] font-medium text-text-quaternary uppercase\">\n                    Baseline\n                  </span>\n                ) : null}\n              </span>\n              <span className=\"flex flex-wrap items-center gap-1\">\n                <StrategyBadge strategy={o.strategy} />\n                <ViabilityBadge viability={o.viability} />\n              </span>\n              {o.partner ? (\n                <span className=\"text-xs text-text-tertiary\">\n                  {o.partner.name}\n                  {o.partner.siteName ? ` · ${o.partner.siteName}` : \"\"}\n                </span>\n              ) : null}\n            </div>\n          )\n          return onSelect ? (\n            <button\n              key={o.id}\n              type=\"button\"\n              onClick={() => onSelect(o.id)}\n              aria-pressed={selected}\n              className={cn(\n                \"cursor-pointer border-b border-border text-left transition-colors hover:bg-muted focus-visible:ring-4 focus-visible:ring-ring/25 focus-visible:outline-none\",\n                columnClass(o),\n                selected && \"bg-brand-primary ring-1 ring-border-brand ring-inset\"\n              )}\n            >\n              {header}\n            </button>\n          ) : (\n            <div key={o.id} className={cn(\"border-b border-border\", columnClass(o))}>\n              {header}\n            </div>\n          )\n        })}\n\n        {/* Dimension rows */}\n        {rows.map((row, rowIndex) => (\n          <React.Fragment key={row.key}>\n            <div\n              className={cn(\n                \"sticky left-0 z-10 bg-card px-4 py-3 text-xs font-semibold text-muted-foreground\",\n                rowIndex < rows.length - 1 && \"border-b border-border\"\n              )}\n            >\n              {row.label}\n            </div>\n            {shown.map((o) => (\n              <div\n                key={o.id}\n                className={cn(\n                  columnClass(o),\n                  rowIndex < rows.length - 1 && \"border-b border-border\"\n                )}\n              >\n                {row.render(o)}\n              </div>\n            ))}\n          </React.Fragment>\n        ))}\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/blocks/domain/opportunities/opportunity-comparison.tsx"
    },
    {
      "path": "components/blocks/domain/opportunities/opportunity-decision.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport {\n  baselineOf,\n  formatMoney,\n  opportunityTotalCost,\n  rankOpportunities,\n  type MaterialRequirement,\n  type Opportunity,\n} from \"./types\"\nimport {\n  opportunityShortName,\n  useVizTip,\n  VERDICT_MARK_COLORS,\n  VerdictLegend,\n  VizTooltip,\n} from \"./viz-utils\"\n\n/**\n * OpportunityDecision — the decision grid. One dot per pathway, placed by\n * what actually drives the choice: estimated net cost across, and the share\n * of its gates already cleared (viability conditions + compliance items) up.\n * The strong options collect top-left — ready and affordable; colour is the\n * AI verdict and the baseline route is pinned as a reference line.\n */\n\nconst W = 900\nconst H = 460\nconst ML = 70\nconst MR = 180\nconst MT = 30\nconst MB = 52\nconst PLOT_W = W - ML - MR\nconst PLOT_H = H - MT - MB\n\n/** Share of gates (conditions + compliance) already cleared, 0–1. */\nexport function opportunityReadiness(opportunity: Opportunity): number {\n  const gates = [\n    ...opportunity.viability.conditions.map((item) => item.status === \"met\"),\n    ...opportunity.compliance.map((item) => item.status === \"ok\"),\n  ]\n  if (gates.length === 0) return 1\n  return gates.filter(Boolean).length / gates.length\n}\n\nfunction niceCostCeil(max: number): number {\n  const step = max > 120_000 ? 50_000 : 25_000\n  return Math.ceil((max * 1.08) / step) * step\n}\n\nfunction formatCostTick(value: number): string {\n  return value === 0 ? \"£0\" : `£${Math.round(value / 1000)}k`\n}\n\nexport function OpportunityDecision({\n  requirement,\n  opportunities,\n  onOpenDetail,\n  className,\n}: {\n  requirement: MaterialRequirement\n  opportunities: Opportunity[]\n  onOpenDetail?: (id: string) => void\n  className?: string\n}) {\n  const { tip, move, pin, clear, containerRef } = useVizTip()\n  const [hoveredId, setHoveredId] = React.useState<string | null>(null)\n\n  const baseline = baselineOf(opportunities)\n  const ranked = rankOpportunities(opportunities)\n\n  const xMax = niceCostCeil(\n    Math.max(...opportunities.map((o) => Math.abs(opportunityTotalCost(o))), 1)\n  )\n  const xTickStep = xMax > 120_000 ? 50_000 : 25_000\n  const xTicks = Array.from(\n    { length: Math.floor(xMax / xTickStep) + 1 },\n    (_, index) => index * xTickStep\n  )\n  const yTicks = [0, 0.25, 0.5, 0.75, 1]\n\n  const px = (cost: number) => ML + (cost / xMax) * PLOT_W\n  // 10px vertical inset keeps 0%/100% dots off the plot edges\n  const py = (ready: number) => MT + 10 + (1 - ready) * (PLOT_H - 20)\n\n  const points = ranked.map((opportunity) => ({\n    opportunity,\n    x: px(opportunityTotalCost(opportunity)),\n    y: py(opportunityReadiness(opportunity)),\n  }))\n\n  // Direct labels: anchor away from the plot edge, nudge collisions apart.\n  const labels = new Map<string, { x: number; y: number; anchor: \"start\" | \"end\" }>()\n  for (const side of [\"start\", \"end\"] as const) {\n    const group = points\n      .filter((point) =>\n        side === \"start\" ? point.x <= ML + PLOT_W * 0.62 : point.x > ML + PLOT_W * 0.62\n      )\n      .sort((a, b) => a.y - b.y)\n    let lastY = -Infinity\n    for (const point of group) {\n      const y = Math.max(point.y + 4, lastY + 14)\n      lastY = y\n      labels.set(point.opportunity.id, {\n        x: side === \"start\" ? point.x + 13 : point.x - 13,\n        y,\n        anchor: side,\n      })\n    }\n  }\n\n  const baselineX = baseline ? px(opportunityTotalCost(baseline)) : null\n\n  return (\n    <div\n      ref={containerRef}\n      data-slot=\"opportunity-decision\"\n      className={cn(\n        \"relative flex flex-col gap-3 rounded-xl border border-border bg-card p-4\",\n        className\n      )}\n    >\n      <VerdictLegend note=\"across = estimated net cost · up = gates cleared\" />\n      <svg\n        viewBox={`0 0 ${W} ${H}`}\n        className=\"h-auto w-full\"\n        role=\"img\"\n        aria-label={`Decision grid of ${opportunities.length} pathways for ${requirement.title}, by cost and readiness. Use the matrix view for the full table.`}\n      >\n        {/* Grid */}\n        {yTicks.map((tick) => (\n          <g key={tick}>\n            <line\n              x1={ML}\n              x2={ML + PLOT_W}\n              y1={py(tick)}\n              y2={py(tick)}\n              stroke=\"var(--color-border)\"\n              strokeWidth=\"1\"\n            />\n            <text\n              x={ML - 10}\n              y={py(tick) + 3.5}\n              textAnchor=\"end\"\n              fontSize=\"10.5\"\n              className=\"fill-text-quaternary tabular-nums\"\n            >\n              {Math.round(tick * 100)}%\n            </text>\n          </g>\n        ))}\n        {xTicks.map((tick) => (\n          <g key={tick}>\n            <line\n              x1={px(tick)}\n              x2={px(tick)}\n              y1={MT}\n              y2={MT + PLOT_H}\n              stroke=\"var(--color-border)\"\n              strokeWidth=\"1\"\n            />\n            <text\n              x={px(tick)}\n              y={MT + PLOT_H + 18}\n              textAnchor=\"middle\"\n              fontSize=\"10.5\"\n              className=\"fill-text-quaternary tabular-nums\"\n            >\n              {formatCostTick(tick)}\n            </text>\n          </g>\n        ))}\n\n        {/* Baseline reference */}\n        {baselineX != null && baseline ? (\n          <g>\n            <line\n              x1={baselineX}\n              x2={baselineX}\n              y1={MT - 4}\n              y2={MT + PLOT_H}\n              stroke=\"var(--color-fg-quaternary)\"\n              strokeWidth=\"1.25\"\n            />\n            <text\n              x={baselineX}\n              y={MT - 10}\n              textAnchor=\"middle\"\n              fontSize=\"10\"\n              className=\"fill-text-quaternary\"\n            >\n              baseline · {formatMoney(opportunityTotalCost(baseline))}\n            </text>\n          </g>\n        ) : null}\n\n        {/* Corner reading aids */}\n        <text\n          x={ML + 10}\n          y={MT + 16}\n          fontSize=\"10\"\n          fontWeight={500}\n          fill=\"var(--color-text-success-primary)\"\n          opacity=\"0.85\"\n        >\n          ready &amp; affordable\n        </text>\n        <text\n          x={ML + PLOT_W - 10}\n          y={MT + PLOT_H - 10}\n          textAnchor=\"end\"\n          fontSize=\"10\"\n          className=\"fill-text-quaternary\"\n        >\n          costly · gated\n        </text>\n\n        {/* Axis titles */}\n        <text\n          x={ML + PLOT_W / 2}\n          y={H - 12}\n          textAnchor=\"middle\"\n          fontSize=\"11\"\n          className=\"fill-text-tertiary\"\n        >\n          estimated net cost →\n        </text>\n        <text\n          x={18}\n          y={MT + PLOT_H / 2}\n          textAnchor=\"middle\"\n          fontSize=\"11\"\n          className=\"fill-text-tertiary\"\n          transform={`rotate(-90 18 ${MT + PLOT_H / 2})`}\n        >\n          gates cleared ↑\n        </text>\n\n        {/* Dots + direct labels */}\n        {points.map(({ opportunity, x, y }) => {\n          const ruledOut = opportunity.viability.verdict === \"ruled_out\"\n          const color = VERDICT_MARK_COLORS[opportunity.viability.verdict]\n          const active = hoveredId === opportunity.id\n          const dimmed = hoveredId != null && !active\n          const label = labels.get(opportunity.id)!\n          return (\n            <g\n              key={opportunity.id}\n              className=\"cursor-pointer outline-none transition-opacity duration-150\"\n              opacity={dimmed ? 0.35 : 1}\n              onMouseMove={move(opportunity)}\n              onMouseEnter={() => setHoveredId(opportunity.id)}\n              onMouseLeave={() => {\n                setHoveredId(null)\n                clear()\n              }}\n              onClick={() => onOpenDetail?.(opportunity.id)}\n              onFocus={pin(opportunity, x, y)}\n              onBlur={clear}\n              onKeyDown={(event) => {\n                if (event.key === \"Enter\" || event.key === \" \") {\n                  event.preventDefault()\n                  onOpenDetail?.(opportunity.id)\n                }\n              }}\n              tabIndex={0}\n              role=\"button\"\n              aria-label={`${opportunity.name}, ${formatMoney(opportunityTotalCost(opportunity))}, ${Math.round(opportunityReadiness(opportunity) * 100)}% of gates cleared`}\n            >\n              <circle cx={x} cy={y} r={16} fill=\"transparent\" />\n              {ruledOut ? (\n                <circle\n                  cx={x}\n                  cy={y}\n                  r={active ? 8 : 7}\n                  fill=\"var(--color-bg-primary)\"\n                  stroke={color}\n                  strokeWidth=\"1.75\"\n                  strokeDasharray=\"3 2.5\"\n                />\n              ) : (\n                <circle\n                  cx={x}\n                  cy={y}\n                  r={active ? 8 : 7}\n                  fill={color}\n                  stroke=\"var(--color-bg-primary)\"\n                  strokeWidth=\"2\"\n                />\n              )}\n              <text\n                x={label.x}\n                y={label.y}\n                textAnchor={label.anchor}\n                fontSize=\"11\"\n                fontWeight={500}\n                className={ruledOut ? \"fill-text-quaternary\" : \"fill-text-secondary\"}\n              >\n                {opportunityShortName(opportunity)}\n                {opportunity.isBaseline ? \" (baseline)\" : \"\"}\n              </text>\n            </g>\n          )\n        })}\n      </svg>\n      <VizTooltip tip={tip} baseline={baseline} />\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/blocks/domain/opportunities/opportunity-decision.tsx"
    },
    {
      "path": "components/blocks/domain/opportunities/opportunity-detail.tsx",
      "content": "import type * as React from \"react\"\nimport { AlertCircle, CheckCircle, Clock } from \"@untitledui/icons\"\n\nimport { Badge } from \"@/components/ui/badge\"\nimport { cn } from \"@/lib/utils\"\nimport { CostBreakdown } from \"./cost-breakdown\"\nimport {\n  CompatibilityRing,\n  OpportunityStatusBadge,\n  SavingsDelta,\n  StrategyBadge,\n  TimingBadge,\n  ViabilityBadge,\n} from \"./opportunity-primitives\"\nimport { RoutePathway } from \"./route-pathway\"\nimport {\n  formatDistanceKm,\n  formatMoney,\n  MATERIAL_RELATION_LABELS,\n  opportunityIsEstimated,\n  opportunityTotalCost,\n  SCORE_TIER_LABELS,\n  scoreTier,\n  type ComplianceItem,\n  type ComplianceStatus,\n  type MaterialRequirement,\n  type Opportunity,\n} from \"./types\"\nimport { ViabilityPanel } from \"./viability-panel\"\n\n/**\n * OpportunityDetail — the full record for one pathway, composed from the\n * family's surfaces: headline figures, the route drawn step by step, the\n * itemised cost estimate against the baseline, the AI viability assessment\n * and the derived compliance position. Designed to sit inside a sheet or\n * drawer (vertical rhythm, no fixed width) but works as a standalone panel.\n */\n\nconst COMPLIANCE_META: Record<\n  ComplianceStatus,\n  {\n    icon: React.ComponentType<{ className?: string }>\n    iconClass: string\n    label: string\n    variant: React.ComponentProps<typeof Badge>[\"variant\"]\n  }\n> = {\n  ok: {\n    icon: CheckCircle,\n    iconClass: \"text-fg-success-primary\",\n    label: \"In order\",\n    variant: \"success\",\n  },\n  gap: {\n    icon: AlertCircle,\n    iconClass: \"text-fg-error-primary\",\n    label: \"Gap\",\n    variant: \"error\",\n  },\n  pending: {\n    icon: Clock,\n    iconClass: \"text-fg-quaternary\",\n    label: \"Pending\",\n    variant: \"gray\",\n  },\n}\n\nfunction SectionHeading({ children }: { children: React.ReactNode }) {\n  return (\n    <h4 className=\"text-xs font-semibold text-text-quaternary uppercase tracking-wide\">\n      {children}\n    </h4>\n  )\n}\n\nfunction ComplianceList({ items }: { items: ComplianceItem[] }) {\n  return (\n    <ul className=\"flex flex-col gap-2.5\">\n      {items.map((item) => {\n        const meta = COMPLIANCE_META[item.status]\n        const Icon = meta.icon\n        return (\n          <li key={item.id} className=\"flex items-start gap-2.5\">\n            <Icon\n              className={cn(\"mt-0.5 size-4 shrink-0\", meta.iconClass)}\n              aria-hidden=\"true\"\n            />\n            <div className=\"flex min-w-0 grow flex-col gap-0.5\">\n              <span className=\"text-sm font-medium text-text-secondary\">{item.label}</span>\n              {item.detail ? (\n                <span className=\"text-xs text-text-tertiary\">{item.detail}</span>\n              ) : null}\n            </div>\n            <Badge variant={meta.variant} className=\"shrink-0\">\n              {meta.label}\n            </Badge>\n          </li>\n        )\n      })}\n    </ul>\n  )\n}\n\nexport function OpportunityDetail({\n  requirement,\n  opportunity,\n  baseline,\n  className,\n}: {\n  requirement: MaterialRequirement\n  opportunity: Opportunity\n  /** Enables the delta rows (usually the scenario's `isBaseline` pathway). */\n  baseline?: Opportunity\n  className?: string\n}) {\n  const total = opportunityTotalCost(opportunity)\n  const estimated = opportunityIsEstimated(opportunity)\n\n  return (\n    <div\n      data-slot=\"opportunity-detail\"\n      className={cn(\"flex flex-col gap-6\", className)}\n    >\n      {/* Headline */}\n      <div className=\"flex flex-col gap-3\">\n        <div className=\"flex flex-wrap items-center gap-1.5\">\n          <StrategyBadge strategy={opportunity.strategy} />\n          <OpportunityStatusBadge status={opportunity.status} />\n          <ViabilityBadge viability={opportunity.viability} className=\"ml-auto\" />\n        </div>\n        <div>\n          <h3 className=\"text-lg font-semibold text-foreground\">{opportunity.name}</h3>\n          <p className=\"text-sm text-text-tertiary\">{opportunity.headline}</p>\n          {opportunity.partner ? (\n            <p className=\"mt-1 text-xs text-text-quaternary\">\n              with {opportunity.partner.name}\n              {opportunity.partner.siteName ? ` · ${opportunity.partner.siteName}` : null}\n            </p>\n          ) : null}\n        </div>\n        <div className=\"flex flex-wrap items-end justify-between gap-3 rounded-xl bg-bg-secondary px-4 py-3\">\n          <div className=\"flex flex-col gap-0.5\">\n            <span\n              className=\"text-display-xs font-semibold text-foreground tabular-nums\"\n              title={estimated ? \"Includes modelled estimates\" : \"Quoted costs\"}\n            >\n              {estimated ? \"~\" : \"\"}\n              {formatMoney(total)}\n            </span>\n            <SavingsDelta opportunity={opportunity} baseline={baseline} />\n          </div>\n          <div className=\"flex items-center gap-2.5\">\n            <TimingBadge timing={opportunity.timing} />\n            {opportunity.compatibility ? (\n              <CompatibilityRing compatibility={opportunity.compatibility} />\n            ) : null}\n          </div>\n        </div>\n      </div>\n\n      {/* Route */}\n      <div className=\"flex flex-col gap-3\">\n        <div className=\"flex items-baseline justify-between gap-3\">\n          <SectionHeading>Route</SectionHeading>\n          {opportunity.totalDistanceKm > 0 ? (\n            <span className=\"text-xs text-text-tertiary tabular-nums\">\n              {formatDistanceKm(opportunity.totalDistanceKm)} total\n            </span>\n          ) : null}\n        </div>\n        <RoutePathway route={opportunity.route} orientation=\"vertical\" />\n      </div>\n\n      {/* Costs */}\n      <div className=\"flex flex-col gap-3\">\n        <SectionHeading>Estimated costs</SectionHeading>\n        <CostBreakdown\n          opportunity={opportunity}\n          baseline={baseline}\n          requirement={requirement}\n        />\n      </div>\n\n      {/* Compatibility explainers */}\n      {opportunity.compatibility ? (\n        <div className=\"flex flex-col gap-3\">\n          <SectionHeading>Compatibility</SectionHeading>\n          <p className=\"text-sm text-text-secondary\">\n            Scored {opportunity.compatibility.score}/100 —{\" \"}\n            {SCORE_TIER_LABELS[scoreTier(opportunity.compatibility.score)]} (\n            {MATERIAL_RELATION_LABELS[opportunity.compatibility.relation]}).\n          </p>\n          <ul className=\"flex list-disc flex-col gap-1 pl-5 text-sm text-text-tertiary\">\n            {opportunity.compatibility.explainers.map((explainer) => (\n              <li key={explainer}>{explainer}</li>\n            ))}\n          </ul>\n        </div>\n      ) : null}\n\n      {/* Viability */}\n      <ViabilityPanel viability={opportunity.viability} />\n\n      {/* Compliance */}\n      {opportunity.compliance.length > 0 ? (\n        <div className=\"flex flex-col gap-3\">\n          <SectionHeading>Compliance position</SectionHeading>\n          <ComplianceList items={opportunity.compliance} />\n        </div>\n      ) : null}\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/blocks/domain/opportunities/opportunity-detail.tsx"
    },
    {
      "path": "components/blocks/domain/opportunities/opportunity-flow.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport {\n  baselineOf,\n  formatMoney,\n  formatMoneyDelta,\n  opportunityIsEstimated,\n  opportunityTotalCost,\n  rankOpportunities,\n  savingsVsBaseline,\n  STRATEGY_LABELS,\n  type MaterialRequirement,\n  type Opportunity,\n} from \"./types\"\nimport {\n  opportunityShortName,\n  RULED_OUT_DASH,\n  useVizTip,\n  VERDICT_MARK_COLORS,\n  verdictWash,\n  VerdictLegend,\n  VizTooltip,\n} from \"./viz-utils\"\n\n/**\n * OpportunityFlow — the cost canvas. Every considered pathway fans out from\n * the requirement as a ribbon whose width is its estimated net cost, so the\n * money story is legible before a single number is read: the fat ribbon is\n * the expensive route, the thin one the bargain. Facilities a route passes\n * through sit as waypoints on the ribbon; colour is the AI verdict and\n * ruled-out pathways stay visible, dashed and washed out.\n */\n\nconst W = 920\nconst ROW_H = 76\nconst PAD_Y = 24\nconst X1 = 192\nconst X2 = 604\nconst TEXT_X = 622\nconst RIGHT_X = W - 16\nconst MIN_W = 7\nconst MAX_W = 44\n\nfunction truncate(text: string, max: number): string {\n  return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text\n}\n\n/** Sankey-style link of width `w` between two horizontal anchors. */\nfunction ribbonPath(y1: number, y2: number, w: number): string {\n  const k = (X2 - X1) / 2\n  const top1 = y1 - w / 2\n  const top2 = y2 - w / 2\n  const bottom1 = y1 + w / 2\n  const bottom2 = y2 + w / 2\n  return [\n    `M ${X1} ${top1}`,\n    `C ${X1 + k} ${top1} ${X2 - k} ${top2} ${X2} ${top2}`,\n    `L ${X2} ${bottom2}`,\n    `C ${X2 - k} ${bottom2} ${X1 + k} ${bottom1} ${X1} ${bottom1}`,\n    \"Z\",\n  ].join(\" \")\n}\n\n/** Point at parameter t along the ribbon's centreline. */\nfunction centrePoint(y1: number, y2: number, t: number) {\n  const k = (X2 - X1) / 2\n  const u = 1 - t\n  const x =\n    u * u * u * X1 + 3 * u * u * t * (X1 + k) + 3 * u * t * t * (X2 - k) + t * t * t * X2\n  const y = u * u * u * y1 + 3 * u * u * t * y1 + 3 * u * t * t * y2 + t * t * t * y2\n  return { x, y }\n}\n\nexport function OpportunityFlow({\n  requirement,\n  opportunities,\n  onOpenDetail,\n  className,\n}: {\n  requirement: MaterialRequirement\n  opportunities: Opportunity[]\n  onOpenDetail?: (id: string) => void\n  className?: string\n}) {\n  const { tip, move, pin, clear, containerRef } = useVizTip()\n  const [hoveredId, setHoveredId] = React.useState<string | null>(null)\n\n  const baseline = baselineOf(opportunities)\n  const ranked = rankOpportunities(opportunities)\n  const H = ranked.length * ROW_H + PAD_Y * 2\n\n  const costs = ranked.map(opportunityTotalCost)\n  const maxCost = Math.max(...costs.map(Math.abs), 1)\n  const widths = costs.map(\n    (cost) => MIN_W + (MAX_W - MIN_W) * (Math.abs(cost) / maxCost)\n  )\n\n  // Origin-side stack: ribbons leave the requirement in rank order.\n  const stackTotal =\n    widths.reduce((sum, w) => sum + w, 0) + (ranked.length - 1) * 6\n  let cursor = (H - stackTotal) / 2\n  const stackY = widths.map((w) => {\n    const y = cursor + w / 2\n    cursor += w + 6\n    return y\n  })\n\n  const rowY = (index: number) => PAD_Y + index * ROW_H + ROW_H / 2\n\n  const exportDirection = requirement.direction === \"export\"\n  const quantityLabel = `${requirement.quantity.value.toLocaleString(\"en-GB\")} ${requirement.quantity.unit}`\n\n  return (\n    <div\n      ref={containerRef}\n      data-slot=\"opportunity-flow\"\n      className={cn(\n        \"relative flex flex-col gap-3 rounded-xl border border-border bg-card p-4\",\n        className\n      )}\n    >\n      <VerdictLegend note=\"ribbon width = estimated net cost\" />\n      <svg\n        viewBox={`0 0 ${W} ${H}`}\n        className=\"h-auto w-full\"\n        role=\"img\"\n        aria-label={`Cost flow of ${ranked.length} pathways for ${requirement.title}. Use the matrix view for the full table.`}\n      >\n        {/* Ribbons first, so labels stay on top */}\n        {ranked.map((opportunity, index) => {\n          const ruledOut = opportunity.viability.verdict === \"ruled_out\"\n          const active = hoveredId === opportunity.id\n          const dimmed = hoveredId != null && !active\n          const wash = ruledOut ? 10 : active ? 40 : dimmed ? 10 : 26\n          const waypoints = opportunity.route.slice(1, -1)\n          const totalKm = opportunity.totalDistanceKm || 1\n          return (\n            <g\n              key={opportunity.id}\n              className=\"cursor-pointer outline-none transition-opacity duration-150\"\n              onMouseMove={move(opportunity)}\n              onMouseEnter={() => setHoveredId(opportunity.id)}\n              onMouseLeave={() => {\n                setHoveredId(null)\n                clear()\n              }}\n              onClick={() => onOpenDetail?.(opportunity.id)}\n              onFocus={pin(opportunity, X2, rowY(index))}\n              onBlur={clear}\n              onKeyDown={(event) => {\n                if (event.key === \"Enter\" || event.key === \" \") {\n                  event.preventDefault()\n                  onOpenDetail?.(opportunity.id)\n                }\n              }}\n              tabIndex={0}\n              role=\"button\"\n              aria-label={`${opportunity.name}, ${formatMoney(costs[index])}`}\n            >\n              <path\n                d={ribbonPath(stackY[index], rowY(index), widths[index])}\n                fill={verdictWash(opportunity.viability.verdict, wash)}\n              />\n              {ruledOut ? (\n                <path\n                  d={`M ${X1} ${stackY[index]} C ${X1 + (X2 - X1) / 2} ${stackY[index]} ${X2 - (X2 - X1) / 2} ${rowY(index)} ${X2} ${rowY(index)}`}\n                  fill=\"none\"\n                  stroke={VERDICT_MARK_COLORS.ruled_out}\n                  strokeWidth=\"1.5\"\n                  strokeDasharray={RULED_OUT_DASH}\n                  opacity={dimmed ? 0.3 : 0.8}\n                />\n              ) : null}\n              {/* facility waypoints along the route */}\n              {waypoints.map((node) => {\n                const along = (node.legDistanceKm ?? totalKm / 2) / totalKm\n                const point = centrePoint(\n                  stackY[index],\n                  rowY(index),\n                  Math.min(Math.max(along, 0.25), 0.75)\n                )\n                return (\n                  <g key={node.id} opacity={dimmed ? 0.3 : 1}>\n                    <circle\n                      cx={point.x}\n                      cy={point.y}\n                      r={5}\n                      fill=\"var(--color-bg-primary)\"\n                      stroke=\"var(--color-fg-quaternary)\"\n                      strokeWidth=\"1.5\"\n                    />\n                    {/* below the dot, on the ribbon's own wash */}\n                    <text\n                      x={point.x}\n                      y={point.y + 17}\n                      textAnchor=\"middle\"\n                      fontSize=\"10\"\n                      className=\"fill-text-quaternary\"\n                    >\n                      {truncate(node.label, 34)}\n                    </text>\n                  </g>\n                )\n              })}\n            </g>\n          )\n        })}\n\n        {/* Requirement block */}\n        <g>\n          <rect\n            x={24}\n            y={(H - stackTotal) / 2 - 16}\n            width={X1 - 24}\n            height={stackTotal + 32}\n            rx={12}\n            fill=\"var(--color-bg-secondary)\"\n            stroke=\"var(--color-border)\"\n          />\n          <rect\n            x={40}\n            y={H / 2 - 22}\n            width={11}\n            height={11}\n            rx={3}\n            transform={`rotate(45 ${45.5} ${H / 2 - 16.5})`}\n            fill=\"var(--color-fg-primary)\"\n          />\n          <text\n            x={58}\n            y={H / 2 - 12}\n            fontSize=\"12.5\"\n            fontWeight={600}\n            className=\"fill-text-primary\"\n          >\n            {truncate(requirement.location.name.split(\",\")[0], 16)}\n          </text>\n          <text x={40} y={H / 2 + 8} fontSize=\"11\" className=\"fill-text-tertiary\">\n            {quantityLabel} {exportDirection ? \"to place\" : \"needed\"}\n          </text>\n        </g>\n\n        {/* Row labels */}\n        {ranked.map((opportunity, index) => {\n          const y = rowY(index)\n          const cost = costs[index]\n          const active = hoveredId === opportunity.id\n          const dimmed = hoveredId != null && !active\n          const ruledOut = opportunity.viability.verdict === \"ruled_out\"\n          const savings =\n            baseline && !opportunity.isBaseline\n              ? savingsVsBaseline(opportunity, baseline)\n              : null\n          return (\n            <g\n              key={opportunity.id}\n              opacity={dimmed ? 0.4 : 1}\n              className=\"cursor-pointer transition-opacity duration-150\"\n              onMouseMove={move(opportunity)}\n              onMouseEnter={() => setHoveredId(opportunity.id)}\n              onMouseLeave={() => {\n                setHoveredId(null)\n                clear()\n              }}\n              onClick={() => onOpenDetail?.(opportunity.id)}\n            >\n              <rect\n                x={X2}\n                y={y - ROW_H / 2 + 4}\n                width={W - X2}\n                height={ROW_H - 8}\n                fill=\"transparent\"\n              />\n              {opportunity.viability.verdict === \"ruled_out\" ? (\n                <circle\n                  cx={TEXT_X - 10}\n                  cy={y - 8}\n                  r={4.5}\n                  fill=\"none\"\n                  stroke={VERDICT_MARK_COLORS.ruled_out}\n                  strokeWidth=\"1.5\"\n                  strokeDasharray=\"2.5 2\"\n                />\n              ) : (\n                <circle\n                  cx={TEXT_X - 10}\n                  cy={y - 8}\n                  r={4.5}\n                  fill={VERDICT_MARK_COLORS[opportunity.viability.verdict]}\n                />\n              )}\n              <text\n                x={TEXT_X}\n                y={y - 4}\n                fontSize=\"13\"\n                fontWeight={600}\n                className={ruledOut ? \"fill-text-tertiary\" : \"fill-text-primary\"}\n              >\n                {truncate(opportunityShortName(opportunity), 27)}\n              </text>\n              <text x={TEXT_X} y={y + 13} fontSize=\"11\" className=\"fill-text-tertiary\">\n                {STRATEGY_LABELS[opportunity.strategy]}\n                {opportunity.totalDistanceKm > 0\n                  ? ` · ${opportunity.totalDistanceKm} km`\n                  : null}\n                {opportunity.isBaseline ? \"  ·  BASELINE\" : null}\n              </text>\n              <text\n                x={RIGHT_X}\n                y={y - 4}\n                textAnchor=\"end\"\n                fontSize=\"13\"\n                fontWeight={600}\n                className={cn(\n                  \"tabular-nums\",\n                  ruledOut ? \"fill-text-tertiary\" : \"fill-text-primary\"\n                )}\n              >\n                {opportunityIsEstimated(opportunity) ? \"~\" : \"\"}\n                {formatMoney(cost)}\n              </text>\n              {savings != null ? (\n                <text\n                  x={RIGHT_X}\n                  y={y + 13}\n                  textAnchor=\"end\"\n                  fontSize=\"11\"\n                  fontWeight={600}\n                  className=\"tabular-nums\"\n                  fill={\n                    savings >= 0\n                      ? \"var(--color-text-success-primary)\"\n                      : \"var(--color-text-error-primary)\"\n                  }\n                >\n                  {formatMoneyDelta(savings)}\n                </text>\n              ) : (\n                <text\n                  x={RIGHT_X}\n                  y={y + 13}\n                  textAnchor=\"end\"\n                  fontSize=\"11\"\n                  className=\"fill-text-quaternary\"\n                >\n                  baseline\n                </text>\n              )}\n            </g>\n          )\n        })}\n      </svg>\n      <VizTooltip tip={tip} baseline={baseline} />\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/blocks/domain/opportunities/opportunity-flow.tsx"
    },
    {
      "path": "components/blocks/domain/opportunities/opportunity-map.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport {\n  baselineOf,\n  formatMoney,\n  opportunityTotalCost,\n  rankOpportunities,\n  type MaterialRequirement,\n  type Opportunity,\n  type RouteNode,\n} from \"./types\"\nimport {\n  opportunityShortName,\n  RULED_OUT_DASH,\n  useVizTip,\n  VERDICT_MARK_COLORS,\n  VerdictLegend,\n  VizTooltip,\n} from \"./viz-utils\"\n\n/**\n * OpportunityMap — the proximity view. The requirement site sits at the\n * centre; every considered pathway radiates out to its counterparty sites,\n * plotted by compass bearing and straight-line distance against faint\n * distance rings. Multi-step routes chain through their facility; arrows\n * carry the direction of movement (out for exports, in for imports);\n * zero-distance pathways (site-won, treat-and-return) loop back to the\n * centre. Colour is the AI verdict; ruled-out routes stay visible, dashed.\n */\n\nconst W = 860\nconst H = 560\nconst CX = W / 2\nconst CY = H / 2\nconst PAD = 84\n\ninterface XY {\n  x: number\n  y: number\n}\n\nfunction polar(bearingDeg: number, r: number): XY {\n  const rad = (bearingDeg * Math.PI) / 180\n  return { x: CX + Math.sin(rad) * r, y: CY - Math.cos(rad) * r }\n}\n\n/** Ring step so 2–4 rings cover the furthest site. */\nfunction ringStep(maxKm: number): number {\n  const steps = [1, 2, 5, 10, 20, 25, 50]\n  const target = maxKm / 2.5\n  return steps.find((step) => step >= target) ?? 50\n}\n\nfunction nodePoint(node: RouteNode, toRadius: (km: number) => number): XY {\n  if (!node.geo || node.geo.distanceKm === 0) return { x: CX, y: CY }\n  return polar(node.geo.bearingDeg, toRadius(node.geo.distanceKm))\n}\n\n/** Gentle quadratic between two points; `side` flips the bow. */\nfunction legPath(from: XY, to: XY, side: number): string {\n  const mx = (from.x + to.x) / 2\n  const my = (from.y + to.y) / 2\n  const dx = to.x - from.x\n  const dy = to.y - from.y\n  const len = Math.hypot(dx, dy) || 1\n  const bow = Math.min(len * 0.14, 26) * side\n  const cx = mx - (dy / len) * bow\n  const cy = my + (dx / len) * bow\n  return `M ${from.x} ${from.y} Q ${cx} ${cy} ${to.x} ${to.y}`\n}\n\n/** Curve midpoint (t = 0.5) + travel angle, for the direction arrow. */\nfunction legArrow(from: XY, to: XY, side: number) {\n  const mx = (from.x + to.x) / 2\n  const my = (from.y + to.y) / 2\n  const dx = to.x - from.x\n  const dy = to.y - from.y\n  const len = Math.hypot(dx, dy) || 1\n  const bow = Math.min(len * 0.14, 26) * side\n  return {\n    x: mx - (dy / len) * (bow / 2),\n    y: my + (dx / len) * (bow / 2),\n    angle: (Math.atan2(dy, dx) * 180) / Math.PI,\n  }\n}\n\nexport function OpportunityMap({\n  requirement,\n  opportunities,\n  onOpenDetail,\n  className,\n}: {\n  requirement: MaterialRequirement\n  opportunities: Opportunity[]\n  onOpenDetail?: (id: string) => void\n  className?: string\n}) {\n  const { tip, move, pin, clear, containerRef } = useVizTip()\n  const [hoveredId, setHoveredId] = React.useState<string | null>(null)\n\n  const baseline = baselineOf(opportunities)\n  const ranked = rankOpportunities(opportunities)\n\n  // Unique off-centre sites across every route, keyed by label.\n  const sites = new Map<string, RouteNode>()\n  for (const opportunity of opportunities) {\n    for (const node of opportunity.route) {\n      if (node.geo && node.geo.distanceKm > 0 && !sites.has(node.label)) {\n        sites.set(node.label, node)\n      }\n    }\n  }\n\n  const maxKm = Math.max(\n    1,\n    ...[...sites.values()].map((node) => node.geo?.distanceKm ?? 0)\n  )\n  const step = ringStep(maxKm)\n  // Square-root radial scale: distance-true rings, but the near-in sites —\n  // where most matches live — get room instead of piling on the centre.\n  const plotR = Math.min(W, H) / 2 - PAD\n  const toRadius = (km: number) => plotR * Math.sqrt(km / (maxKm * 1.06))\n  const rings: number[] = []\n  for (let km = step; km <= maxKm * 1.02; km += step) rings.push(km)\n  if (rings.length === 0) rings.push(step)\n\n  // The single pathway terminating at a site (for its money label).\n  const terminal = new Map<string, Opportunity>()\n  for (const opportunity of opportunities) {\n    const last = opportunity.route[opportunity.route.length - 1]\n    const target =\n      requirement.direction === \"import\" ? opportunity.route[0] : last\n    if (target.geo && target.geo.distanceKm > 0 && !terminal.has(target.label)) {\n      terminal.set(target.label, opportunity)\n    }\n  }\n\n  const dim = (id: string) => hoveredId != null && hoveredId !== id\n\n  // Site labels with a greedy collision pass: sorted top-to-bottom, any label\n  // whose box overlaps an already-placed one slides below it.\n  const siteLabels = [...sites.values()]\n    .map((node) => {\n      const point = nodePoint(node, toRadius)\n      const geo = node.geo!\n      const rad = (geo.bearingDeg * Math.PI) / 180\n      const sin = Math.sin(rad)\n      const cos = Math.cos(rad)\n      const anchor: \"start\" | \"middle\" | \"end\" =\n        sin > 0.3 ? \"start\" : sin < -0.3 ? \"end\" : \"middle\"\n      const owner = terminal.get(node.label)\n      const money = owner\n        ? `~${formatMoney(opportunityTotalCost(owner))}`\n        : undefined\n      const width = 6.2 * Math.max(node.label.length, money?.length ?? 0)\n      return {\n        node,\n        point,\n        owner,\n        money,\n        anchor,\n        lx: point.x + sin * 14,\n        ly: point.y - cos * 14 + (cos < -0.3 ? 16 : cos > 0.3 ? -8 : 4),\n        width,\n        height: money ? 28 : 14,\n      }\n    })\n    .sort((a, b) => a.ly - b.ly)\n  interface LabelBox {\n    lx: number\n    ly: number\n    width: number\n    height: number\n    anchor: \"start\" | \"middle\" | \"end\"\n  }\n  // Ring labels are fixed obstacles the site labels must slide past.\n  const obstacles: LabelBox[] = rings.map((km) => ({\n    lx: CX + toRadius(km) * 0.707 + 4,\n    ly: CY + toRadius(km) * 0.707 + 10,\n    width: 36,\n    height: 12,\n    anchor: \"start\",\n  }))\n  const x0 = (box: LabelBox) =>\n    box.anchor === \"start\"\n      ? box.lx\n      : box.anchor === \"end\"\n        ? box.lx - box.width\n        : box.lx - box.width / 2\n  for (let i = 0; i < siteLabels.length; i++) {\n    const label = siteLabels[i]\n    for (const prior of [...obstacles, ...siteLabels.slice(0, i)]) {\n      const overlapX = x0(label) < x0(prior) + prior.width && x0(prior) < x0(label) + label.width\n      const overlapY =\n        label.ly - 11 < prior.ly - 11 + prior.height + 3 && prior.ly - 11 < label.ly + label.height\n      if (overlapX && overlapY) label.ly = prior.ly + prior.height + 3\n    }\n  }\n\n  // Put the centre label on whichever side has the most breathing room.\n  const sitePoints = [...sites.values()].map((node) => nodePoint(node, toRadius))\n  const centerLabel = [\n    { x: CX, y: CY - 26, anchor: \"middle\" as const },\n    { x: CX, y: CY + 36, anchor: \"middle\" as const },\n    { x: CX - 22, y: CY + 4, anchor: \"end\" as const },\n    { x: CX + 22, y: CY + 4, anchor: \"start\" as const },\n  ]\n    .map((candidate) => ({\n      ...candidate,\n      score: Math.min(\n        ...sitePoints.map((point) =>\n          Math.hypot(point.x - candidate.x, point.y - candidate.y)\n        )\n      ),\n    }))\n    .sort((a, b) => b.score - a.score)[0]\n\n  return (\n    <div\n      ref={containerRef}\n      data-slot=\"opportunity-map\"\n      className={cn(\n        \"relative flex flex-col gap-3 rounded-xl border border-border bg-card p-4\",\n        className\n      )}\n    >\n      <VerdictLegend note=\"positions as the crow flies · road km on the routes\" />\n      <svg\n        viewBox={`0 0 ${W} ${H}`}\n        className=\"h-auto w-full\"\n        role=\"img\"\n        aria-label={`Map of ${opportunities.length} pathways around ${requirement.location.name}. Use the matrix view for the full table.`}\n      >\n        {/* Distance rings — labels ride the SE diagonal, out of the way */}\n        {rings.map((km) => (\n          <g key={km}>\n            <circle\n              cx={CX}\n              cy={CY}\n              r={toRadius(km)}\n              fill=\"none\"\n              stroke=\"var(--color-border)\"\n              strokeWidth=\"1\"\n            />\n            <text\n              x={CX + toRadius(km) * 0.707 + 4}\n              y={CY + toRadius(km) * 0.707 + 10}\n              className=\"fill-text-quaternary\"\n              fontSize=\"10\"\n            >\n              {km} km\n            </text>\n          </g>\n        ))}\n        <text\n          x={W - 18}\n          y={22}\n          textAnchor=\"middle\"\n          className=\"fill-text-quaternary\"\n          fontSize=\"10\"\n        >\n          N ↑\n        </text>\n\n        {/* Routes (ranked so open pathways draw above ruled-out ones) */}\n        {[...ranked].reverse().map((opportunity, reverseIndex) => {\n          const ruledOut = opportunity.viability.verdict === \"ruled_out\"\n          const color = VERDICT_MARK_COLORS[opportunity.viability.verdict]\n          const points = opportunity.route.map((node) => nodePoint(node, toRadius))\n          const legs = points\n            .slice(1)\n            .map((to, index) => ({ from: points[index], to, index }))\n            .filter(({ from, to }) => Math.hypot(to.x - from.x, to.y - from.y) > 1)\n          const selfLoop = legs.length === 0\n          const active = hoveredId === opportunity.id\n          const strokeWidth = active ? 3 : opportunity.status === \"shortlisted\" ? 2.5 : 2\n          const common = {\n            onMouseMove: move(opportunity),\n            onMouseEnter: () => setHoveredId(opportunity.id),\n            onMouseLeave: () => {\n              setHoveredId(null)\n              clear()\n            },\n            onClick: () => onOpenDetail?.(opportunity.id),\n            onFocus: pin(opportunity, CX, CY),\n            onBlur: clear,\n            onKeyDown: (event: React.KeyboardEvent) => {\n              if (event.key === \"Enter\" || event.key === \" \") {\n                event.preventDefault()\n                onOpenDetail?.(opportunity.id)\n              }\n            },\n            tabIndex: 0,\n            role: \"button\" as const,\n            \"aria-label\": `${opportunity.name}, ${formatMoney(opportunityTotalCost(opportunity))}`,\n          }\n          const side = reverseIndex % 2 === 0 ? 1 : -1\n          return (\n            <g\n              key={opportunity.id}\n              className=\"cursor-pointer outline-none transition-opacity duration-150\"\n              opacity={dim(opportunity.id) ? 0.3 : 1}\n              {...common}\n            >\n              {selfLoop ? (\n                <>\n                  <circle\n                    cx={CX}\n                    cy={CY + 38}\n                    r={24}\n                    fill=\"none\"\n                    stroke={color}\n                    strokeWidth={strokeWidth}\n                    strokeDasharray={ruledOut ? RULED_OUT_DASH : undefined}\n                    opacity={ruledOut ? 0.75 : 1}\n                  />\n                  <circle cx={CX} cy={CY + 38} r={16} fill=\"transparent\" stroke=\"transparent\" strokeWidth={18} />\n                  <text\n                    x={CX}\n                    y={CY + 78}\n                    textAnchor=\"middle\"\n                    fontSize=\"11\"\n                    fontWeight={500}\n                    className=\"fill-text-secondary\"\n                  >\n                    {opportunityShortName(opportunity)} — on site\n                  </text>\n                  <text\n                    x={CX}\n                    y={CY + 91}\n                    textAnchor=\"middle\"\n                    fontSize=\"11\"\n                    fontWeight={600}\n                    className=\"fill-text-primary tabular-nums\"\n                  >\n                    ~{formatMoney(opportunityTotalCost(opportunity))}\n                  </text>\n                </>\n              ) : (\n                legs.map(({ from, to, index }) => {\n                  const legSide = side * (index % 2 === 0 ? 1 : -1)\n                  const arrow = legArrow(from, to, legSide)\n                  return (\n                    <g key={index}>\n                      {/* generous hit area */}\n                      <path\n                        d={legPath(from, to, legSide)}\n                        fill=\"none\"\n                        stroke=\"transparent\"\n                        strokeWidth=\"16\"\n                      />\n                      <path\n                        d={legPath(from, to, legSide)}\n                        fill=\"none\"\n                        stroke={color}\n                        strokeWidth={strokeWidth}\n                        strokeLinecap=\"round\"\n                        strokeDasharray={ruledOut ? RULED_OUT_DASH : undefined}\n                        opacity={ruledOut ? 0.75 : 1}\n                      />\n                      {/* route order is travel order, so the angle is the direction */}\n                      <polygon\n                        points=\"0,-3.6 7,0 0,3.6\"\n                        fill={color}\n                        opacity={ruledOut ? 0.75 : 1}\n                        transform={`translate(${arrow.x} ${arrow.y}) rotate(${arrow.angle})`}\n                      />\n                    </g>\n                  )\n                })\n              )}\n            </g>\n          )\n        })}\n\n        {/* Sites */}\n        {siteLabels.map(({ node, point, owner, money, anchor, lx, ly }) => {\n          const endColor = owner\n            ? VERDICT_MARK_COLORS[owner.viability.verdict]\n            : undefined\n          const ruledOut = owner?.viability.verdict === \"ruled_out\"\n          return (\n            <g\n              key={node.label}\n              opacity={owner && dim(owner.id) ? 0.35 : 1}\n              className=\"transition-opacity duration-150\"\n            >\n              <circle\n                cx={point.x}\n                cy={point.y}\n                r={6.5}\n                fill={endColor && !ruledOut ? endColor : \"var(--color-bg-primary)\"}\n                stroke={endColor ?? \"var(--color-fg-quaternary)\"}\n                strokeWidth={1.75}\n                strokeDasharray={ruledOut ? \"2.5 2\" : undefined}\n              />\n              <text\n                x={lx}\n                y={ly}\n                textAnchor={anchor}\n                fontSize=\"11\"\n                fontWeight={500}\n                className=\"fill-text-secondary\"\n              >\n                {node.label}\n              </text>\n              {money ? (\n                <text\n                  x={lx}\n                  y={ly + 13}\n                  textAnchor={anchor}\n                  fontSize=\"11\"\n                  fontWeight={600}\n                  className=\"fill-text-primary tabular-nums\"\n                >\n                  {money}\n                </text>\n              ) : null}\n            </g>\n          )\n        })}\n\n        {/* The requirement site */}\n        <g>\n          <circle\n            cx={CX}\n            cy={CY}\n            r={11}\n            fill=\"none\"\n            stroke=\"var(--color-fg-primary)\"\n            strokeWidth=\"1.5\"\n            opacity=\"0.5\"\n            className=\"motion-safe:animate-ping\"\n            style={{ transformBox: \"fill-box\", transformOrigin: \"center\" }}\n          />\n          <rect\n            x={CX - 7}\n            y={CY - 7}\n            width={14}\n            height={14}\n            rx={3.5}\n            transform={`rotate(45 ${CX} ${CY})`}\n            fill=\"var(--color-fg-primary)\"\n          />\n          <text\n            x={centerLabel.x}\n            y={centerLabel.y}\n            textAnchor={centerLabel.anchor}\n            fontSize=\"12\"\n            fontWeight={600}\n            className=\"fill-text-primary\"\n          >\n            {requirement.location.name.split(\",\")[0]}\n          </text>\n        </g>\n      </svg>\n      <VizTooltip tip={tip} baseline={baseline} />\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/blocks/domain/opportunities/opportunity-map.tsx"
    },
    {
      "path": "components/blocks/domain/opportunities/opportunity-primitives.tsx",
      "content": "import type * as React from \"react\"\nimport {\n  AlertCircle,\n  AlertTriangle,\n  Archive,\n  ArrowNarrowRight,\n  Building07,\n  CheckCircle,\n  Clock,\n  Home02,\n  Package,\n  RefreshCcw02,\n  SlashOctagon,\n  Tool02,\n  Trash03,\n  Truck01,\n} from \"@untitledui/icons\"\n\nimport { Badge } from \"@/components/ui/badge\"\nimport { cn } from \"@/lib/utils\"\nimport {\n  formatMoneyDelta,\n  MATERIAL_RELATION_LABELS,\n  OPPORTUNITY_STATUS_LABELS,\n  savingsVsBaseline,\n  SCORE_TIER_LABELS,\n  scoreTier,\n  STRATEGY_LABELS,\n  TIMING_FIT_LABELS,\n  VERDICT_LABELS,\n  type Compatibility,\n  type Opportunity,\n  type OpportunityStatus,\n  type OpportunityStrategy,\n  type RouteNode,\n  type RouteNodeKind,\n  type ScoreTier,\n  type TimingFit,\n  type Viability,\n  type ViabilityVerdict,\n} from \"./types\"\n\n/**\n * Shared atoms for the opportunity matrix family: node icons, the verdict /\n * timing / strategy / status badges, the compatibility ring and the\n * savings-vs-baseline delta. Every surface (cards, comparison, matrix,\n * detail) renders these so the vocabulary cannot drift between views.\n */\n\n// ---------------------------------------------------------------------------\n// Route node icons\n// ---------------------------------------------------------------------------\n\nconst NODE_ICONS: Record<RouteNodeKind, React.ComponentType<{ className?: string }>> = {\n  site: Building07,\n  receiving_site: Home02,\n  treatment_facility: Tool02,\n  recycling_facility: RefreshCcw02,\n  transfer_station: Truck01,\n  landfill: Trash03,\n  stockpile: Archive,\n  supplier: Package,\n}\n\nexport function RouteNodeIcon({\n  kind,\n  className,\n}: {\n  kind: RouteNodeKind\n  className?: string\n}) {\n  const Icon = NODE_ICONS[kind]\n  return <Icon className={cn(\"size-4\", className)} aria-hidden=\"true\" />\n}\n\n/** Inline chip strip: origin → … → destination with leg distances. */\nexport function RouteSummary({\n  route,\n  className,\n}: {\n  route: RouteNode[]\n  className?: string\n}) {\n  return (\n    <div\n      data-slot=\"route-summary\"\n      className={cn(\"flex flex-wrap items-center gap-1.5 text-xs text-text-tertiary\", className)}\n    >\n      {route.map((node, index) => (\n        <span key={node.id} className=\"flex items-center gap-1.5\">\n          {index > 0 ? (\n            <span className=\"flex items-center gap-1 whitespace-nowrap text-text-quaternary\">\n              <ArrowNarrowRight className=\"size-3.5\" aria-hidden=\"true\" />\n              {node.legDistanceKm != null ? (\n                <span className=\"tabular-nums\">{node.legDistanceKm} km</span>\n              ) : null}\n            </span>\n          ) : null}\n          <span className=\"flex items-center gap-1 font-medium text-text-secondary\">\n            <RouteNodeIcon kind={node.kind} className=\"size-3.5 text-fg-quaternary\" />\n            {node.label}\n          </span>\n        </span>\n      ))}\n    </div>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// Viability\n// ---------------------------------------------------------------------------\n\nconst VERDICT_BADGE_VARIANTS: Record<\n  ViabilityVerdict,\n  React.ComponentProps<typeof Badge>[\"variant\"]\n> = {\n  viable: \"success\",\n  conditional: \"warning\",\n  unlikely: \"gray\",\n  ruled_out: \"error\",\n}\n\nconst VERDICT_ICONS: Record<ViabilityVerdict, React.ComponentType<{ className?: string }>> = {\n  viable: CheckCircle,\n  conditional: AlertCircle,\n  unlikely: AlertTriangle,\n  ruled_out: SlashOctagon,\n}\n\nexport function ViabilityBadge({\n  viability,\n  showConfidence = false,\n  className,\n}: {\n  viability: Viability\n  /** Append \"· 78%\" for detail surfaces. */\n  showConfidence?: boolean\n  className?: string\n}) {\n  const Icon = VERDICT_ICONS[viability.verdict]\n  return (\n    <Badge variant={VERDICT_BADGE_VARIANTS[viability.verdict]} className={className}>\n      <Icon className=\"size-3\" aria-hidden=\"true\" />\n      {VERDICT_LABELS[viability.verdict]}\n      {showConfidence ? (\n        <span className=\"opacity-70 tabular-nums\">\n          · {Math.round(viability.confidence * 100)}%\n        </span>\n      ) : null}\n    </Badge>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// Compatibility ring (nexus4 MatchScoreBadge tiers)\n// ---------------------------------------------------------------------------\n\nconst TIER_RING_CLASSES: Record<ScoreTier, string> = {\n  excellent: \"text-utility-green-600\",\n  good: \"text-utility-blue-600\",\n  fair: \"text-utility-amber-600\",\n  poor: \"text-utility-orange-600\",\n  low: \"text-utility-red-600\",\n}\n\n/** Compact score ring; colour follows the nexus4 tier bands. */\nexport function CompatibilityRing({\n  compatibility,\n  size = 36,\n  className,\n}: {\n  compatibility: Compatibility\n  /** Outer box in px. */\n  size?: number\n  className?: string\n}) {\n  const stroke = 3.5\n  const radius = (size - stroke) / 2\n  const circumference = 2 * Math.PI * radius\n  const tier = scoreTier(compatibility.score)\n  const offset = circumference - (compatibility.score / 100) * circumference\n  return (\n    <div\n      data-slot=\"compatibility-ring\"\n      role=\"img\"\n      aria-label={`Compatibility ${compatibility.score} — ${SCORE_TIER_LABELS[tier]} (${MATERIAL_RELATION_LABELS[compatibility.relation]})`}\n      title={`${SCORE_TIER_LABELS[tier]} · ${MATERIAL_RELATION_LABELS[compatibility.relation]}`}\n      className={cn(\n        \"relative inline-flex shrink-0 items-center justify-center\",\n        TIER_RING_CLASSES[tier],\n        className\n      )}\n      style={{ width: size, height: size }}\n    >\n      <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} fill=\"none\">\n        <circle\n          cx={size / 2}\n          cy={size / 2}\n          r={radius}\n          className=\"stroke-bg-quaternary\"\n          strokeWidth={stroke}\n        />\n        <circle\n          cx={size / 2}\n          cy={size / 2}\n          r={radius}\n          stroke=\"currentColor\"\n          strokeWidth={stroke}\n          strokeLinecap=\"round\"\n          strokeDasharray={circumference}\n          strokeDashoffset={offset}\n          transform={`rotate(-90 ${size / 2} ${size / 2})`}\n        />\n      </svg>\n      <span className=\"absolute text-[11px] font-semibold text-foreground tabular-nums\">\n        {compatibility.score}\n      </span>\n    </div>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// Cost delta vs baseline\n// ---------------------------------------------------------------------------\n\n/** \"+£127,465 vs baseline\" (green = saving) or the baseline marker itself. */\nexport function SavingsDelta({\n  opportunity,\n  baseline,\n  className,\n}: {\n  opportunity: Opportunity\n  baseline: Opportunity | undefined\n  className?: string\n}) {\n  if (!baseline || opportunity.isBaseline) {\n    return (\n      <span className={cn(\"text-xs font-medium text-text-quaternary\", className)}>\n        Baseline route\n      </span>\n    )\n  }\n  const savings = savingsVsBaseline(opportunity, baseline)\n  return (\n    <span\n      data-slot=\"savings-delta\"\n      className={cn(\n        \"text-xs font-semibold tabular-nums\",\n        savings >= 0 ? \"text-success-primary\" : \"text-error-primary\",\n        className\n      )}\n    >\n      {formatMoneyDelta(savings)} vs baseline\n    </span>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// Timing / strategy / status badges\n// ---------------------------------------------------------------------------\n\nconst TIMING_BADGE_VARIANTS: Record<\n  TimingFit,\n  React.ComponentProps<typeof Badge>[\"variant\"]\n> = {\n  good: \"success\",\n  tight: \"warning\",\n  mismatch: \"error\",\n}\n\nexport function TimingBadge({\n  timing,\n  className,\n}: {\n  timing: { fit: TimingFit; note: string }\n  className?: string\n}) {\n  return (\n    <Badge\n      variant={TIMING_BADGE_VARIANTS[timing.fit]}\n      className={className}\n      title={timing.note}\n    >\n      <Clock className=\"size-3\" aria-hidden=\"true\" />\n      {TIMING_FIT_LABELS[timing.fit]}\n    </Badge>\n  )\n}\n\nexport function StrategyBadge({\n  strategy,\n  className,\n}: {\n  strategy: OpportunityStrategy\n  className?: string\n}) {\n  return (\n    <Badge variant=\"gray\" className={className}>\n      {STRATEGY_LABELS[strategy]}\n    </Badge>\n  )\n}\n\nconst STATUS_BADGE_VARIANTS: Record<\n  OpportunityStatus,\n  React.ComponentProps<typeof Badge>[\"variant\"]\n> = {\n  new: \"blue\",\n  shortlisted: \"brand\",\n  enquiry_sent: \"success\",\n  dismissed: \"gray\",\n}\n\nexport function OpportunityStatusBadge({\n  status,\n  className,\n}: {\n  status: OpportunityStatus\n  className?: string\n}) {\n  return (\n    <Badge variant={STATUS_BADGE_VARIANTS[status]} className={className}>\n      {OPPORTUNITY_STATUS_LABELS[status]}\n    </Badge>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/blocks/domain/opportunities/opportunity-primitives.tsx"
    },
    {
      "path": "components/blocks/domain/opportunities/options-matrix.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@/components/ui/table\"\nimport { cn } from \"@/lib/utils\"\nimport {\n  CompatibilityRing,\n  OpportunityStatusBadge,\n  RouteSummary,\n  SavingsDelta,\n  StrategyBadge,\n  TimingBadge,\n  ViabilityBadge,\n} from \"./opportunity-primitives\"\nimport {\n  baselineOf,\n  formatDistanceKm,\n  formatMoney,\n  opportunityIsEstimated,\n  opportunityTotalCost,\n  rankOpportunities,\n  type MaterialRequirement,\n  type Opportunity,\n} from \"./types\"\n\n/**\n * OptionsMatrix — the full transparency view: every pathway the engine\n * considered for a requirement, including the ones it ruled out and why.\n * Where the comparison view helps choose between open options, this table\n * builds trust in the recommendation by showing the whole decision space —\n * ruled-out rows stay legible enough to read their exclusion reason.\n */\n\nexport function OptionsMatrix({\n  requirement,\n  opportunities,\n  onOpenDetail,\n  showSummary = true,\n  className,\n}: {\n  requirement: MaterialRequirement\n  opportunities: Opportunity[]\n  /** Rows become clickable when provided. */\n  onOpenDetail?: (id: string) => void\n  /** Hide the considered/open/ruled-out note when the host page carries it. */\n  showSummary?: boolean\n  className?: string\n}) {\n  const ranked = rankOpportunities(opportunities)\n  const baseline = baselineOf(opportunities)\n  const ruledOut = opportunities.filter(\n    (opportunity) => opportunity.viability.verdict === \"ruled_out\"\n  ).length\n  const open = opportunities.length - ruledOut\n\n  return (\n    <div data-slot=\"options-matrix\" className={cn(\"flex flex-col gap-2\", className)}>\n      {showSummary ? (\n        <p className=\"text-xs text-text-tertiary\">\n          {opportunities.length} pathways considered · {open} open · {ruledOut} ruled out\n        </p>\n      ) : null}\n      <div className=\"overflow-x-auto rounded-xl border border-border bg-card\">\n        <Table>\n          <TableHeader>\n            <TableRow>\n              <TableHead className=\"px-4\">Pathway</TableHead>\n              <TableHead className=\"px-4\">Route</TableHead>\n              <TableHead className=\"px-4\">Verdict</TableHead>\n              <TableHead className=\"px-4\">Compatibility</TableHead>\n              <TableHead className=\"px-4\">Distance</TableHead>\n              <TableHead className=\"px-4\">Timing</TableHead>\n              <TableHead className=\"px-4 text-right\">Est. cost</TableHead>\n              <TableHead className=\"px-4 text-right\">vs baseline</TableHead>\n              <TableHead className=\"px-4\">Status</TableHead>\n            </TableRow>\n          </TableHeader>\n          <TableBody>\n            {ranked.map((o) => {\n              const dimmed = o.viability.verdict === \"ruled_out\"\n              const mute = (children: React.ReactNode) =>\n                dimmed ? <span className=\"opacity-60\">{children}</span> : children\n              return (\n                <TableRow\n                  key={o.id}\n                  onClick={onOpenDetail ? () => onOpenDetail(o.id) : undefined}\n                  className={cn(onOpenDetail && \"cursor-pointer\")}\n                >\n                  <TableCell className=\"px-4 whitespace-normal\">\n                    <div className=\"flex min-w-44 flex-col items-start gap-1\">\n                      <span className={cn(dimmed && \"opacity-60\")}>\n                        {onOpenDetail ? (\n                          <button\n                            type=\"button\"\n                            onClick={(event) => {\n                              event.stopPropagation()\n                              onOpenDetail(o.id)\n                            }}\n                            className=\"cursor-pointer rounded-xs text-left text-sm font-semibold text-foreground focus-visible:ring-4 focus-visible:ring-ring/25 focus-visible:outline-none\"\n                          >\n                            {o.name}\n                          </button>\n                        ) : (\n                          <span className=\"text-sm font-semibold text-foreground\">\n                            {o.name}\n                          </span>\n                        )}\n                      </span>\n                      <span className={cn(\"flex flex-wrap items-center gap-1\", dimmed && \"opacity-60\")}>\n                        <StrategyBadge strategy={o.strategy} />\n                        {o.isBaseline ? (\n                          <span className=\"rounded-full border border-border px-1.5 py-px text-[10px] font-medium text-text-quaternary uppercase\">\n                            Baseline\n                          </span>\n                        ) : null}\n                      </span>\n                      {o.partner ? (\n                        <span className={cn(\"text-xs text-text-tertiary\", dimmed && \"opacity-60\")}>\n                          {o.partner.name}\n                        </span>\n                      ) : null}\n                      {dimmed && o.viability.ruledOutReason ? (\n                        <span className=\"text-xs whitespace-normal text-error-primary\">\n                          {o.viability.ruledOutReason}\n                        </span>\n                      ) : null}\n                    </div>\n                  </TableCell>\n                  <TableCell className=\"px-4 whitespace-normal\">\n                    {mute(\n                      <RouteSummary route={o.route} className=\"max-w-72 min-w-52\" />\n                    )}\n                  </TableCell>\n                  <TableCell className=\"px-4\">\n                    <ViabilityBadge viability={o.viability} />\n                  </TableCell>\n                  <TableCell className=\"px-4\">\n                    {mute(\n                      o.compatibility ? (\n                        <CompatibilityRing compatibility={o.compatibility} size={28} />\n                      ) : (\n                        <span className=\"text-text-quaternary\">—</span>\n                      )\n                    )}\n                  </TableCell>\n                  <TableCell className=\"px-4 tabular-nums\">\n                    {mute(formatDistanceKm(o.totalDistanceKm))}\n                  </TableCell>\n                  <TableCell className=\"px-4\">{mute(<TimingBadge timing={o.timing} />)}</TableCell>\n                  <TableCell className=\"px-4 text-right\">\n                    {mute(\n                      <span className=\"font-medium text-foreground tabular-nums\">\n                        {opportunityIsEstimated(o) ? \"~\" : \"\"}\n                        {formatMoney(opportunityTotalCost(o))}\n                      </span>\n                    )}\n                  </TableCell>\n                  <TableCell className=\"px-4 text-right\">\n                    {mute(<SavingsDelta opportunity={o} baseline={baseline} />)}\n                  </TableCell>\n                  <TableCell className=\"px-4\">\n                    {mute(<OpportunityStatusBadge status={o.status} />)}\n                  </TableCell>\n                </TableRow>\n              )\n            })}\n          </TableBody>\n        </Table>\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/blocks/domain/opportunities/options-matrix.tsx"
    },
    {
      "path": "components/blocks/domain/opportunities/route-pathway.tsx",
      "content": "import { Truck01 } from \"@untitledui/icons\"\n\nimport { cn } from \"@/lib/utils\"\nimport { RouteNodeIcon } from \"./opportunity-primitives\"\nimport { formatDistanceKm, ROUTE_NODE_LABELS, type RouteNode } from \"./types\"\n\n/**\n * RoutePathway — the multi-step route visualisation for an opportunity:\n * every node in the chain (site, treatment facility, receiving site,\n * landfill, …) drawn as a labelled disc, joined by connector legs carrying\n * the haulage distance. Horizontal reads left-to-right for cards and\n * comparison surfaces; vertical is the compact rail variant for drawers and\n * narrow columns. Single-node routes (e.g. site-won processing) render the\n * node alone, no connectors.\n */\n\nconst DISC_BASE =\n  \"flex size-10 shrink-0 items-center justify-center rounded-full ring-1\"\nconst DISC_DEFAULT = \"bg-bg-secondary text-fg-secondary ring-border\"\nconst DISC_TERMINAL = \"bg-bg-brand-primary text-fg-brand-primary ring-border-brand\"\n\nfunction NodeDisc({ node, terminal }: { node: RouteNode; terminal: boolean }) {\n  return (\n    <span\n      data-slot=\"route-node-disc\"\n      className={cn(DISC_BASE, terminal ? DISC_TERMINAL : DISC_DEFAULT)}\n    >\n      <RouteNodeIcon kind={node.kind} className=\"size-5\" />\n    </span>\n  )\n}\n\nfunction DwellPill({ note }: { note: string }) {\n  return (\n    <span className=\"inline-flex w-fit items-center rounded-full bg-bg-tertiary px-2 py-0.5 text-[11px] font-medium text-text-tertiary\">\n      {note}\n    </span>\n  )\n}\n\nfunction LegChip({ distanceKm }: { distanceKm?: number }) {\n  return (\n    <span className=\"flex items-center gap-1 text-xs font-medium whitespace-nowrap text-text-quaternary\">\n      <Truck01 className=\"size-3.5\" aria-hidden=\"true\" />\n      {distanceKm != null ? (\n        <span className=\"tabular-nums\">{formatDistanceKm(distanceKm)}</span>\n      ) : null}\n    </span>\n  )\n}\n\nexport function RoutePathway({\n  route,\n  orientation = \"horizontal\",\n  className,\n}: {\n  route: RouteNode[]\n  /** Horizontal for cards and wide surfaces; vertical for drawers/rails. */\n  orientation?: \"horizontal\" | \"vertical\"\n  className?: string\n}) {\n  if (orientation === \"vertical\") {\n    return (\n      <ol data-slot=\"route-pathway\" className={cn(\"flex flex-col\", className)}>\n        {route.map((node, index) => {\n          const terminal = index === route.length - 1 && route.length > 1\n          return (\n            <li key={node.id} className=\"flex flex-col\">\n              {index > 0 ? (\n                <div className=\"flex min-h-8 items-center gap-3\" aria-hidden=\"true\">\n                  <span className=\"flex w-10 justify-center self-stretch\">\n                    <span className=\"w-px bg-border-secondary\" />\n                  </span>\n                  <LegChip distanceKm={node.legDistanceKm} />\n                </div>\n              ) : null}\n              <div className=\"flex items-start gap-3\">\n                <NodeDisc node={node} terminal={terminal} />\n                <div className=\"flex min-w-0 flex-col gap-0.5 pt-0.5\">\n                  <span className=\"text-sm font-medium text-text-secondary\">\n                    {node.label}\n                  </span>\n                  <span className=\"text-xs text-text-quaternary\">\n                    {ROUTE_NODE_LABELS[node.kind]}\n                  </span>\n                  {node.detail ? (\n                    <span className=\"text-xs text-text-tertiary\">{node.detail}</span>\n                  ) : null}\n                  {node.dwellNote ? <DwellPill note={node.dwellNote} /> : null}\n                </div>\n              </div>\n            </li>\n          )\n        })}\n      </ol>\n    )\n  }\n\n  return (\n    <div data-slot=\"route-pathway\" className={cn(\"overflow-x-auto\", className)}>\n      <ol className=\"flex min-w-fit items-start\">\n        {route.map((node, index) => {\n          const terminal = index === route.length - 1 && route.length > 1\n          return (\n            <li key={node.id} className=\"flex flex-1 items-start last:flex-none\">\n              {index > 0 ? (\n                <div\n                  className=\"flex min-w-16 flex-1 items-center gap-1.5 px-1 pt-3\"\n                  aria-hidden=\"true\"\n                >\n                  <span className=\"h-px min-w-3 flex-1 bg-border-secondary\" />\n                  <LegChip distanceKm={node.legDistanceKm} />\n                  <span className=\"h-px min-w-3 flex-1 bg-border-secondary\" />\n                </div>\n              ) : null}\n              <div className=\"flex w-32 shrink-0 flex-col items-center gap-1.5 text-center\">\n                <NodeDisc node={node} terminal={terminal} />\n                <div className=\"flex flex-col items-center gap-0.5\">\n                  <span className=\"text-sm font-medium text-text-secondary\">\n                    {node.label}\n                  </span>\n                  <span className=\"text-xs text-text-quaternary\">\n                    {ROUTE_NODE_LABELS[node.kind]}\n                  </span>\n                  {node.detail ? (\n                    <span className=\"text-xs text-balance text-text-tertiary\">\n                      {node.detail}\n                    </span>\n                  ) : null}\n                  {node.dwellNote ? <DwellPill note={node.dwellNote} /> : null}\n                </div>\n              </div>\n            </li>\n          )\n        })}\n      </ol>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/blocks/domain/opportunities/route-pathway.tsx"
    },
    {
      "path": "components/blocks/domain/opportunities/types.ts",
      "content": "/**\n * opportunities — the shared contract for the opportunity matrix family\n * (opportunity-card, route-pathway, cost-breakdown, opportunity-comparison,\n * options-matrix, viability-panel, opportunity-detail).\n *\n * Like `marketplace-types` for the map family, this module holds the\n * vocabulary and nothing else. It models how Nexus presents the *routing\n * matrix* for a material requirement: every pathway the engine considered\n * for sourcing (imports) or placing (exports) a material — landfill\n * disposal, direct re-use, treat-then-reuse, stockpile-and-hold, primary or\n * recycled supply — each carrying route legs, an itemised cost estimate, a\n * compatibility score (mirroring the nexus4 matching engine's 1–100\n * composite with its material / distance / quantity breakdown) and an\n * AI-assessed viability verdict. Blocks depend on this and stay\n * data-agnostic; demo datasets live in `data.ts`.\n */\n\n// ---------------------------------------------------------------------------\n// The requirement (the base position an opportunity set is computed for)\n// ---------------------------------------------------------------------------\n\n/** `export` = surplus to move off site; `import` = a need to source. */\nexport type RequirementDirection = \"export\" | \"import\"\n\n/** Mirrors nexus4 `regulatoryStatus` (lowercased for display data). */\nexport type RegulatoryStatus = \"waste\" | \"product\" | \"byproduct\" | \"unknown\"\n\n/** Mirrors nexus4 `qualityLevel`. */\nexport type QualityLevel = \"unknown\" | \"declared\" | \"tested\" | \"certified\"\n\nexport interface RequirementMaterial {\n  /** Taxonomy class, e.g. \"Topsoil\". */\n  classLabel: string\n  /** Leaf term, e.g. \"Topsoil — BS 3882 multipurpose grade\". */\n  termLabel: string\n  /** Six-digit EWC code where the material is (currently) waste. */\n  ewcCode?: string\n  regulatoryStatus: RegulatoryStatus\n  qualityLevel: QualityLevel\n}\n\nexport interface RequirementQuantity {\n  value: number\n  /** Display unit, e.g. \"m³\" or \"t\". */\n  unit: string\n  /** Normalised volume for comparisons. */\n  volumeM3: number\n  /** Normalised mass for per-tonne costs. */\n  tonnes: number\n}\n\nexport interface MaterialRequirement {\n  id: string\n  direction: RequirementDirection\n  /** e.g. \"Surplus topsoil — Riverside Quarter\". */\n  title: string\n  projectName: string\n  material: RequirementMaterial\n  quantity: RequirementQuantity\n  location: { name: string; postcode: string }\n  /** Availability / need window, ISO dates. */\n  window: { from: string; until: string }\n  /** One-paragraph context shown on requirement headers. */\n  summary?: string\n}\n\n// ---------------------------------------------------------------------------\n// Routes — a pathway is an ordered chain of nodes with legs between them\n// ---------------------------------------------------------------------------\n\n/** Node kinds mirror nexus4 movement endpoint kinds + facility types. */\nexport type RouteNodeKind =\n  | \"site\"\n  | \"receiving_site\"\n  | \"treatment_facility\"\n  | \"recycling_facility\"\n  | \"transfer_station\"\n  | \"landfill\"\n  | \"stockpile\"\n  | \"supplier\"\n\nexport interface RouteNode {\n  id: string\n  kind: RouteNodeKind\n  /** e.g. \"Avonmouth Soil Treatment Centre\". */\n  label: string\n  /** What happens at this node, e.g. \"Screen to 10mm and blend to BS 3882\". */\n  detail?: string\n  /** Road distance of the leg *arriving* at this node (absent on the first). */\n  legDistanceKm?: number\n  /** Dwell annotation, e.g. \"held ~10 weeks\". */\n  dwellNote?: string\n  /**\n   * Position relative to the requirement site for spatial views: compass\n   * bearing and straight-line distance (road distances stay on the legs).\n   * Omit on nodes at the requirement site itself — they sit at the centre.\n   */\n  geo?: { bearingDeg: number; distanceKm: number }\n}\n\nexport const ROUTE_NODE_LABELS: Record<RouteNodeKind, string> = {\n  site: \"Own site\",\n  receiving_site: \"Receiving site\",\n  treatment_facility: \"Treatment facility\",\n  recycling_facility: \"Recycling facility\",\n  transfer_station: \"Transfer station\",\n  landfill: \"Landfill\",\n  stockpile: \"Storage / stockpile\",\n  supplier: \"Supplier\",\n}\n\n// ---------------------------------------------------------------------------\n// Costs — itemised estimate lines; negative amounts are credits\n// ---------------------------------------------------------------------------\n\nexport type CostKind =\n  | \"haulage\"\n  | \"gate_fee\"\n  | \"landfill_tax\"\n  | \"treatment\"\n  | \"testing\"\n  | \"storage\"\n  | \"material_purchase\"\n  | \"processing\"\n  | \"admin\"\n  | \"credit\"\n  | \"other\"\n\nexport const COST_KIND_LABELS: Record<CostKind, string> = {\n  haulage: \"Haulage\",\n  gate_fee: \"Gate fee\",\n  landfill_tax: \"Landfill tax\",\n  treatment: \"Treatment\",\n  testing: \"Testing\",\n  storage: \"Storage\",\n  material_purchase: \"Material purchase\",\n  processing: \"Processing\",\n  admin: \"Administration\",\n  credit: \"Credit\",\n  other: \"Other\",\n}\n\nexport interface CostLine {\n  id: string\n  kind: CostKind\n  /** e.g. \"Haulage — 265 loads to Shortwood\". */\n  label: string\n  /** GBP. Negative = credit/avoided cost (rendered as a saving). */\n  amount: number\n  /** Qualifier, e.g. \"at £4.05/t inert rate\". */\n  note?: string\n  /** True when modelled rather than quoted — rendered with a tilde. */\n  estimated?: boolean\n}\n\n// ---------------------------------------------------------------------------\n// Compatibility — mirrors the nexus4 matching engine output\n// ---------------------------------------------------------------------------\n\nexport type MaterialRelation =\n  | \"exact\"\n  | \"same_class\"\n  | \"same_family\"\n  | \"cross_class_rule\"\n\nexport const MATERIAL_RELATION_LABELS: Record<MaterialRelation, string> = {\n  exact: \"Exact material match\",\n  same_class: \"Same material class\",\n  same_family: \"Same material family\",\n  cross_class_rule: \"Compatible by rule\",\n}\n\n/** The engine's composite (1–100) and its factor breakdown (each 0–1). */\nexport interface Compatibility {\n  score: number\n  breakdown: { material: number; distance: number; quantity: number }\n  relation: MaterialRelation\n  /** Human explainers, e.g. \"Quantity fully meets requirement\". */\n  explainers: string[]\n}\n\nexport type ScoreTier = \"excellent\" | \"good\" | \"fair\" | \"poor\" | \"low\"\n\n/** The nexus4 `MatchScoreBadge` tier bands. */\nexport function scoreTier(score: number): ScoreTier {\n  if (score >= 80) return \"excellent\"\n  if (score >= 60) return \"good\"\n  if (score >= 40) return \"fair\"\n  if (score >= 20) return \"poor\"\n  return \"low\"\n}\n\nexport const SCORE_TIER_LABELS: Record<ScoreTier, string> = {\n  excellent: \"Excellent\",\n  good: \"Good\",\n  fair: \"Fair\",\n  poor: \"Poor\",\n  low: \"Low\",\n}\n\n// ---------------------------------------------------------------------------\n// Viability — the AI assessment layer (new; no nexus4 equivalent yet)\n// ---------------------------------------------------------------------------\n\nexport type ViabilityVerdict = \"viable\" | \"conditional\" | \"unlikely\" | \"ruled_out\"\n\nexport const VERDICT_LABELS: Record<ViabilityVerdict, string> = {\n  viable: \"Viable\",\n  conditional: \"Conditional\",\n  unlikely: \"Unlikely\",\n  ruled_out: \"Ruled out\",\n}\n\nexport type ConditionStatus = \"met\" | \"action_needed\" | \"pending\"\n\n/** A gate the pathway must pass, e.g. \"BS 3882 test results\". */\nexport interface ViabilityCondition {\n  id: string\n  label: string\n  status: ConditionStatus\n  detail?: string\n}\n\nexport interface Viability {\n  verdict: ViabilityVerdict\n  /** Assessment confidence, 0–1. */\n  confidence: number\n  /** One-sentence plain-English assessment. */\n  summary: string\n  strengths: string[]\n  risks: string[]\n  conditions: ViabilityCondition[]\n  /** Single reason shown for ruled-out options in the matrix. */\n  ruledOutReason?: string\n  /** ISO datetime of the assessment run. */\n  assessedAt: string\n}\n\n// ---------------------------------------------------------------------------\n// Compliance — derived requirement items (nexus4 requirements engine style)\n// ---------------------------------------------------------------------------\n\nexport type ComplianceStatus = \"ok\" | \"gap\" | \"pending\"\n\nexport interface ComplianceItem {\n  id: string\n  /** e.g. \"Waste transfer notes per load\". */\n  label: string\n  status: ComplianceStatus\n  detail?: string\n}\n\n// ---------------------------------------------------------------------------\n// The opportunity — one considered pathway\n// ---------------------------------------------------------------------------\n\nexport type OpportunityStrategy =\n  | \"landfill_disposal\"\n  | \"direct_reuse\"\n  | \"treat_then_reuse\"\n  | \"treat_and_return\"\n  | \"stockpile_hold\"\n  | \"recovery\"\n  | \"supply_primary\"\n  | \"supply_recycled\"\n  | \"supply_surplus\"\n  | \"site_won\"\n\nexport const STRATEGY_LABELS: Record<OpportunityStrategy, string> = {\n  landfill_disposal: \"Landfill disposal\",\n  direct_reuse: \"Direct re-use\",\n  treat_then_reuse: \"Treat, then re-use\",\n  treat_and_return: \"Treat and return\",\n  stockpile_hold: \"Stockpile and hold\",\n  recovery: \"Land recovery\",\n  supply_primary: \"Primary supply\",\n  supply_recycled: \"Recycled supply\",\n  supply_surplus: \"Surplus from site\",\n  site_won: \"Site-won processing\",\n}\n\nexport type TimingFit = \"good\" | \"tight\" | \"mismatch\"\n\nexport const TIMING_FIT_LABELS: Record<TimingFit, string> = {\n  good: \"Windows align\",\n  tight: \"Tight window\",\n  mismatch: \"Window mismatch\",\n}\n\nexport type OpportunityStatus = \"new\" | \"shortlisted\" | \"enquiry_sent\" | \"dismissed\"\n\nexport const OPPORTUNITY_STATUS_LABELS: Record<OpportunityStatus, string> = {\n  new: \"New\",\n  shortlisted: \"Shortlisted\",\n  enquiry_sent: \"Enquiry sent\",\n  dismissed: \"Dismissed\",\n}\n\nexport interface Opportunity {\n  id: string\n  requirementId: string\n  /** Card title, e.g. \"Direct re-use — Meadowbank Park\". */\n  name: string\n  strategy: OpportunityStrategy\n  /** The industry-default route others are compared against. */\n  isBaseline?: boolean\n  /** One-line pitch under the title. */\n  headline: string\n  /** Counterparty, where one exists. */\n  partner?: { name: string; siteName?: string }\n  route: RouteNode[]\n  totalDistanceKm: number\n  costs: CostLine[]\n  timing: { fit: TimingFit; note: string }\n  /** Absent where there is no matched counterpart (e.g. landfill). */\n  compatibility?: Compatibility\n  viability: Viability\n  compliance: ComplianceItem[]\n  status: OpportunityStatus\n}\n\n// ---------------------------------------------------------------------------\n// Derivations & formatting\n// ---------------------------------------------------------------------------\n\n/** Net estimated cost of a pathway (credits subtract). */\nexport function opportunityTotalCost(opportunity: Opportunity): number {\n  return opportunity.costs.reduce((sum, line) => sum + line.amount, 0)\n}\n\n/** True when any cost line is modelled rather than quoted. */\nexport function opportunityIsEstimated(opportunity: Opportunity): boolean {\n  return opportunity.costs.some((line) => line.estimated)\n}\n\n/** Positive = cheaper than the baseline pathway. */\nexport function savingsVsBaseline(\n  opportunity: Opportunity,\n  baseline: Opportunity\n): number {\n  return opportunityTotalCost(baseline) - opportunityTotalCost(opportunity)\n}\n\nexport function baselineOf(\n  opportunities: Opportunity[]\n): Opportunity | undefined {\n  return opportunities.find((opportunity) => opportunity.isBaseline)\n}\n\n/** £12,400 / −£3,150; whole pounds. */\nexport function formatMoney(amount: number): string {\n  const rounded = Math.round(Math.abs(amount))\n  const formatted = `£${rounded.toLocaleString(\"en-GB\")}`\n  return amount < 0 ? `−${formatted}` : formatted\n}\n\n/** Signed delta for savings copy: \"+£128,200\" (saving) / \"−£9,000\". */\nexport function formatMoneyDelta(amount: number): string {\n  return `${amount >= 0 ? \"+\" : \"−\"}£${Math.round(Math.abs(amount)).toLocaleString(\"en-GB\")}`\n}\n\nexport function formatPerTonne(\n  total: number,\n  requirement: MaterialRequirement\n): string {\n  if (!requirement.quantity.tonnes) return \"—\"\n  return `£${(total / requirement.quantity.tonnes).toFixed(2)}/t`\n}\n\nexport function formatDistanceKm(km: number): string {\n  return km < 10 ? `${km.toFixed(1)} km` : `${Math.round(km)} km`\n}\n\nexport function formatQuantity(requirement: MaterialRequirement): string {\n  const { value, unit, tonnes } = requirement.quantity\n  return `${value.toLocaleString(\"en-GB\")} ${unit} (~${tonnes.toLocaleString(\"en-GB\")} t)`\n}\n\nconst WINDOW_FORMAT = new Intl.DateTimeFormat(\"en-GB\", {\n  month: \"short\",\n  year: \"numeric\",\n})\n\nexport function formatWindow(window: { from: string; until: string }): string {\n  const from = WINDOW_FORMAT.format(new Date(window.from))\n  const until = WINDOW_FORMAT.format(new Date(window.until))\n  return from === until ? from : `${from} – ${until}`\n}\n\nexport function formatConfidence(confidence: number): string {\n  return `${Math.round(confidence * 100)}% confidence`\n}\n\n/** Sort: baseline last within equal verdicts; viable-first, cheapest-first. */\nconst VERDICT_ORDER: Record<ViabilityVerdict, number> = {\n  viable: 0,\n  conditional: 1,\n  unlikely: 2,\n  ruled_out: 3,\n}\n\nexport function rankOpportunities(opportunities: Opportunity[]): Opportunity[] {\n  return [...opportunities].sort((a, b) => {\n    const verdict = VERDICT_ORDER[a.viability.verdict] - VERDICT_ORDER[b.viability.verdict]\n    if (verdict !== 0) return verdict\n    return opportunityTotalCost(a) - opportunityTotalCost(b)\n  })\n}\n",
      "type": "registry:component",
      "target": "components/blocks/domain/opportunities/types.ts"
    },
    {
      "path": "components/blocks/domain/opportunities/viability-panel.tsx",
      "content": "import type * as React from \"react\"\nimport {\n  AlertCircle,\n  AlertTriangle,\n  CheckCircle,\n  Clock,\n  SlashOctagon,\n  Stars01,\n} from \"@untitledui/icons\"\n\nimport { Badge } from \"@/components/ui/badge\"\nimport { cn } from \"@/lib/utils\"\nimport { ViabilityBadge } from \"./opportunity-primitives\"\nimport {\n  formatConfidence,\n  type ConditionStatus,\n  type Viability,\n  type ViabilityCondition,\n  type ViabilityVerdict,\n} from \"./types\"\n\n/**\n * ViabilityPanel — the AI reasoning surface. Shows how the assessment was\n * reached, not just the verdict: the plain-English summary, a confidence\n * meter, the strengths and risks the model weighed, and the conditions the\n * pathway must still pass (each a checklist gate with its own status).\n * Ruled-out pathways swap the lists for a single error callout carrying the\n * reason, so a dead option never masquerades as an open one.\n */\n\nconst ASSESSED_FORMAT = new Intl.DateTimeFormat(\"en-GB\", {\n  day: \"numeric\",\n  month: \"short\",\n  year: \"numeric\",\n})\n\nconst CONFIDENCE_FILL: Record<ViabilityVerdict, string> = {\n  viable: \"bg-utility-green-500\",\n  conditional: \"bg-utility-amber-500\",\n  unlikely: \"bg-utility-neutral-400\",\n  ruled_out: \"bg-utility-red-500\",\n}\n\nconst CONDITION_META: Record<\n  ConditionStatus,\n  {\n    icon: React.ComponentType<{ className?: string }>\n    iconClass: string\n    label: string\n    variant: React.ComponentProps<typeof Badge>[\"variant\"]\n  }\n> = {\n  met: {\n    icon: CheckCircle,\n    iconClass: \"text-fg-success-primary\",\n    label: \"Met\",\n    variant: \"success\",\n  },\n  action_needed: {\n    icon: AlertCircle,\n    iconClass: \"text-fg-warning-primary\",\n    label: \"Action needed\",\n    variant: \"warning\",\n  },\n  pending: {\n    icon: Clock,\n    iconClass: \"text-fg-quaternary\",\n    label: \"Pending\",\n    variant: \"gray\",\n  },\n}\n\nfunction ConditionRow({ condition }: { condition: ViabilityCondition }) {\n  const meta = CONDITION_META[condition.status]\n  const Icon = meta.icon\n  return (\n    <li className=\"flex items-start gap-2.5\">\n      <Icon className={cn(\"mt-0.5 size-4 shrink-0\", meta.iconClass)} aria-hidden=\"true\" />\n      <div className=\"flex min-w-0 grow flex-col gap-0.5\">\n        <span className=\"text-sm font-medium text-text-secondary\">{condition.label}</span>\n        {condition.detail ? (\n          <span className=\"text-xs text-text-tertiary\">{condition.detail}</span>\n        ) : null}\n      </div>\n      <Badge variant={meta.variant} className=\"shrink-0\">\n        {meta.label}\n      </Badge>\n    </li>\n  )\n}\n\nfunction AssessmentList({\n  title,\n  items,\n  icon: Icon,\n  iconClass,\n}: {\n  title: string\n  items: string[]\n  icon: React.ComponentType<{ className?: string }>\n  iconClass: string\n}) {\n  if (items.length === 0) return null\n  return (\n    <div className=\"flex flex-col gap-2\">\n      <h4 className=\"text-xs font-semibold text-text-quaternary uppercase tracking-wide\">\n        {title}\n      </h4>\n      <ul className=\"flex flex-col gap-1.5\">\n        {items.map((item) => (\n          <li key={item} className=\"flex items-start gap-2.5\">\n            <Icon className={cn(\"mt-0.5 size-4 shrink-0\", iconClass)} aria-hidden=\"true\" />\n            <span className=\"text-sm text-text-secondary\">{item}</span>\n          </li>\n        ))}\n      </ul>\n    </div>\n  )\n}\n\nexport function ViabilityPanel({\n  viability,\n  className,\n}: {\n  viability: Viability\n  className?: string\n}) {\n  const ruledOut = viability.verdict === \"ruled_out\"\n\n  return (\n    <div\n      data-slot=\"viability-panel\"\n      className={cn(\n        \"flex flex-col gap-4 rounded-xl bg-card p-4 shadow-xs ring-1 ring-foreground/10\",\n        className\n      )}\n    >\n      <div className=\"flex flex-wrap items-center gap-2\">\n        <Stars01 className=\"size-4 text-fg-brand-primary\" aria-hidden=\"true\" />\n        <h3 className=\"text-sm font-semibold text-foreground\">Viability assessment</h3>\n        <ViabilityBadge viability={viability} showConfidence className=\"ml-auto\" />\n      </div>\n\n      <p className=\"text-sm text-text-secondary\">{viability.summary}</p>\n\n      <div className=\"flex flex-col gap-1.5\">\n        <div className=\"h-1.5 w-full overflow-hidden rounded-full bg-bg-quaternary\">\n          <div\n            className={cn(\"h-full rounded-full\", CONFIDENCE_FILL[viability.verdict])}\n            style={{ width: `${Math.round(viability.confidence * 100)}%` }}\n          />\n        </div>\n        <span className=\"text-xs text-text-tertiary tabular-nums\">\n          {formatConfidence(viability.confidence)}\n        </span>\n      </div>\n\n      {ruledOut && viability.ruledOutReason ? (\n        <div className=\"flex items-start gap-2.5 rounded-lg border border-utility-red-200 bg-utility-red-50 px-3 py-2.5\">\n          <SlashOctagon\n            className=\"mt-0.5 size-4 shrink-0 text-utility-red-700\"\n            aria-hidden=\"true\"\n          />\n          <div className=\"flex flex-col gap-0.5\">\n            <span className=\"text-sm font-semibold text-utility-red-700\">Ruled out</span>\n            <span className=\"text-sm text-utility-red-700\">{viability.ruledOutReason}</span>\n          </div>\n        </div>\n      ) : null}\n\n      <AssessmentList\n        title=\"Strengths\"\n        items={viability.strengths}\n        icon={CheckCircle}\n        iconClass=\"text-fg-success-primary\"\n      />\n      <AssessmentList\n        title=\"Risks\"\n        items={viability.risks}\n        icon={AlertTriangle}\n        iconClass=\"text-fg-warning-primary\"\n      />\n\n      {viability.conditions.length > 0 ? (\n        <div className=\"flex flex-col gap-2\">\n          <h4 className=\"text-xs font-semibold text-text-quaternary uppercase tracking-wide\">\n            Conditions\n          </h4>\n          <ul className=\"flex flex-col gap-2.5\">\n            {viability.conditions.map((condition) => (\n              <ConditionRow key={condition.id} condition={condition} />\n            ))}\n          </ul>\n        </div>\n      ) : null}\n\n      <p className=\"text-xs text-text-quaternary\">\n        Assessed by Nexus AI · {ASSESSED_FORMAT.format(new Date(viability.assessedAt))}\n      </p>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/blocks/domain/opportunities/viability-panel.tsx"
    },
    {
      "path": "components/blocks/domain/opportunities/viz-utils.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { ViabilityBadge } from \"./opportunity-primitives\"\nimport {\n  formatMoney,\n  formatMoneyDelta,\n  opportunityTotalCost,\n  savingsVsBaseline,\n  STRATEGY_LABELS,\n  VERDICT_LABELS,\n  type Opportunity,\n  type ViabilityVerdict,\n} from \"./types\"\n\n/**\n * Shared pieces for the visual surfaces (proximity map, cost flow, decision\n * grid): the verdict mark colours, the hover tooltip and the legend. Marks\n * wear these colours; text always stays on text tokens.\n */\n\n// ---------------------------------------------------------------------------\n// Verdict mark colours — theme-flipping fg steps (500 light / 400 dark)\n// ---------------------------------------------------------------------------\n\nexport const VERDICT_MARK_COLORS: Record<ViabilityVerdict, string> = {\n  viable: \"var(--color-fg-success-secondary)\",\n  conditional: \"var(--color-fg-warning-secondary)\",\n  unlikely: \"var(--color-fg-quaternary)\",\n  ruled_out: \"var(--color-fg-quaternary)\",\n}\n\n/** Soft fill for ribbon/area marks (a wash of the mark colour). */\nexport function verdictWash(verdict: ViabilityVerdict, pct = 24): string {\n  return `color-mix(in oklab, ${VERDICT_MARK_COLORS[verdict]} ${pct}%, transparent)`\n}\n\nexport const RULED_OUT_DASH = \"5 4\"\n\n/** Short display name: the part after the dash (\"Direct re-use — Meadowbank\n * Park\" → \"Meadowbank Park\"), else the full name. */\nexport function opportunityShortName(opportunity: Opportunity): string {\n  return opportunity.name.split(\"—\")[1]?.trim() || opportunity.name\n}\n\n// ---------------------------------------------------------------------------\n// Legend\n// ---------------------------------------------------------------------------\n\nconst LEGEND_VERDICTS: ViabilityVerdict[] = [\n  \"viable\",\n  \"conditional\",\n  \"unlikely\",\n  \"ruled_out\",\n]\n\n/** Verdict swatch row shown above every visual surface. */\nexport function VerdictLegend({\n  note,\n  className,\n}: {\n  /** Right-aligned caption, e.g. \"ribbon width = estimated net cost\". */\n  note?: string\n  className?: string\n}) {\n  return (\n    <div\n      data-slot=\"verdict-legend\"\n      className={cn(\n        \"flex flex-wrap items-center justify-between gap-x-4 gap-y-1\",\n        className\n      )}\n    >\n      <div className=\"flex flex-wrap items-center gap-x-3 gap-y-1\">\n        {LEGEND_VERDICTS.map((verdict) => (\n          <span\n            key={verdict}\n            className=\"flex items-center gap-1.5 text-xs text-text-tertiary\"\n          >\n            <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" aria-hidden=\"true\">\n              {verdict === \"ruled_out\" ? (\n                <circle\n                  cx=\"7\"\n                  cy=\"7\"\n                  r=\"4.5\"\n                  fill=\"none\"\n                  stroke={VERDICT_MARK_COLORS[verdict]}\n                  strokeWidth=\"1.5\"\n                  strokeDasharray=\"2.5 2\"\n                />\n              ) : (\n                <circle cx=\"7\" cy=\"7\" r=\"4.5\" fill={VERDICT_MARK_COLORS[verdict]} />\n              )}\n            </svg>\n            {VERDICT_LABELS[verdict]}\n          </span>\n        ))}\n      </div>\n      {note ? <p className=\"text-xs text-text-quaternary\">{note}</p> : null}\n    </div>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// Hover tooltip\n// ---------------------------------------------------------------------------\n\nexport interface VizTipState {\n  opportunity: Opportunity\n  x: number\n  y: number\n}\n\n/** Pointer-tracked tooltip state, positioned within a relative container. */\nexport function useVizTip() {\n  const [tip, setTip] = React.useState<VizTipState | null>(null)\n  const containerRef = React.useRef<HTMLDivElement>(null)\n\n  const move = React.useCallback(\n    (opportunity: Opportunity) => (event: React.MouseEvent) => {\n      const bounds = containerRef.current?.getBoundingClientRect()\n      if (!bounds) return\n      setTip({\n        opportunity,\n        x: event.clientX - bounds.left,\n        y: event.clientY - bounds.top,\n      })\n    },\n    []\n  )\n\n  /** Keyboard-focus fallback: pin the tip at a fixed point of the mark. */\n  const pin = React.useCallback(\n    (opportunity: Opportunity, x: number, y: number) => () =>\n      setTip({ opportunity, x, y }),\n    []\n  )\n\n  const clear = React.useCallback(() => setTip(null), [])\n\n  return { tip, move, pin, clear, containerRef }\n}\n\nexport function VizTooltip({\n  tip,\n  baseline,\n}: {\n  tip: VizTipState | null\n  baseline: Opportunity | undefined\n}) {\n  if (!tip) return null\n  const { opportunity } = tip\n  const cost = opportunityTotalCost(opportunity)\n  const savings =\n    baseline && !opportunity.isBaseline\n      ? savingsVsBaseline(opportunity, baseline)\n      : null\n  return (\n    <div\n      role=\"status\"\n      className=\"pointer-events-none absolute z-10 flex w-60 flex-col gap-1.5 rounded-lg border border-border bg-card p-3 shadow-lg\"\n      style={{\n        left: Math.min(tip.x + 14, 9999),\n        top: tip.y + 14,\n        transform: tip.x > 320 ? \"translateX(-100%) translateX(-24px)\" : undefined,\n      }}\n    >\n      <div className=\"flex items-start justify-between gap-2\">\n        <p className=\"text-sm leading-snug font-semibold text-foreground\">\n          {opportunityShortName(opportunity)}\n        </p>\n        <ViabilityBadge viability={opportunity.viability} />\n      </div>\n      <p className=\"text-xs text-text-tertiary\">\n        {STRATEGY_LABELS[opportunity.strategy]}\n        {opportunity.partner ? ` · ${opportunity.partner.name}` : null}\n      </p>\n      <p className=\"text-sm font-semibold text-foreground tabular-nums\">\n        ~{formatMoney(cost)}\n        {savings != null ? (\n          <span\n            className={cn(\n              \"ml-2 text-xs font-semibold\",\n              savings >= 0 ? \"text-success-primary\" : \"text-error-primary\"\n            )}\n          >\n            {formatMoneyDelta(savings)} vs baseline\n          </span>\n        ) : (\n          <span className=\"ml-2 text-xs font-medium text-text-quaternary\">\n            baseline\n          </span>\n        )}\n      </p>\n      {opportunity.viability.verdict === \"ruled_out\" &&\n      opportunity.viability.ruledOutReason ? (\n        <p className=\"text-xs text-text-tertiary\">\n          Ruled out: {opportunity.viability.ruledOutReason}\n        </p>\n      ) : null}\n      <p className=\"text-[11px] text-text-quaternary\">Click for the full pathway</p>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/blocks/domain/opportunities/viz-utils.tsx"
    }
  ],
  "type": "registry:block"
}